Skip to content

Commit 6ea34d8

Browse files
authored
fix(coding-agents): scope knowledge pages to the repository they are about (#3571)
A bank collects everything said IN a repository, which is not the same as everything said ABOUT it. A repo that reads its dependency's source, drafts its upstream issues or documents how it configures a service files those facts here too — correctly, since that is where the work happened. Nothing downstream could tell the two apart. Attribution tags (project:, harness:, workspace:) record where a fact ARRIVED from, never what it is ABOUT, and the knowledge:<tier> labels say what KIND of knowledge it is, never whose. By synthesis time the source document is gone and the fact reads as a bare technical decision. So "what are this project's key decisions?" was answered over everything the bank held, and a dependency's decisions were presented as the repo's own — upstream commit SHAs and all (#3476). Name the repository in every seeded page's source query and state the exclusion, so the synthesizer can make that call while it still has the fact's text in front of it. seedPages() already PATCHes a drifted query, so this re-syncs onto banks seeded by an earlier version rather than only new ones — which is why it rides on source_query and not the bank's reflect_mission, which is seeded ONCE and then belongs to whoever set it (#2492). Note the queries change, so the next refresh falls out of delta into one full rebuild per page — which is what re-cleans already-polluted pages.
1 parent f8b3988 commit 6ea34d8

4 files changed

Lines changed: 138 additions & 8 deletions

File tree

hindsight-integrations/coding-agents/src/core/hindsight.pages.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { HindsightClient } from "./hindsight";
3-
import { buildPageTrigger, PAGE_MAX_TOKENS, PAGES } from "./missions";
3+
import { buildPageTrigger, PAGE_MAX_TOKENS, pagesFor } from "./missions";
44
import { resolveConfig } from "./config";
55

6+
/** What a client built with `bank: "repo-a"` and no `project` seeds — the bank id is the fallback. */
7+
const PAGES = pagesFor("repo-a");
8+
69
afterEach(() => vi.restoreAllMocks());
710

811
function stubFetch(calls: any[], jsonImpl: () => Promise<unknown> = async () => ({ ok: true })) {
@@ -279,6 +282,78 @@ describe("HindsightClient.seedPages", () => {
279282
tags: drifted.tags,
280283
});
281284
});
285+
286+
it("names the repository in every seeded query, so synthesis can exclude a dependency's facts", async () => {
287+
const calls: any[] = [];
288+
stubFetchRouted(calls, [
289+
{ match: (m, u) => m === "GET" && u.endsWith("/knowledge-base/tree"), json: { roots: [] } },
290+
]);
291+
const c = new HindsightClient({
292+
apiUrl: "http://x",
293+
bank: "coding-agent::dotfiles",
294+
project: "dotfiles",
295+
});
296+
await c.seedPages();
297+
298+
const posts = calls.filter(
299+
(k) => k.method === "POST" && k.url.endsWith("/knowledge-base/pages")
300+
);
301+
expect(posts).toHaveLength(PAGES.length);
302+
for (const post of posts) {
303+
// The repo is NAMED (not "this project"), and the exclusion is stated — the bank holds
304+
// facts about dependencies the repo merely discusses, and they are not its own (#3476).
305+
expect(post.body.source_query).toContain("dotfiles");
306+
expect(post.body.source_query).toMatch(/external tools, libraries and services/);
307+
expect(post.body.source_query).toMatch(/dependency/);
308+
}
309+
});
310+
311+
it("falls back to the bank id when no project is supplied, never an unscoped query", async () => {
312+
const calls: any[] = [];
313+
stubFetchRouted(calls, [
314+
{ match: (m, u) => m === "GET" && u.endsWith("/knowledge-base/tree"), json: { roots: [] } },
315+
]);
316+
const c = new HindsightClient({ apiUrl: "http://x", bank: "coding-agent::dotfiles" });
317+
await c.seedPages();
318+
319+
for (const post of calls.filter((k) => k.method === "POST")) {
320+
expect(post.body.source_query).toContain("coding-agent::dotfiles");
321+
}
322+
});
323+
});
324+
325+
describe("pagesFor", () => {
326+
it("scopes every page in the taxonomy to the named repository", () => {
327+
const pages = pagesFor("dotfiles");
328+
expect(pages).toHaveLength(5);
329+
for (const page of pages) {
330+
expect(page.source_query).toContain("Scope this page to dotfiles ITSELF");
331+
}
332+
});
333+
334+
it("is a pure function of the project, so a re-seed does not PATCH the same query back", () => {
335+
// seedPages() compares the live description against this text on every deepen run; anything
336+
// varying per call (a timestamp, a set iteration order) would re-PATCH all five pages forever.
337+
expect(pagesFor("dotfiles")).toEqual(pagesFor("dotfiles"));
338+
expect(pagesFor("dotfiles")).not.toEqual(pagesFor("other-repo"));
339+
});
340+
341+
it("keeps the taxonomy's names and tier tags untouched", () => {
342+
expect(pagesFor("dotfiles").map((p) => p.name)).toEqual([
343+
"Component map",
344+
"Core concepts",
345+
"Conventions and patterns",
346+
"Key decisions and rationale",
347+
"Initiatives and enhancements",
348+
]);
349+
expect(pagesFor("dotfiles").flatMap((p) => p.tags)).toEqual([
350+
"knowledge:component",
351+
"knowledge:concept",
352+
"knowledge:convention",
353+
"knowledge:decision",
354+
"knowledge:feature-work",
355+
]);
356+
});
282357
});
283358

284359
/** Route JSON responses by (method, url-substring) so multi-call flows can return distinct bodies. */

hindsight-integrations/coding-agents/src/core/hindsight.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
CODING_BANK_STRUCTURE,
1111
CODING_BANK_TEMPLATE,
1212
PAGE_MAX_TOKENS,
13-
PAGES,
13+
pagesFor,
1414
type PageTrigger,
1515
} from "./missions";
1616
import { pool, semverGte, sleep } from "./util";
@@ -30,6 +30,10 @@ export interface ClientOpts {
3030
apiUrl: string;
3131
apiToken?: string;
3232
bank: string;
33+
/** Repository this bank is about, named in every seeded page's query (`pageScopeRule`). Only
34+
* `seedPages()` reads it; it falls back to the bank id, which carries the repo name in the
35+
* default `coding-agent::{gitProject}` template. */
36+
project?: string;
3337
log?: (msg: string) => void;
3438
/** Cap on concurrent retain-related requests (drain op polls, deepen pools). Default 10. */
3539
maxParallelRetains?: number;
@@ -114,6 +118,7 @@ export class HindsightClient {
114118
readonly apiUrl: string;
115119
readonly apiToken?: string;
116120
readonly bank: string;
121+
readonly project?: string;
117122
readonly opIds: string[] = []; // async operation ids collected by retain(), for drain()
118123
/** Tri-state capability probe: unknown until the first page request, then cached. */
119124
knowledgePagesSupported: boolean | undefined;
@@ -126,6 +131,7 @@ export class HindsightClient {
126131
this.apiUrl = o.apiUrl.replace(/\/$/, "");
127132
this.apiToken = o.apiToken;
128133
this.bank = o.bank;
134+
this.project = o.project;
129135
this.log = o.log ?? (() => {});
130136
this.maxParallelRetains = o.maxParallelRetains || DEFAULT_MAX_PARALLEL_RETAINS;
131137
}
@@ -470,16 +476,18 @@ export class HindsightClient {
470476
}
471477

472478
/**
473-
* Seed the fixed `PAGES` taxonomy as knowledge-base pages at the tree root, idempotently.
479+
* Seed the fixed page taxonomy as knowledge-base pages at the tree root, idempotently.
474480
*
475481
* Matched by NAME, not id: `/knowledge-base/pages` mints its own `kp-…` id, so a stable
476482
* client-chosen id isn't available to match on (unlike the old mental-model path, which keyed
477483
* off a slug). Names are unique per folder server-side, which makes them a sound key.
478484
*
479485
* An existing page is PATCHed rather than recreated so a plugin upgrade that rewords a
480-
* `source_query` re-syncs onto the live page instead of orphaning its synthesized content.
486+
* `source_query` re-syncs onto the live page instead of orphaning its synthesized content —
487+
* which is how `pageScopeRule`'s repo name reaches banks seeded by an earlier version.
481488
*/
482489
async seedPages(pageTrigger: PageTrigger = buildPageTrigger()): Promise<void> {
490+
const pages = pagesFor(this.project ?? this.bank);
483491
const existing = new Map<string, KnowledgeNode>();
484492
let roots: KnowledgeNode[];
485493
try {
@@ -496,7 +504,7 @@ export class HindsightClient {
496504
}
497505
let created = 0;
498506
let updated = 0;
499-
for (const page of PAGES) {
507+
for (const page of pages) {
500508
const hit = existing.get(page.name.toLowerCase());
501509
const body = {
502510
name: page.name,
@@ -536,7 +544,7 @@ export class HindsightClient {
536544
}
537545
this.log(
538546
`[bank] knowledge pages seeded on ${this.bank}: ${created} created, ${updated} re-synced, ` +
539-
`${PAGES.length - created - updated} unchanged`
547+
`${pages.length - created - updated} unchanged`
540548
);
541549
}
542550

hindsight-integrations/coding-agents/src/core/missions.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,46 @@ export const KNOWLEDGE_LABELS: EntityLabelGroup = {
185185
// any repo; the curator populates each from history+chats and can spawn per-component sub-pages.
186186
// A seeded page is a tag-scoped synthesis view: `tags` pins it to one `knowledge:<tier>` label so
187187
// its synthesis draws from the facts the extractor routed to that tier (exact set-ops — see
188-
// KNOWLEDGE_LABELS above; names/tiers mirror the label vocabulary).
188+
// KNOWLEDGE_LABELS above; names/tiers mirror the label vocabulary). The tiers say what KIND of
189+
// knowledge a fact is, never WHOSE — `pageScopeRule` below carries that half.
189190
export interface KnowledgePage {
190191
name: string;
191192
source_query: string;
192193
tags: string[];
193194
}
194195

195-
export const PAGES: KnowledgePage[] = [
196+
/**
197+
* The subject-scoping clause every seeded page's query carries, naming the repository it is about.
198+
*
199+
* A bank collects everything said IN a repository, which is NOT the same as everything said ABOUT
200+
* it: a repo that reads its dependency's source, drafts its upstream issues, or documents how it
201+
* configures a service files those facts here too — correctly, since that is where the work
202+
* happened. Nothing downstream can tell the two apart. Attribution tags (`project:`, `harness:`,
203+
* `workspace:`) record where a fact ARRIVED from, never what it is ABOUT, and by synthesis time the
204+
* source document is gone: the fact reads as a bare technical decision with no hint whose codebase
205+
* it belongs to. So the page builder answered "what are this project's key decisions?" over
206+
* everything the bank held and presented a dependency's decisions as the repo's own, upstream
207+
* commit SHAs and all (#3476).
208+
*
209+
* Naming the repo and stating the exclusion is what lets the synthesizer make that call while it
210+
* still has the fact's text in front of it. It rides on `source_query` rather than the bank's
211+
* `reflect_mission` because the mission is seeded ONCE and then belongs to whoever set it
212+
* (CODING_BANK_STRUCTURE, #2492) — a mission-only fix would never reach an existing bank, while a
213+
* reworded query re-syncs through `seedPages()`'s drift PATCH on the next run.
214+
*/
215+
function pageScopeRule(project: string): string {
216+
return (
217+
` Scope this page to ${project} ITSELF: the bank also holds facts about external tools, ` +
218+
`libraries and services that ${project} merely uses, configures, deploys or discusses, and ` +
219+
`those belong to somebody else's codebase. Include something only when its subject is ` +
220+
`${project}'s own code, configuration or process; when it is about a dependency, leave it out ` +
221+
`however well-evidenced it looks — including any commit SHA or identifier that belongs to that ` +
222+
`dependency's repository rather than this one.`
223+
);
224+
}
225+
226+
/** The taxonomy before scoping — never seeded directly; `pagesFor` binds it to a repository. */
227+
const PAGE_TAXONOMY: readonly KnowledgePage[] = [
196228
{
197229
name: "Component map",
198230
source_query:
@@ -236,6 +268,17 @@ export const PAGES: KnowledgePage[] = [
236268
},
237269
];
238270

271+
/**
272+
* The seeded pages for one repository: the taxonomy above with `project` named in every query.
273+
*
274+
* A pure function of `project`, so the query text is STABLE for a given repo and `seedPages()`
275+
* PATCHes once (on the upgrade that introduces the clause) rather than on every deepen run.
276+
*/
277+
export function pagesFor(project: string): KnowledgePage[] {
278+
const scope = pageScopeRule(project);
279+
return PAGE_TAXONOMY.map((page) => ({ ...page, source_query: page.source_query + scope }));
280+
}
281+
239282
// Refresh policy shared by every page this plugin creates — the seeded taxonomy above and the
240283
// per-initiative pages `captureInitiative` adds.
241284
export const PAGE_MAX_TOKENS = 4096;

hindsight-integrations/coding-agents/src/deepen.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ async function main() {
133133
apiUrl: API_URL,
134134
apiToken: API_TOKEN,
135135
bank: FINAL_BANK!,
136+
// Names the repository in every seeded page's query, so page synthesis can tell this
137+
// project's decisions from those of a dependency it merely discusses (#3476). Same
138+
// worktree-aware name the gitlog document id uses, so all worktrees agree on it.
139+
project: repoNameOf(REPO!),
136140
maxParallelRetains: cfg.maxParallelRetains,
137141
log,
138142
});

0 commit comments

Comments
 (0)