Skip to content

feat(recall): scope teamwiki codebase recall by project/role (#912) - #974

Open
STiFLeR7 wants to merge 21 commits into
Tencent:mainfrom
STiFLeR7:feat/912-scope-wiki-recall-by-project
Open

STiFLeR7 wants to merge 21 commits into
Tencent:mainfrom
STiFLeR7:feat/912-scope-wiki-recall-by-project

Conversation

@STiFLeR7

@STiFLeR7 STiFLeR7 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Docs and learnings are already scoped by role/project namespace (#707), but teamwiki was not. A checkout active on one project (e.g. --project svc-a) got teamai recall hits from every codebase's evidence/code/<slug>/, including slugs that belong to an unrelated project — recall would surface another project's codebase evidence just because it lived in the same team wiki.

Fix

Adds a hand-declared wiki resource type in manifest-schema.ts, following the exact pattern docs already uses:

  • HAND_DECLARED_RESOURCE_TYPES gains 'wiki' — this alone threads it through the existing generic plumbing in roles.ts/projects.ts (NAMESPACED_RESOURCE_TYPES, mergeNamespaces, resolveRoleResourceNamespaces, resolveProjectResourceNamespaces already loop over HAND_DECLARED_RESOURCE_TYPES generically), with no changes needed there.
  • resource-namespaces.ts computes inactiveWikiNamespaces the same way it already computes inactiveDocsNamespaces: a slug ANY role or project declares under resources.wiki is withheld unless this directory's active role/project selects it; an undeclared slug stays shared (no change for existing teams).
  • code-knowledge-recall.ts's loadWikiPages/queryCodeKnowledge take a new withheldCodebases option and filter evidence/code/<slug>/ directories by it, case-folded.
  • recall.ts wires the two together: computes withheldCodebases from the active project config via resolveResourceNamespaces and passes it to queryCodeKnowledge.

The slug is whatever teamai codebase --project <slug> wrote — unrelated to a manifest project id — so a team declares the one it already uses:

# manifest/projects.yaml
projects:
  - id: svc-a
    resources:
      wiki: [svc-a]     # evidence/code/svc-a/ only where svc-a is active

Review follow-up round 1: route depth and the knowledge graph also needed scoping

codex-review found the page-directory filtering above left two more surfaces unscoped:

  • --depth route returned the global teamwiki/router.md verbatim, which lists every codebase's name/link/description/keywords plus a <!-- search-anchor --> keyword comment. Fixed by stripping withheld-codebase lines and the anchor comment from the content before returning it.
  • .indices/graph-index.json was loaded unfiltered at context/lookup depth, so a withheld codebase's nodes could match as BM25 entry nodes, boost scores via graph neighbors, or surface a withheld codebase's own node id through an allowed page's relatedFiles via a cross-repo DEPENDS_ON edge.

Also fixed the two propagation gaps the same review round flagged: the "upgrade every member first" warning and the directory-naming-rule list in manage-admin.md now include wiki alongside env/hooks/mcp/models/docs, and the --namespaces CLI help text (+ generated commands.md) and docs/designs/multi-project-management.md's resource-type enumeration now mention wiki too.

Review follow-up round 2: the round-1 router fix missed a link format, and the round-1 graph fix dropped legitimate global-only edges

  • Router filtering only recognized [[evidence/code/<slug>/index]] links (routerTemplate's format). rebuildWikiIndex() — the path actually taken after an import — writes [[code/<slug>/index]] table rows instead, with no evidence/ prefix, so a codebase imported that way still leaked its full row. Fixed by widening the line-match regex to accept an optional evidence/ prefix before code/<slug>.
  • The round-1 graph fix rebuilt the scoped graph from only the allowed per-repo graph files. That discards anything that exists only in the global .indices/graph-index.json — chiefly teamai codebase --reconcile's product↔code MAPS_TO edges, since the reconciler reads and writes the global graph directly and never touches a per-repo file. Activating any wiki restriction therefore silently dropped those mappings even for codebases that were still fully allowed. Fixed by flipping the approach: scopeGlobalGraph (replacing buildAggregatedGraph's excludeProjects) now starts from the real global graph and subtracts exactly the withheld codebase's own node slugs (read from its per-repo file, which still correctly names every fact-level node it contributed, prefixed or not), then drops any edge left dangling from a removed endpoint. That dangling-edge cleanup is what still removes a cross-repo DEPENDS_ON edge into a withheld node — the original leak this graph-scoping effort started from — without needing to know the edge came from cross-repo detection rather than a per-repo file.
  • (P2) resolveResourceNamespaces() in recall.ts ran outside the existing code-retrieval try/catch, so an unreadable or malformed manifest rejected the whole recall call even when a valid learnings index had already been searched safely. Moved it inside the same try, so it now degrades the same way a queryCodeKnowledge failure already does: skip code recall, warn, keep the rest of the results.

Review follow-up round 3: scopeGlobalGraph itself (round 2's fix) over-pruned, plus a real but smaller slug-collision ambiguity

  • scopeGlobalGraph's edge filter required both endpoints to survive as nodes (keptSlugs.has(e.from) && keptSlugs.has(e.to)). But an AST/heuristic edge is commonly file-to-file ({from: 'src/a.ts', to: 'src/b.ts'}) with neither endpoint present in nodes[] at all — so the moment anything was withheld, every such edge vanished for every codebase, allowed ones included, dropping their files from relatedFiles/"Candidate change files". Fixed by subtracting by identity instead: collect every node slug and edge endpoint a withheld codebase's own per-repo file contributed, then drop only edges actually touching one of those — not edges that merely fail to resolve to a known node.
  • Fact-level node slugs are not repo-qualified (buildCodeGraph mints component/App the same way for any repo), so an allowed and a withheld repo can legitimately collide on one slug after merging; subtracting by slug alone would also remove the allowed repo's node. Fixed by also collecting every allowed codebase's own identifiers and clearing any that overlap with the withheld set before subtracting — a shared identifier is never removed.
  • (P2) docs/usage-guide.md (+ zh-CN) still said --namespaces "neither touches env, hooks, mcp, models or docs", omitting wiki, and CHANGELOG.md's feat(projects): extend project isolation to knowledge/docs/agents/env/hooks/mcp #707 entry enumerating resources: keys was stale the same way. Added wiki to all three.

Review follow-up round 4: router filtering missed the AI-domain branch's unlinked lines, and scopeGlobalGraph missed reconcile-added code-page nodes

  • Router filtering only removed individual lines carrying a code/<slug> link. routerTemplate()'s AI-domain branch, though, emits a ### <domain> header line with no link at all, and an unresolved component (one whose name does not match any known project) as a bare - <name> line with no link either. Once a withheld codebase's properly-linked sibling lines were removed, an all-withheld domain's header — and any such unlinked fallback line under it — survived untouched, still naming the domain and component. Fixed by grouping router.md into sections at each markdown header first: a section whose links are all withheld (and at least one was found) is now dropped whole, header and any unlinked lines included; a section mixing allowed and withheld links still keeps its header and drops only the withheld lines, as before.
  • scopeGlobalGraph identified withheld content solely from per-repo graph files, but teamai codebase --reconcile adds code-page nodes (evidence/code/<slug>/<page>) and their MAPS_TO edges straight to the global graph, the same way it adds product-page nodes — never to a per-repo file. After reconciling and then withholding that codebase, those nodes/edges survived, so a query matching the withheld page's title could still use it as an entry node or graph boost. Fixed with a second pass over the global graph's own nodes, matched by the evidence/code/<slug>/ prefix (which, unlike a bare fact-level slug, unambiguously names the codebase that owns it, so there is no allowed/withheld collision risk the way there is for prefix-less slugs).

Review follow-up round 5: scopeGlobalGraph still failed open when a withheld codebase has no per-repo graph file at all, plus a narrower colliding-edge gap

  • [P1] teamai codebase --extract run directly (not through teamai import's cache-then-copy orchestration) writes only the global teamwiki/.indices/graph-index.json and never populates evidence/code/<slug>/.indices/graph-index.json for that codebase. scopeGlobalGraph's withheld-identifier collection only reads that per-repo path, so a codebase extracted this way had no identifiers collected for it at all when withheld — its fact-level nodes and edges stayed in the "scoped" graph fully exposed. Fixed by failing closed, not open: if any withheld codebase's per-repo graph file is missing or unreadable, scopeGlobalGraph now returns null (no graph at all for this query) rather than a result it cannot vouch for. queryCodeKnowledge already treats a null graph as "skip graph-based features," the same as when no graph exists at all, so no consumer-side change was needed.
  • [P2] Clearing an identifier shared between an allowed and withheld repo (round 3's fix for colliding fact-level slugs like component/App) correctly keeps both repos' nodes, but a withheld repo can also have an edge directly between two such colliding names — a relationship that only ever existed in the withheld repo, which neither endpoint-identifier removal nor the "shared identifier survives" rule catches, since both endpoints end up allowed. Fixed by tracking edge pairs (from|to) the same way identifiers are tracked: a withheld-only pair is subtracted even when both of its endpoints individually survive; a pair also present in an allowed repo's own graph is still preserved.

Review follow-up round 6: a mixed router section still leaked an unattributable line, a colliding node's metadata could still be the withheld repo's, and two smaller gaps

  • [P1] A mixed router domain section correctly dropped withheld-linked lines, but a bare unresolved-component fallback line (routerTemplate's "no project match" branch, no link at all) survived regardless, since lineSlug() can never attribute an unlinked line to either side. filterRouterContent only ever runs when something IS withheld, so that ambiguity can't be resolved safely either way — fixed by dropping every unlinked bullet line unconditionally, the same fail-closed-on-ambiguity rule scopeGlobalGraph already applies to graph ownership it cannot verify.
  • [P1] Clearing a slug an allowed repo also claims (round 5's fix) correctly kept the node, but never corrected its data: mergeGraphs lets the later-processed repo's node win outright with no field-level merge, so the surviving global node could still carry the withheld repo's title/domain if that repo's write happened to win — querying that title could then select it as an entry node and affect scoring. Fixed by re-attaching the allowed repo's own copy of a contested node (read in the same per-repo scan already in place). This also surfaced a second bug while writing the test for it: the existing "nothing to remove" fast path returned the graph untouched before this restoration ever ran, for the exact case where the only withheld content is a slug that's also allowed (nothing to remove, but still something to correct) — fixed by tracking contested slugs before the allowed set clears them out of the withheld set, and including that count in the fast-path's guard.
  • [P2] The edge-ownership key omitted relation, so an allowed App -REFERENCES-> Config cleared a withheld App -DEPENDS_ON-> Config out of the withheld set even though it never claimed that specific relation — both edges then survived. Fixed by keying edges on from|to|relation, matching the graph schema's own edge identity.
  • [P2] usage-guide.md (+ zh-CN) said wiki scoping needs a team repo "with manifest/projects.yaml", but a role-only declaration in manifest/roles.yaml works with no projects.yaml at all. Reworded to not imply projects.yaml is required.

Review follow-up round 7: two more real gaps inside scopeGlobalGraph itself, plus an edge-identity nit

  • [P1] A withheld codebase's per-repo graph file was marked "accounted for" the moment JSON.parse succeeded, with no check that the result was actually a graph. A truncated or otherwise corrupted write can leave syntactically valid JSON ({}) that is not a graph at all — nodes ?? [] / edges ?? [] would silently contribute zero identifiers while the global graph still has that codebase's real content, so scoping would run as if there were nothing to subtract. Fixed by validating the parsed result has nodes/edges arrays before marking it accounted for; anything else now falls into the same fail-closed path as an unreadable file.
  • [P1] Per-repo-file edge-ownership keys used the raw relation string, but loadGraphIndex normalizes the legacy imports relation name to DEPENDS_ON when it loads the global graph. scopeGlobalGraph reads per-repo files with a plain JSON.parse, bypassing that normalization — so a withheld repo's own imports edge computed a key that could never match the already-normalized DEPENDS_ON edge in the loaded global graph, letting that withheld relationship survive. Fixed by normalizing the relation the same way before computing the key (exported LEGACY_RELATIONS from graph-index.schema.ts rather than duplicating the mapping).
  • [P3] The edge-ownership key concatenated from|to|relation with a plain |, which a |-containing identifier could alias (a|b -> c vs a -> b|c). Switched to JSON.stringify([from, to, relation]), matching graphEdgeKey's own approach in graph-index.schema.ts.

Review follow-up round 8: the round-7 validation was itself too shallow, and it explains a second metadata-restoration gap

  • [P1] The "structural validation" added last round only checked that nodes/edges were arrays. A node that doesn't conform to GraphNodeSchema (missing required fields, wrong types) still passed that check and was silently skipped (no slug extracted) while the whole file still counted as "successfully read ownership from" — so a withheld codebase with any schema-invalid nodes mixed into real ones could leave those real nodes exposed. Fixed by reusing parseGraphIndex (now exported from graph-index.schema.ts) — the exact schema loadGraphIndex validates the global graph with — instead of a loose array check.
  • [P1] Restoring a contested node's metadata (round 6's fix) spread the RAW per-repo JSON object over the surviving global node. For a node using the legacy id/label/kind shape, the raw object has no title key at all (only label) — so the spread never actually overrode title, leaving the withheld repo's title in place if its write had won the merge. Fixed by the same parseGraphIndex reuse: it normalizes label→title, id→slug, kind→type the same way the global graph itself was normalized on load, so the node now stored for restoration carries the right field names. (This reuse also let the hand-rolled relation-normalization helper added last round come back out — parseGraphIndex's edge schema already does it.)

Review follow-up round 9: a linkless all-withheld domain still leaked its header, and a schema-valid-but-empty withheld graph file was trusted too readily

  • [P1] A router domain section whose components ALL fell back to a bare, unlinked line (every one failed routerTemplate's project match) has no code/<slug> links at all. The section-level "all-withheld" check requires finding at least one withheld link to decide a section is fully withheld, so an empty slugs array never triggered it — the bare bullets still got dropped individually, but the ### <domain> header above them had nothing left to filter it by and survived, still naming the domain. Fixed by deciding at the bullet level instead of the slug level: if a section had any bullets at all and NONE of them survive line filtering — withheld-linked or unattributably bare — the whole section (header included) is now dropped.
  • [P1] scopeGlobalGraph already failed closed on a withheld codebase's per-repo file that's missing, unreadable, or schema-invalid — but a schema-valid, entirely empty one ({nodes: [], edges: []}) still parsed cleanly and was marked accounted for while contributing nothing to subtract. If that file goes stale or is truncated after an earlier successful aggregation, the global graph can still hold the withheld codebase's real nodes with nothing left to remove them. A genuine extraction's per-repo graph is never actually empty (buildIndexHubOverlay unconditionally adds the project's own index/hub node whenever extraction produces any page), so an empty graph for a withheld codebase is now distrusted the same as an invalid one.

Review follow-up round 10: every per-repo-file fail-closed guard shared one root assumption that an interrupted import can break

  • [P1] Every guard added over the last several rounds (missing, unreadable, schema-invalid, empty) still assumed a withheld codebase's current per-repo file completely and accurately describes everything it ever contributed to the global graph. An interrupted teamai import breaks exactly that: it can replace a withheld codebase's per-repo graph with a different, valid, non-empty one and be interrupted before the next aggregation folds the change in, leaving the global graph holding OLD nodes/edges that no longer appear anywhere in the per-repo file scopeGlobalGraph would read. No amount of validating that file harder closes this — it can be perfectly valid and simply describe something else now.

    Fixed at the root, per the review's own suggestion: ownership is now stamped onto the data at the moment it is merged, not re-inferred from a separate, independently-mutable file at query time. buildAggregatedGraph tags every node and edge from a per-repo file with origin: <that codebase's slug> before merging it in (origin is now a first-class optional field on GraphNode/GraphEdge). That tag travels with the data in the global graph forever after, correct regardless of whatever the per-repo file is later rewritten to, emptied to, or deleted to. scopeGlobalGraph now checks tags first: a withheld codebase with ANY origin-tagged content in the global graph is "tag-covered," and its removal is read directly off those tags — no per-repo file involved at all. Only a codebase tagging has never covered (predates this field, or was extracted directly via teamai codebase --extract outside teamai import's per-repo-file-producing path) falls back to the existing per-repo-file mechanism, with all its existing fail-closed guards intact for exactly that narrower, legacy case. Allowed codebases are still always read regardless of tagging, since collision handling (two codebases sharing one unqualified fact-level slug) needs an allowed codebase's own data to restore — the merged graph keeps only one node per colliding slug, so tagging alone can't tell "this slug is ALSO legitimately an allowed codebase's."

Review follow-up round 11: a reconcile-materialized endpoint node had no origin of its own, plus a docs gap

  • [P1] teamai codebase --reconcile repairs a code-ast/code-heuristic edge whose endpoint isn't a node yet by minting a placeholder node for it (loadReconciliationBase's endpointNodes loop). That placeholder never copied the edge's own origin tag, so round 10's tag-based removal — which skips the per-repo-file fallback entirely once a withheld codebase has ANY tagged content ("tag-covered") — correctly removed the withheld codebase's tagged edge but left this origin-less placeholder node behind and searchable, letting withheld content affect entry-node matching and graph boosts. Both endpoints of an intra-repo AST/heuristic edge belong to the same codebase that produced the edge, so the fix stamps origin: edge.origin on the node at the moment it is minted — closing the gap at creation time, consistent with round 10's "tag ownership once, at the source" approach, rather than adding another special case to scopeGlobalGraph itself (which needed no changes: its existing origin-tag pass already removes a tagged node).
  • [P2] product-overview.md's resource-namespace summary (and its zh-CN counterpart) documented that skills/rules/docs/etc. can live under a <namespace>/ subdirectory, but never mentioned that the Team Context codebase knowledge graph below it is scoped the same way via resources.wiki (already documented in depth in usage-guide.md, just missing a pointer from the higher-level overview). Added a one-line cross-reference in both.

Review follow-up round 12: a cross-repo edge could outlive a slug collision, and an originless edge endpoint slipped past the reconcile-added-content pass

  • [P2] detectCrossRepoEdges synthesizes an edge spanning two codebases (by label match) and left it fully untagged — correct in isolation, since no single origin fully describes a two-codebase edge. But if the matched node's unqualified slug was later also minted by a THIRD, allowed repo and that repo's write won the merge (collision handling's "allowed repo's node survives" rule, working as designed), the edge then had no way to be tagged withheld at all: the node it pointed at no longer carried the withheld repo's origin, and the edge itself never did either. Fixed by tagging the edge, at the exact moment detectCrossRepoEdges matches it, with the origin of whichever side was found via label lookup in the OTHER graph — the specific codebase this edge's existence actually depends on, captured before any later collision can silently reattribute it. scopeGlobalGraph needed no changes: its edge-level origin-tag pass already removes any edge once it carries a withheld tag.
  • [P2] The evidence/code/<slug>/ prefix pass that catches --reconcile-added content (nodes/edges written straight to the global graph, never to a per-repo file) only ever scanned nodes[]. import-iwiki.ts has its own, separate direct-to-global-graph writer that can add a MAPS_TO edge pointing at a code page path (evidence/code/<slug>/<page>.md) purely because a doc term appears somewhere in that page's text — with no node ever created for the page itself. That edge's endpoint was therefore never in nodes[] to be matched by the prefix check, so it survived scoping untouched. Fixed by running the identical prefix check over edge endpoints too.

Review follow-up round 13: a cross-repo edge needs BOTH its origins tracked, not just the matched side

  • [P1] Round 12 tagged a synthesized cross-repo edge with only the origin of whichever side detectCrossRepoEdges matched by label lookup. But the relationship depends on BOTH sides — the repo whose own import produced the match, AND the repo that was matched. The review's own example: withheld svc-b imports allowed svc-a, while allowed svc-c happens to share svc-b's unqualified SOURCE-node slug (not the matched/target side round 12 tested). Collision handling correctly lets svc-c's node survive, but the edge's lone tag was the matched side's origin (svc-a, allowed) — so withholding svc-b left the edge with no path to removal at all, even though it only exists because of svc-b's own import. Fixed by replacing the single-valued tag on a cross-repo edge with a new GraphEdge.crossOrigins: string[] field carrying BOTH sides' origins, captured at the moment of detection — before either endpoint can be silently reattributed by a later collision. scopeGlobalGraph withholds the edge if EITHER entry is in the withheld set.

Review follow-up round 14: the importing side's origin could itself be stale, a legacy cross-edge had no removal path at all, and a merge collision could silently drop provenance

  • [P1] detectCrossRepoEdges's reverse scan (existing.edges matched against the new repo) derived the importing side's origin by re-looking-up whichever node CURRENTLY sits at the import edge's from slug in existing.nodes — but existing accumulates every repo processed so far, so if a withheld importer's node slug was already overwritten by a LATER, unrelated allowed repo before the repo it imports gets processed, that lookup returns the wrong (allowed) origin. The synthesized edge then ends up tagged with two allowed origins and survives scoping entirely, even though it only exists because of the withheld repo's own import. Fixed by preferring the import edge's OWN origin tag (edge.origin, stamped once and immune to any later node collision) over a fresh node lookup, in both the forward and reverse scans; falling back to the node lookup only for edges that themselves predate origin tagging.
  • [P1] Per-repo files never list a synthesized cross-repo edge at all (cross edges are written straight to the global graph at aggregation time, never to any per-repo file) — so the per-repo-file fallback mechanism (used only for a withheld codebase origin tagging has never covered) has no way to ever discover, let alone subtract, one. When such a codebase's node survives a slug collision with an allowed repo (correct, standard collision handling), a cross-repo edge that predates origin/crossOrigins tagging entirely and touches that slug has no remaining path to removal. Retroactively recovering that edge's true provenance isn't possible — the information needed to attribute it was never persisted. Fixed by failing closed instead: if a fallback-withheld codebase's contested slug is the endpoint of an edge that looks like a legacy, unattributable cross-repo edge (DEPENDS_ON relation, no origin, no crossOrigins, no source), scopeGlobalGraph now returns null rather than risk exposing it. Scoped precisely to fallback-sourced contested slugs so it cannot fire for the common, fully tag-covered case.
  • [P2] mergeGraphs deduplicates edges by from|to|relation and let whichever side won the tie-break silently discard the OTHER side's crossOrigins outright. If two DIFFERENT codebases independently produce a cross-repo edge with the identical identity (e.g. both import something matching the same third repo's component under an equally generic importer slug of their own), the edge's final origin set — and therefore whether withholding one of them removes it — depended on which repo happened to merge last. Fixed by unioning crossOrigins on collision instead of overwriting, making removal correctly trigger regardless of merge order (though a single edge-key still can't distinguish two fully-independent, unmerged provenances enough to let one survive while only the other is withheld — an edge touched by any withheld origin is still removed, erring toward the safe/fail-closed direction for this narrow, rare case).

Review follow-up round 15: a flat origin set on a cross-repo edge discarded a different, fully-allowed repo pair's equally valid claim on it

  • [P2] Round 14's crossOrigins: string[] fix unioned both sides' origins on a merge collision, but flattened them: if withheld svc-b and allowed svc-c BOTH independently produce the identical cross-repo edge (e.g. both import something matching the same svc-a component under an equally generic importer slug of their own), the union became [svc-b, svc-c, svc-a] — withholding svc-b alone removed the edge, discarding svc-c's fully-allowed, entirely independent relationship to the exact same edge identity. Replaced crossOrigins: string[] with crossOriginPairs: string[][] — one pair per independent detection — and scopeGlobalGraph now withholds the edge only when EVERY pair has a withheld member, so an edge independently producible by a fully-allowed pair survives.

    Digging into this surfaced a second, deeper bug the round-14 unit test alone couldn't catch: buildAggregatedGraph appended newly-detected cross-edges to the global graph with a raw .push(), bypassing mergeGraphs entirely. So two independent detections sharing one from/to/relation key never actually got merged onto one edge object in the real aggregation pipeline — they sat as two separate array entries, both removable by the same shared key the moment either one was tagged withheld. Routed cross-edges through mergeGraphs instead, which is what makes the pair-tracking fix above actually take effect end to end, not just in a mergeGraphs call made directly.

Review follow-up round 16: import-iwiki.ts's own direct-match MAPS_TO edges had the identical untagged-edge gap as the cross-repo edges

  • [P1] import-iwiki.ts's direct-match path (a doc term matching a code node's label exactly) writes a MAPS_TO edge straight to the global graph pointing at a bare fact-level node slug (e.g. component/App), with no origin tag at all. If the withheld codebase that supplied the matched node later collides with an allowed codebase sharing that same unqualified slug, collision restoration correctly keeps the node — but the untagged edge had no path to removal: the evidence/code/<slug>/ prefix pass can't help (a bare fact-level slug carries no codebase prefix to match), and the edge never lived in any per-repo file either. Fixed the same way as the cross-repo edges: capture the matched node's own origin at match time and stamp it onto the edge, immune to any later collision reattributing the node. scopeGlobalGraph needed no changes for this common case — its existing single-origin tag pass already handles any edge once it carries one.

    Also generalized the round-14 fail-closed guard (for a withheld, not-tag-covered codebase's contested slug touched by an edge with no way to verify ownership) beyond DEPENDS_ON-relation edges specifically — a legacy, untagged MAPS_TO edge predating this fix poses the identical risk, and nothing about the guard's reasoning was ever actually relation-specific.

    Writing a test for this surfaced a pre-existing, unrelated latent crash: reconcileIwikiWithCodebase read raw node.label/node.id (the legacy field names) with no fallback to the current title/slug schema, since it parses the global graph file directly rather than through loadGraphIndex's field normalization — so it would throw against any graph written in the modern schema. Fixed alongside, since the new regression test couldn't otherwise exercise the fix against realistic data.

Review follow-up round 17: an edge's plain origin and its crossOriginPairs are not mutually exclusive, but the removal check treated them as if they were

  • [P2] scopeGlobalGraph's edge-withholding check read edge.crossOriginPairs ?? (edge.origin ? [[edge.origin]] : []) — if crossOriginPairs was present at all, origin was ignored outright, even when BOTH were set on the same edge. mergeGraphs can produce exactly that shape: an allowed repo's own plain, origin-tagged edge colliding (identical from/to/relation) with a separately-detected synthesized cross-repo edge. If every crossOriginPairs entry ended up withheld, the edge was removed even though its own, independently-allowed origin tag was an equally valid derivation of it that had nothing to do with the cross-repo pairs. Fixed by concatenating origin onto crossOriginPairs as one more length-1 "pair" instead of letting either field win outright — the edge is withheld only when EVERY pair, origin included, has a withheld member.

Review follow-up round 18: mergeGraphs itself, not just scopeGlobalGraph, needed to preserve a losing edge's plain origin

  • [P2] Round 17 fixed scopeGlobalGraph to treat an edge's origin and crossOriginPairs as independent alternatives once BOTH happened to be present on the same edge object — but mergeGraphs itself never actually produced that combined shape correctly in the first place. When a plain origin-tagged edge collided (identical from/to/relation) with a crossOriginPairs-only edge, mergeGraphs only ever unioned crossOriginPairs; the LOSING side's plain origin was never read at all, so it vanished from the merged edge entirely rather than surviving as one more independent provenance pair. Fixed by treating each side's origin as its own one-element pair before unioning, symmetrically for both sides of the collision — not just reading whatever field happens to still be present on the object that wins the evidence tie-break. This also generalizes correctly to two plain edges colliding (previously the losing side's origin was dropped outright) and is proven end-to-end through the real aggregation pipeline, not only a direct mergeGraphs call.

Review follow-up round 19: a direct teamai codebase --extract never got origin-tagged at all, not even via the fallback

  • [P1] A standalone teamai codebase --extract (outside teamai import's cache-dir-then-copy orchestration) writes its graph straight into the real teamwiki's global .indices/graph-index.json — buildAggregatedGraph, the only step that stamps origin, never runs for it. Re-extracting a withheld codebase this way produced untagged nodes/edges with no path to origin-based removal, while its evidence/code/<slug>/ per-repo file — left over from an earlier teamai import — sat stale, describing older content, and couldn't be trusted as the fallback either. Fixed at the source, the same way as every other origin-tagging fix in this PR: extractCodebase now stamps origin = project on every node and edge it writes, immediately before saveGraphIndex — whether this call is a standalone extract (writes straight to the real global file, now correctly tag-covered) or part of import's orchestration (writes to a cache location later copied into the per-repo file and re-tagged identically by aggregateGlobalGraph — redundant but harmless there).

Docs

usage-guide.md (+ zh-CN), manage-admin.md and docs/designs/multi-project-management.md each get the wiki namespace documented alongside the existing docs namespace coverage. product-overview.md (+ zh-CN) now cross-references that coverage from its resource-namespace summary.

Test plan

81 new/rewritten tests across 8 files:

  • resource-namespaces-wiki.test.ts — inactiveWikiNamespaces resolution against real roles.yaml/projects.yaml fixtures.
  • code-knowledge-recall-wiki-scope.test.ts — withheldCodebases filtering against real teamwiki fixtures: case-fold matching, route-depth filtering for both router link formats (evidence/code/<slug> and code/<slug>), the AI-domain-grouped format (an all-withheld domain section dropped whole vs. a mixed one keeping its header and dropping an unattributable bare line too, and a domain whose every component is an unmatched bare line dropped whole despite carrying no links at all), cross-codebase relatedFiles leakage via a graph edge, and preservation of a global-only forward edge (the kind --reconcile adds directly to the global graph) when the withheld codebase is unrelated to it.
  • recall-wiki-scope.test.ts — recall()'s wiring (the exact withheldCodebases argument queryCodeKnowledge receives) plus a regression test that an unreadable projects.yaml degrades to skipping code recall instead of rejecting the whole call.
  • graph-aggregate.test.ts — scopeGlobalGraph: subtracts a withheld project's origin-tagged nodes and edges directly from the global graph (the primary mechanism now), removes a cross-repo edge into a withheld node, removes a --reconcile-added withheld code-page node and its MAPS_TO edge even though neither ever lived in a per-repo file, subtracts a withheld-only edge between two colliding slugs by its exact relation (including the legacy imports name) while keeping an allowed edge under a different relation, restores an allowed repo's own title/domain for a colliding slug even when the withheld repo's version won the merge (including the legacy label field), removes a withheld codebase's tagged content correctly even after its per-repo file goes structurally wrong, stale-empty, or is replaced by a newer non-overlapping graph before re-aggregation runs (the review's exact interrupted-reimport scenario), falls back to the per-repo file (with the same fail-closed guards as before) only for a codebase tagging has never covered, and still fails closed for that narrower case when the file is missing or schema-invalid, does not fail closed when only an unrelated allowed codebase lacks one, keeps a global-only edge/node unrelated to the withheld project, keeps an allowed codebase's file-to-file edge whose endpoints are not declared nodes at all, keeps an allowed codebase's node when a withheld codebase happens to mint the identical unqualified slug, case-fold matching, and the no-op/empty-input cases.
  • codebase-reconcile.test.ts — the round-11 fix: a reconcile-repaired endpoint node for a code-ast edge is tagged with the same origin as that edge, and scopeGlobalGraph then correctly removes both the originally-missing endpoint nodes when that codebase is withheld.
  • cross-repo-edges.test.ts / graph-aggregate.test.ts — the round-12/13 fixes: detectCrossRepoEdges tags its synthesized edge with crossOrigins carrying BOTH sides' origins (omitting a side that predates origin tagging), and two full aggregate-then-scope tests reproduce both collision directions end to end — a cross-repo edge survives a later, allowed repo winning a same unqualified slug on either its matched (target) side or its source (importer) side right up until these fixes, and is gone after them, while the unrelated allowed repo's own edge and the now-allowed node(s) both still survive. A third test covers an import-iwiki.ts-style originless MAPS_TO edge into evidence/code/<withheld>/<page>.md.
  • cross-repo-edges.test.ts — the round-14 importing-side fix: the reverse scan uses the import edge's own origin tag even when the node currently at its from slug has since been reattributed to an unrelated repo by a later collision, and falls back to that node's origin only when the edge itself predates tagging.
  • graph-aggregate.test.ts — a hand-written, pre-upgrade-style fixture (global graph with a legacy, fully untagged cross-repo edge; two per-repo files colliding on its source slug, one withheld, one allowed) confirms scopeGlobalGraph now fails closed instead of silently keeping that edge. A new mergeGraphs describe block confirms crossOriginPairs are unioned as SEPARATE pairs (not flattened/overwritten) on an edge-identity collision, order-independently, dedupes an identical pair contributed by both sides, and confirms a plain single-origin edge collision is unaffected.
  • graph-aggregate.test.ts — the round-15 fix end to end through the real aggregation pipeline (not just a direct mergeGraphs call): withheld svc-b and allowed svc-c both independently import something matching allowed svc-a's component under the identical importer slug; the resulting single, merged edge carries both pairs, and withholding only svc-b keeps the edge (svc-c's pair is fully allowed) while withholding both svc-b and svc-c removes it.
  • import-iwiki-reconcile.test.ts (new) — reconcileIwikiWithCodebase's direct-match edges carry the matched node's own origin; a full aggregate → reconcile → simulate-later-collision → scopeGlobalGraph sequence confirms the edge is removed once that codebase is withheld even though its matched node survives the collision.
  • graph-aggregate.test.ts — the broadened fail-closed guard: a hand-written legacy fixture with a fully untagged MAPS_TO edge (instead of DEPENDS_ON) into a contested fact-level slug still correctly fails closed.
  • graph-aggregate.test.ts — the round-17 fix: a hand-written edge carrying BOTH origin and crossOriginPairs survives when only the crossOriginPairs side is withheld (the origin side is independently allowed), and is removed once every side — origin included — is withheld.
  • graph-aggregate.test.ts — the round-18 mergeGraphs fix: two plain, single-origin edges colliding now preserve both origins as independent pairs instead of the loser's vanishing; a plain edge colliding with a crossOriginPairs-only one preserves the plain side's origin regardless of merge order; and a full real-pipeline test (svc-a's own plain edge happens to collide with a cross-repo edge synthesized from withheld svc-w importing allowed svc-z) confirms svc-a's independent claim keeps the edge alive after withholding svc-w.
  • codebase-extract-fallback.test.ts (new describe block) — the round-19 fix: every node/edge a direct extractCodebase writes to the real teamwiki's global graph carries origin set to the project slug, and scopeGlobalGraph correctly withholds all of it even with a deliberately stale, untouched evidence/code/<project>/.indices/graph-index.json per-repo file sitting alongside it.

All pass. Plus the 5 existing test files most directly touched by this change (recall.test.ts, recall-scope-isolation.test.ts, pull-docs-namespaces.test.ts, resource-namespaces-case-alias.test.ts, projects.test.ts), import-repo-incremental.test.ts/import-repo-merge.test.ts/import-dir.test.ts (exercise extractCodebase's real callers), and commands-reference.test.ts (regenerated generated-docs snapshot) — 184 tests total — all still pass with zero regressions. (One pre-existing, unrelated Windows-only failure in codebase-reconcile.test.ts — a fixture path containing a literal | character, which Windows' filesystem rejects — reproduces identically on main via git-stash before/after and is untouched by this PR.) Typecheck and lint (oxlint --type-aware) both clean.

Real-CLI verification (not just unit tests)

Built (npm run build) and ran the actual CLI against a hand-built team repo + project fixture, across every review round:

  • recall "octopus" --depth lookup with only svc-a active → only svc-a's page returned; with both svc-a/svc-b active → both returned.
  • recall "router" --depth route, across all three router formats (routerTemplate's plain bullets, rebuildWikiIndex's table rows, and routerTemplate's AI-domain-grouped sections, mixed and all-withheld) → in every case svc-b's content (including an all-withheld domain's header, an unlinked fallback line with no match in projects[], and an unmatched bare line in an otherwise-allowed mixed domain) is stripped when only svc-a is active, while an allowed/mixed domain's header and line survive.
  • A synthetic cross-repo graph edge (a/client → b/service) surfaces b/service under "Candidate change files" when both projects are active, and disappears when svc-b is withheld — and a separate global-only forward edge (a/client → docs/product/billing, simulating what --reconcile writes directly to the global graph) survives scoping and still appears, confirming the fix removes only the withheld codebase's own content.
  • A file-to-file graph edge with neither endpoint declared as a node (src/a.ts → src/b.ts, the real shape of an AST/heuristic edge) survives in "Candidate change files" even when an unrelated codebase (svc-b) is withheld — confirming the round-3 over-pruning fix.
  • A scopeGlobalGraph call against a global graph with an injected reconcile-style withheld code-page node (evidence/code/svc-b/overview) and MAPS_TO edge confirms both are removed while an unrelated product-page node survives.
  • A deliberately malformed projects.yaml now makes recall print the existing "code graph retrieval unavailable" warning and exit 0, instead of crashing the whole command.
  • A codebase with evidence pages but no per-repo graph file ever (simulating a direct --extract, never tag-covered) makes an unrelated, fully-accounted-for codebase's own "Candidate change files" entry disappear too the moment the no-graph-file codebase is withheld — confirming the per-repo-file fallback's fail-closed path still applies for a codebase tagging has never covered.
  • A single scopeGlobalGraph call against a graph where a withheld repo's write won the merge for a colliding slug now returns the allowed repo's title/domain for that slug, and keeps only the allowed REFERENCES edge between the colliding endpoints while dropping the withheld DEPENDS_ON one.
  • A withheld edge recorded under the legacy imports relation name is correctly normalized and removed, leaving only the allowed REFERENCES edge; and a per-repo file with a node that doesn't validate against the graph schema, for a codebase never successfully aggregated before, still makes scopeGlobalGraph return null.
  • A router domain whose every component is an unmatched bare line (no code/<slug> link anywhere in the section) is now fully stripped from --depth route's snippet while an unrelated, properly-linked domain survives.
  • A scopeGlobalGraph call reproducing the round-10 review's exact scenario — aggregate once (tagging a withheld codebase's node), then replace that codebase's per-repo file with a newer, valid, non-overlapping graph with no re-aggregation — confirms the OLD tagged node is still correctly removed, without the function ever reading (or being misled by) the replaced file. The same holds when that per-repo file instead goes structurally wrong or stale-empty post-aggregation: the whole query now stays usable, scoped precisely by the tag, instead of failing closed to no graph at all.
  • A full teamai codebase --reconcile run (the real CLI command, not a direct function call) against a per-repo graph with a code-ast edge (src/a.ts → src/b.ts) whose endpoints are both missing from nodes[]: the reconciler mints both placeholder nodes and both now carry origin: "auth" (the review's exact scenario). A follow-up scopeGlobalGraph(..., new Set(['auth'])) call confirms neither placeholder survives, while an unrelated product page does.
  • Aggregated three per-repo graphs against the compiled source directly (svc-a imports something labeled BalanceService; svc-b defines it; svc-c, processed after svc-b, mints the identical unqualified slug): confirmed the synthesized cross-repo edge carries origin: "svc-b" and the colliding node ends up origin: "svc-c" after merge, then confirmed scopeGlobalGraph(..., new Set(['svc-b'])) keeps the now-allowed node AND svc-a's own unrelated edge, but drops the cross-repo edge specifically. A second run injected an import-iwiki.ts-shaped originless MAPS_TO edge directly into the global graph and confirmed it's gone after scoping too.
  • Reproduced the round-13 review's own exact scenario directly against the compiled source: withheld svc-b imports allowed svc-a, and a third, allowed svc-c (processed after svc-b) mints the identical unqualified slug svc-b's own importer node used. Confirmed the cross-repo edge carries crossOrigins: ["svc-b", "svc-a"], the colliding node ends up origin: "svc-c" after merge, and scopeGlobalGraph(..., new Set(['svc-b'])) keeps both the now-allowed b/client node and svc-a's own a/service node, while still dropping the cross-repo edge between them.
  • Called detectCrossRepoEdges directly against the compiled source with a hand-built existing graph where the import edge's own tag (origin: "svc-b") disagrees with the CURRENT node at its from slug (origin: "svc-x", simulating a later collision) — confirmed the synthesized edge's crossOrigins correctly contains "svc-b" (the edge's own tag) and not "svc-x" (the stale node).
  • Built the exact legacy-data scenario from the round-14 review by hand (a global graph with a fully untagged DEPENDS_ON edge, plus per-repo files for a withheld and a colliding allowed repo, with no aggregateGlobalGraph call so nothing gets re-tagged) and called scopeGlobalGraph against the compiled source directly — confirmed it returns null instead of silently keeping the unattributable edge.
  • Called mergeGraphs directly against the compiled source with two edges sharing one from/to/relation identity but different crossOrigins — confirmed the merged edge's crossOrigins is the union of both (["svc-w","svc-z","svc-y"]), not just whichever side won the tie-break.
  • Reproduced the round-15 review's exact scenario against the compiled source via the real aggregateGlobalGraph pipeline: withheld svc-b and allowed svc-c both independently import something matching allowed svc-a's component under the identical generic importer slug. Confirmed the global graph ends up with exactly ONE merged edge carrying crossOriginPairs: [["svc-b","svc-a"],["svc-c","svc-a"]] (not two separate raw entries), that scopeGlobalGraph(..., new Set(['svc-b'])) keeps that edge (svc-c's pair is fully allowed), and that withholding both svc-b and svc-c together removes it.
  • Reproduced the round-16 review's exact scenario against the compiled source: aggregated a withheld svc-b with a component/App node, called the real reconcileIwikiWithCodebase against a doc containing `App` — confirmed the resulting edge carries origin: "svc-b". Then simulated a later collision (an allowed svc-c claiming the identical unqualified slug) and confirmed scopeGlobalGraph(..., new Set(['svc-b'])) keeps the now-allowed component/App node but still removes the MAPS_TO edge.
  • Called scopeGlobalGraph directly against the compiled source with a hand-written edge carrying both origin: "svc-a" and crossOriginPairs: [["svc-w","svc-z"]] — confirmed withholding only svc-w keeps the edge (svc-a's own origin is independently allowed), and withholding svc-a, svc-w, and svc-z together removes it.
  • Reproduced the round-18 review's scenario through the real aggregateGlobalGraph pipeline against the compiled source: svc-a's own plain fact-level edge (a/x -> a/y) collides with a cross-repo edge independently synthesized from withheld svc-w importing allowed svc-z's component. Confirmed the merged edge carries crossOriginPairs: [["svc-a"],["svc-w","svc-z"]] (not just the cross edge's pair, losing svc-a's), and that scopeGlobalGraph(..., new Set(['svc-w'])) keeps the edge alive on svc-a's independent claim.
  • Ran the actual built CLI (node dist/index.js codebase --extract <dir> --project widget --json), standalone, against a fresh single-file TypeScript fixture with no prior teamai import ever run. Inspected the resulting teamwiki/.indices/graph-index.json directly: every one of its 3 nodes and 1 edge carries "origin": "widget".

A full project-wide npx vitest run was attempted multiple times (once per review round) but the host machine was resource-starved/low on memory from accumulated test-run processes; the harness killed it rather than let it run on unreliable output. Each time, the targeted suite above plus the real-CLI verification stand in for it; the one pre-existing slow/timeout-prone test outside this change (recall-attribution.test.ts) was independently confirmed unrelated via git-stash before/after comparison in the first round.

Closes #912.

🤖 Generated with Claude Code

…#912)

Docs and learnings are already scoped by role/project namespace (Tencent#707);
teamwiki was not. A checkout active on one project (`--project svc-a`) got
`teamai recall` hits from every codebase's `evidence/code/<slug>/`,
including ones that belong to an unrelated project — recall would surface
another team's codebase evidence just because it lived in the same wiki.

Adds a hand-declared `wiki` resource type (manifest-schema.ts), following
the exact pattern `docs` already uses:

- `HAND_DECLARED_RESOURCE_TYPES` gains `'wiki'` — this alone threads it
  through the existing generic plumbing in roles.ts/projects.ts
  (NAMESPACED_RESOURCE_TYPES, mergeNamespaces, resolveRoleResourceNamespaces,
  resolveProjectResourceNamespaces all already loop over
  HAND_DECLARED_RESOURCE_TYPES generically) with no changes needed there.
- resource-namespaces.ts computes `inactiveWikiNamespaces` the same way it
  already computes `inactiveDocsNamespaces`: a slug ANY role or project
  declares under `resources.wiki` is withheld unless this directory's
  active role/project selects it; an undeclared slug stays shared.
- code-knowledge-recall.ts's `loadWikiPages`/`queryCodeKnowledge` take a new
  `withheldCodebases` option and filter `evidence/code/<slug>/` directories
  by it (case-folded, matching how the directory lookup itself is
  effectively case-insensitive on Windows/macOS).
- recall.ts wires the two together: computes `withheldCodebases` from the
  active project config via `resolveResourceNamespaces` and passes it to
  `queryCodeKnowledge`.

The slug is whatever `teamai codebase --project <slug>` wrote, unrelated to
a manifest project id, so a team declares the one it already uses — no new
convention needed.

Docs: usage-guide.md (+ zh-CN) and the admin skill reference
(manage-admin.md) each get a short "Wiki by namespace" paragraph mirroring
the existing "Docs by namespace" one.

Test plan: 12 new tests across 3 files — resource-namespaces-wiki.test.ts
(inactiveWikiNamespaces resolution against real roles.yaml/projects.yaml
fixtures), code-knowledge-recall-wiki-scope.test.ts (withheldCodebases
filtering against real teamwiki fixtures, including case-fold matching),
recall-wiki-scope.test.ts (recall()'s wiring, asserting the exact
withheldCodebases argument queryCodeKnowledge receives). All 12 pass.
Typecheck and lint (oxlint --type-aware) both clean. The 5 existing test
files most directly touched by this change (recall.test.ts,
recall-scope-isolation.test.ts, pull-docs-namespaces.test.ts,
resource-namespaces-case-alias.test.ts, projects.test.ts — 71 tests) all
still pass with zero regressions.

A full project-wide suite run was attempted but the host machine was
resource-starved from this session's own accumulated test-run processes
(100+ orphaned node.exe, causing cascading 15s test timeouts and one
literally-impossible "8514000ms" timer reading); killed rather than trust
its output. recall-attribution.test.ts's one slow/timeout-prone test was
independently confirmed pre-existing and unrelated via direct git-stash
before/after comparison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jeff-r2026 jeff-r2026 assigned SaulMoro and jeff-r2026 and unassigned SaulMoro Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/code-knowledge-recall.ts:234 — depth: 'route' returns the global teamwiki/router.md before applying withheldCodebases. Since that router lists every codebase, a checkout scoped to svc-a can run teamai recall ... --depth route and still receive svc-b names, links, descriptions, and keywords.

  • [P1 blocking] src/code-knowledge-recall.ts:479 — Page directories are filtered, but the global .indices/graph-index.json remains unfiltered. In lookup mode, an allowed page with a graph edge to a withheld codebase can emit that withheld node through relatedFiles/“Candidate change files”; withheld nodes can also affect scoring through graph boosts. The graph must be scoped alongside the pages.

  • [P1 blocking] The PR changes runtime recall behavior, but the description documents only unit tests, typecheck, and lint. The repository’s Code Review Rules require at least one representative end-to-end/real-CLI verification record for runtime changes.

…iki namespace (Tencent#974 review)

codex-review on PR Tencent#974 found two gaps where a withheld codebase still
leaked through recall despite the page-level scoping:

- `--depth route` returned the global `teamwiki/router.md` verbatim.
  Since that file lists every codebase (name, link, description,
  keywords) plus a `<!-- search-anchor -->` comment aggregating all
  keywords, a directory scoped to one codebase could still see every
  other one's entry. Fixed by stripping withheld-codebase bullet lines
  and the anchor comment from the content before it's returned.

- The knowledge graph (`.indices/graph-index.json`) was loaded
  unfiltered at `context`/`lookup` depth, so a withheld codebase's
  nodes could still match as BM25 entry nodes, boost scores via graph
  neighbors, or — most concretely — surface a withheld codebase's own
  node identifier through an allowed page's `relatedFiles` via a
  cross-repo DEPENDS_ON edge. A withheld codebase's fact-level graph
  nodes/edges (AST/heuristic) are keyed by raw file path with no
  `evidence/code/<slug>/` prefix at all, so there's no reliable string
  to filter the already-merged graph by after the fact. Fixed at the
  root instead: `graph-aggregate.ts`'s existing per-repo aggregation
  (the one place that still knows, per per-repo graph file, which
  codebase it came from) now accepts an `excludeProjects` set and skips
  a withheld codebase's graph file entirely — including skipping
  cross-repo edge detection against it. `queryCodeKnowledge` calls this
  scoped rebuild instead of reading the flat merged file whenever any
  codebase is withheld.

Verified via the real built CLI (`npm run build` + a hand-built team
repo + project fixture), not just unit tests:
- `recall "octopus" --depth lookup` with only `svc-a` active returns
  only svc-a's page; with both active, both return.
- `recall "router" --depth route` with only `svc-a` active strips
  svc-b's bullet and the keyword anchor from router.md's snippet.
- A synthetic cross-repo graph edge (`a/client -> b/service`) surfaces
  `b/service` in "Candidate change files" when both projects are
  active, and disappears when svc-b is withheld.

Added 7 new tests (3 in graph-aggregate.test.ts for
buildAggregatedGraph's excludeProjects, 4 in
code-knowledge-recall-wiki-scope.test.ts for router filtering and
cross-codebase relatedFiles leakage) plus the 12 from the original PR.
All 39 tests across every touched/related file pass, typecheck and
lint are clean. A full project-wide `npx vitest run` was killed by the
harness due to low system memory (not a test failure) before
completing; given the same resource constraint as the original PR,
this round relies on the targeted evidence above rather than a full
clean run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three P1 findings in 066c8d7:

  1. --depth route leaked withheld codebases via router.md — fixed by stripping withheld-codebase bullet lines (name/link/description/keywords) and the <!-- search-anchor --> keyword comment from the router content before returning it, mirroring the directory-level scoping already applied to context/lookup.

  2. The knowledge graph was unfiltered, leaking a withheld codebase through relatedFiles/graph boosts — confirmed this is real and reproducible via a cross-repo DEPENDS_ON edge. Root cause: AST/heuristic graph nodes are keyed by raw file path with no evidence/code/<slug>/ prefix, so the already-merged .indices/graph-index.json can't be filtered by slug after the fact. Fixed at the aggregation layer instead — graph-aggregate.ts's existing per-repo merge (the one place that still knows which codebase each per-repo graph file came from) now takes an excludeProjects set and skips a withheld codebase's graph file entirely, including skipping cross-repo edge detection against it. queryCodeKnowledge calls this scoped rebuild instead of the flat merged file whenever any codebase is withheld.

  3. Missing real-CLI verification record — added below.

Real-CLI verification (not just unit tests)

Built (npm run build) and ran the actual CLI against a hand-built team repo + project fixture:

  • recall "octopus" --depth lookup with only svc-a active → only svc-a's page returned; with both svc-a/svc-b active → both returned.
  • recall "router" --depth route with only svc-a active → svc-b's bullet and the keyword anchor are stripped from the snippet; svc-a's stays.
  • A synthetic cross-repo graph edge (a/client → b/service) surfaces b/service under "Candidate change files" when both projects are active, and disappears when svc-b is withheld — confirming the graph-leak fix closes the concrete scenario raised in finding docs: make English the default README #2.

Tests

Added 7 new tests (3 for buildAggregatedGraph's excludeProjects in graph-aggregate.test.ts, 4 for router filtering + cross-codebase relatedFiles leakage in code-knowledge-recall-wiki-scope.test.ts), on top of the original PR's 12. All 39 tests across every touched/related file pass; typecheck and lint are clean.

One caveat, same as the original PR: a full project-wide npx vitest run was killed by the harness due to low system memory before completing (not a test failure). Given the same constraint, this round relies on the targeted suite above plus the real-CLI verification rather than a full clean run.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] The PR changes runtime recall behavior, but the description still documents only unit tests, typecheck, and lint. The repository’s Code Review Rules require one representative end-to-end/real-CLI verification record for runtime changes.
  • [P1 blocking] skill-data/setup/references/manage-admin.md:260 — The upgrade warning still lists only env, hooks, mcp, models, and docs. An admin following this guide may declare resources.wiki before all members upgrade; older CLIs reject that unknown key and their pulls stop.
  • [P2 non-blocking] src/manifest-schema.ts:74 — The new hand-declared wiki type was not propagated to the authoritative CLI help and generated command reference (src/index.ts:435, skill-data/core/references/commands.md:128), or the existing resource-type enumeration in docs/designs/multi-project-management.md:418. These now incorrectly claim the hand-declared set ends at docs.

The previously reported route-router and graph-index isolation findings are resolved in the current diff.

…admin docs (Tencent#974 review)

codex-review's second pass on Tencent#912 found `wiki` missing from every
enumeration of the hand-declared resource types outside
manifest-schema.ts itself, which is the one place it had actually been
added:

- manage-admin.md's "every member upgrade before declaring..." warning
  and its directory-naming-rule list both still stopped at `docs`. An
  admin following the guide could declare `resources.wiki` before
  every member upgraded, and an older CLI rejects the unknown key and
  stops that member's pull.
- `teamai projects add --namespaces`'s help text (and the generated
  `commands.md`, regenerated via `npx vitest run commands-reference
  -u`) and docs/designs/multi-project-management.md's resource-type
  enumeration also still read "env, hooks, mcp, models and docs".

Also moved the real-CLI verification record from a PR comment into the
PR description itself, per Code Review Rules (the description must
document sufficient testing for a runtime-behavior change, not a
comment on it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in c967370:

  • PR description now includes the real-CLI verification record directly (previously only in a comment), plus an updated test-plan section covering both review rounds.
  • manage-admin.md: added wiki to both the "upgrade every member first" warning and the directory-naming-rule enumeration — an admin following the old wording could have declared resources.wiki before every member upgraded, breaking pull for members still on a CLI that rejects unknown resources: keys.
  • P2: added wiki to teamai projects add --namespaces's help text, regenerated commands.md from it (npx vitest run commands-reference -u), and updated docs/designs/multi-project-management.md's resource-type enumeration.

Typecheck, lint, and the commands-reference snapshot test all pass.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/code-knowledge-recall.ts:243 — Router filtering only recognizes evidence/code/<slug> links, but the standard rebuildWikiIndex() path used after imports generates table rows containing [[code/<slug>/index]]. After importing svc-a and svc-b, a checkout withholding svc-b can still run recall ... --depth route and receive svc-b’s domain, name, description, and keywords.

  • [P1 blocking] src/code-knowledge-recall.ts:481 — Rebuilding a scoped graph exclusively from per-repository graph files discards legitimate additions stored only in the global graph. For example, after teamai codebase --reconcile adds product-page nodes and MAPS_TO edges, activating any wiki restriction removes those mappings even for allowed codebases, so a query using product terminology no longer retrieves its mapped code page.

  • [P2 non-blocking] src/recall.ts:692 — resolveResourceNamespaces() runs outside the existing code-retrieval try. An unreadable or malformed roles/projects manifest therefore rejects the entire recall operation, even when a valid existing learnings index could still be searched safely; this should skip code recall with the existing warning behavior instead.

The previously reported testing-record and documentation/help propagation gaps are resolved in the current PR description and diff.

…al-only graph edges (Tencent#974 review round 2)

codex-review's third pass found the first graph/router fix (round 2)
had two real gaps and one unrelated robustness issue:

- Router filtering only recognized `[[evidence/code/<slug>/index]]`
  links (routerTemplate's format), but `rebuildWikiIndex()` — the path
  actually taken after an import — writes `[[code/<slug>/index]]`
  table rows instead, with no `evidence/` prefix. A checkout scoped
  away from a codebase imported this way still saw its full row
  (domain, name, duty, keywords) at `--depth route`. Fixed by widening
  the line-match regex to accept an optional `evidence/` prefix before
  `code/<slug>`.

- The round-2 graph fix rebuilt the scoped graph from only the allowed
  per-repo graph files, which discards anything that exists ONLY in
  the global `.indices/graph-index.json` — chiefly `teamai codebase
  --reconcile`'s product<->code MAPS_TO edges, since the reconciler
  reads and writes the global graph directly and never touches a
  per-repo file. Activating any wiki restriction therefore silently
  dropped those mappings even for codebases that were still fully
  allowed. Fixed by flipping the approach: `scopeGlobalGraph`
  (replacing `buildAggregatedGraph`'s `excludeProjects`) now starts
  from the real global graph and *subtracts* exactly the withheld
  codebase's own node slugs (read from its per-repo file, which still
  correctly names every fact-level node it contributed, prefixed or
  not), then drops any edge left dangling from a removed endpoint.
  That dangling-edge cleanup is what still removes a cross-repo
  DEPENDS_ON edge into a withheld node — the original leak this whole
  graph-scoping effort started from — without needing to know the edge
  came from cross-repo detection rather than a per-repo file.

- (P2) `resolveResourceNamespaces()` in recall.ts ran outside the
  existing code-retrieval try/catch, so an unreadable or malformed
  roles/projects manifest rejected the whole recall call even when a
  valid learnings index had already been searched safely. Moved it
  inside the same try, so it now degrades the same way a
  `queryCodeKnowledge` failure already does: skip code recall, warn,
  keep the rest of the results.

Also moved the real-CLI verification record into the PR description in
the previous round, per Code Review Rules; this round's new real-CLI
checks are documented there too.

Verified via the real built CLI again: a `router.md` fixture in
rebuildWikiIndex's actual table-row format now has the withheld row
stripped at `--depth route`; a synthetic global-only forward edge
(mimicking what --reconcile adds) survives scoping in "Candidate
change files" while a cross-repo edge into the withheld codebase still
doesn't; and a deliberately malformed `projects.yaml` now degrades to
the existing "code graph retrieval unavailable" warning (exit 0)
instead of crashing recall.

9 tests added/rewritten (7 in graph-aggregate.test.ts for
scopeGlobalGraph, replacing the 3 excludeProjects tests it supersedes;
1 router table-row test and 1 global-only-edge-preservation test in
code-knowledge-recall-wiki-scope.test.ts; 1 malformed-manifest
regression test in recall-wiki-scope.test.ts). All 129 tests across
every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three findings in 070e8fd:

  1. Router filter missed rebuildWikiIndex's link format — confirmed real: that generator (the path actually taken after an import) writes [[code/<slug>/index]] table rows, no evidence/ prefix. Widened the line-match regex to accept an optional evidence/ prefix before code/<slug>.

  2. The round-1 graph fix dropped legitimate global-only content — confirmed real and reproduced it: --reconcile reads/writes .indices/graph-index.json directly and never touches a per-repo file, so rebuilding the scoped graph from only the allowed per-repo files silently lost its MAPS_TO edges even for fully-allowed codebases. Replaced the rebuild-from-scratch approach with scopeGlobalGraph: start from the real global graph, subtract exactly the withheld codebase's own node slugs (read from its per-repo file), then drop edges left dangling from a removed endpoint. That dangling-edge cleanup is what still removes a cross-repo edge into a withheld node — the original leak — without needing to know it came from cross-repo detection rather than a per-repo file.

  3. resolveResourceNamespaces() outside the retrieval try — moved it inside, so an unreadable/malformed manifest now degrades to the existing "code graph retrieval unavailable" warning instead of rejecting the whole recall call.

Real-CLI re-verification

  • --depth route against a rebuildWikiIndex-style [[code/<slug>/index]] table-row router.md → withheld codebase's row stripped, allowed one kept.
  • A synthetic global-only forward edge (mimicking what --reconcile writes) survives scoping and still surfaces in "Candidate change files", while a cross-repo edge into the withheld codebase still doesn't.
  • A deliberately malformed projects.yaml → recall now prints the existing warning and exits 0 instead of crashing.

Tests

9 tests added/rewritten (7 replace the superseded buildAggregatedGraph/excludeProjects tests in graph-aggregate.test.ts with scopeGlobalGraph coverage, including a dedicated "keeps global-only content" case; 1 router table-row test; 1 malformed-manifest regression test). 129 tests total across every touched/related file pass; typecheck, lint, and the regenerated commands-reference snapshot are all clean.

PR description updated with the full round-2 verification record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/graph-aggregate.ts:104 — Filtering edges by keptSlugs deletes every edge whose endpoint is a raw file path, although the extractor intentionally emits edges such as src/a.ts → src/b.ts without corresponding graph nodes. Whenever any unrelated wiki namespace is withheld, an allowed page sourced from src/a.ts therefore loses src/b.ts from “Candidate change files.” Remove only edges touching known withheld endpoints instead of requiring both endpoints to be nodes.

  • [P2 non-blocking] src/graph-aggregate.ts:103 — Node slugs are not repository-qualified (buildCodeGraph uses values such as component/App). If an allowed and withheld repository both define the same slug, collecting that slug from the withheld graph removes the allowed repository’s merged node too, eliminating its entry-node boosts and graph relationships. The scoped graph needs ownership-aware handling of duplicate slugs.

  • [P2 non-blocking] docs/usage-guide.md:386 — The command guidance still says the hand-declared resource types end at docs, incorrectly implying --namespaces may manage wiki. The corresponding Chinese text at docs/usage-guide.zh-CN.md:335 and resource-type list at CHANGELOG.md:12 are stale as well.

The earlier router-format, manifest-error, help/design, and real-CLI testing findings are resolved; the PR description now includes sufficient representative real-CLI verification.

…nish wiki doc propagation (Tencent#974 review round 3)

codex-review's fourth pass found scopeGlobalGraph itself (round 2's
fix) had a real over-pruning bug, a real-but-smaller ambiguity, and
three more stale doc enumerations:

- [P1] The edge filter required BOTH endpoints to survive as nodes in
  the scoped graph (`keptSlugs.has(e.from) && keptSlugs.has(e.to)`).
  But an AST/heuristic edge is commonly file-to-file
  (`{from: 'src/a.ts', to: 'src/b.ts'}`) with neither endpoint present
  in `nodes[]` at all — so the moment anything was withheld, every
  such edge vanished for every codebase, allowed ones included,
  dropping their files from `relatedFiles`/"Candidate change files".
  Fixed by subtracting by identity instead: collect every node slug
  AND edge endpoint a withheld codebase's own per-repo file
  contributed, then drop only edges actually touching one of those —
  not edges that merely fail to resolve to a known node.

- [P2] Fact-level node slugs are not repo-qualified (`buildCodeGraph`
  mints `component/App` the same way for any repo), so an allowed and
  a withheld repo can legitimately collide on one slug after merging;
  subtracting by slug alone would also remove the allowed repo's node.
  Fixed by also collecting every ALLOWED codebase's own identifiers and
  clearing any that overlap with the withheld set before subtracting —
  a shared identifier is never removed.

- [P2] `docs/usage-guide.md` (+ zh-CN) still said `--namespaces`
  "neither touches env, hooks, mcp, models or docs", omitting `wiki`,
  and `CHANGELOG.md`'s Tencent#707 entry enumerating `resources:` keys was
  stale the same way. Added `wiki` to all three.

Verified via the real built CLI again: a file-to-file edge with
neither endpoint declared as a node now survives in "Candidate change
files" even when an unrelated codebase is withheld (previously it
vanished the moment anything was withheld, regardless of relation).

Added 2 tests to graph-aggregate.test.ts for the two scopeGlobalGraph
bugs (file-to-file edge preservation, slug-collision non-removal) and
fixed a test-only bug in the existing cross-repo-edge test (it filtered
by `relation === 'DEPENDS_ON'`, which also matches the surviving,
unrelated import edge after `loadGraphIndex` normalizes the legacy
`imports` relation — switched to filtering by the actual withheld
endpoint). 132 tests total across every touched/related file pass;
typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both findings in 5ccde9f:

  1. P1, over-pruning: confirmed real. scopeGlobalGraph's edge filter required both endpoints to survive as nodes, but an AST/heuristic edge is commonly file-to-file with neither endpoint ever present in nodes[] — so withholding anything deleted every such edge for every codebase, allowed ones included. Switched to subtracting by identity: collect every node slug and edge endpoint the withheld codebase's own per-repo file actually contributed, then drop only edges touching one of those.

  2. P2, slug collision: confirmed real. Fact-level slugs aren't repo-qualified, so an allowed and withheld repo can legitimately collide on one slug after merging. Now also collect every allowed codebase's own identifiers and clear any overlap from the withheld set before subtracting, so a shared identifier survives.

  3. P2, stale docs: added wiki to usage-guide.md (+ zh-CN) and the CHANGELOG.md feat(projects): extend project isolation to knowledge/docs/agents/env/hooks/mcp #707 entry.

Real-CLI re-verification

A file-to-file edge with neither endpoint declared as a node (the real shape of an AST/heuristic edge) now survives in "Candidate change files" even when an unrelated codebase is withheld — previously it vanished regardless of relevance the moment anything was withheld.

Tests

2 new tests in graph-aggregate.test.ts for the two bugs, plus a fix to the existing cross-repo-edge test (it filtered by relation === 'DEPENDS_ON', which also matches the surviving unrelated import edge after loadGraphIndex normalizes the legacy imports relation — switched to filtering by the actual withheld endpoint). 132 tests total pass; typecheck and lint clean.

PR description updated with the full round-3 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/code-knowledge-recall.ts:246 — Router filtering removes only lines containing a code/<slug> link. When routerTemplate() receives AI domains, it emits separate ### <domain> and - <component> lines without that link. For a withheld codebase such as svc-b, recall --depth route can therefore still return its domain and component names. The filtering must remove the entire domain section belonging exclusively to withheld projects, or generate/filter structured ownership metadata.

  • [P1 blocking] src/graph-aggregate.ts:107 — scopeGlobalGraph() identifies withheld content solely from the per-repository graph, but reconcileKnowledge() adds code-page nodes such as evidence/code/svc-b/overview and their MAPS_TO edges only to the global graph. After reconciling and then withholding svc-b, those nodes and edges survive, so queries matching the withheld page title can still use it as an entry node and graph boost. Global code-page identifiers under withheld evidence/code/<slug>/ paths must also be removed.

The previously reported testing, documentation, malformed-manifest, router-link-format, raw-edge, and duplicate-slug findings are resolved in the current diff.

…dded withheld code-page nodes (Tencent#974 review round 4)

codex-review's fifth pass found two more real leaks, both in the
graph/router scoping this PR already added:

- [P1] Router filtering only removed individual lines carrying a
  `code/<slug>` link. `routerTemplate()`'s AI-domain branch, though,
  emits a `### <domain>` header line with no link at all, and an
  unresolved component (one whose name does not match any known
  project) as a bare `- <name>` line with no link either. Once the
  properly-linked sibling lines for a withheld codebase were removed,
  an all-withheld domain's header — and any such unlinked fallback
  line under it — survived untouched, still naming the domain and
  component. Fixed by grouping router.md into sections at each
  markdown header first: a section whose links are ALL withheld (and
  at least one was found) is now dropped whole, header and any
  unlinked lines included; a section mixing allowed and withheld links
  still keeps its header and drops only the withheld lines, as before.

- [P1] `scopeGlobalGraph` identified withheld content solely from
  per-repo graph files, but `teamai codebase --reconcile` adds
  code-page nodes (`evidence/code/<slug>/<page>`) and their MAPS_TO
  edges straight to the global graph, the same way it adds
  product-page nodes — never to a per-repo file. After reconciling and
  then withholding that codebase, those nodes/edges survived, so a
  query matching the withheld page's title could still use it as an
  entry node or graph boost. Fixed with a second pass over the global
  graph's own nodes, matched by the `evidence/code/<slug>/` prefix
  (which — unlike a bare fact-level slug — unambiguously names the
  codebase that owns it, so no allowed/withheld collision risk the way
  there is for prefix-less slugs).

Verified via the real built CLI: an all-withheld domain section
(header + unlinked fallback line) is now fully gone from `--depth
route`'s snippet while a mixed/allowed domain's header and allowed
line survive; a `scopeGlobalGraph` call against a global graph with an
injected reconcile-style withheld code-page node and MAPS_TO edge
confirms both are removed while an unrelated product-page node
survives.

4 tests added (2 router domain-section tests in
code-knowledge-recall-wiki-scope.test.ts; 1 reconcile-added-node test
in graph-aggregate.test.ts). 135 tests total across every
touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both findings in b564757:

  1. P1, router domain header leak: confirmed real. routerTemplate()'s AI-domain branch emits a ### <domain> header with no link, and an unresolved component as a bare - <name> line with no link either — once the withheld codebase's properly-linked lines were stripped, the header and any such bare line survived, still naming the domain/component. Fixed by grouping router.md into sections at each markdown header: a section whose links are entirely withheld (and at least one was found) is now dropped whole, header and unlinked lines included; a mixed section still keeps its header and drops only the withheld lines.

  2. P1, reconcile-added global-only code-page node: confirmed real. --reconcile adds code-page nodes (evidence/code/<slug>/<page>) and MAPS_TO edges straight to the global graph, never to a per-repo file, so my per-repo-file-based identifier collection missed them. Added a second pass over the global graph's own nodes, matched by the evidence/code/<slug>/ prefix — which unambiguously names the owning codebase, unlike a bare fact-level slug, so there's no collision risk to guard against here.

Real-CLI re-verification

  • An all-withheld domain section (header + unlinked fallback line) is now fully gone from --depth route's snippet; a mixed/allowed domain's header and line survive.
  • A scopeGlobalGraph call against a graph with an injected reconcile-style withheld code-page node + MAPS_TO edge confirms both are removed while an unrelated product-page node survives.

Tests

4 tests added (2 router domain-section tests, 1 reconcile-added-node test, consistent with the existing suite's patterns). 135 tests total pass; typecheck and lint clean.

PR description updated with the full round-4 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/graph-aggregate.ts:122 — Missing or unreadable per-repository graphs fail open. The documented teamai codebase --extract path writes only teamwiki/.indices/graph-index.json, so a withheld codebase may have no evidence/code/<slug>/.indices/graph-index.json; its fact-level nodes and file edges then remain in the scoped global graph and can still affect scoring or appear through relatedFiles. Graph recall must be disabled or otherwise filtered when ownership for a withheld codebase cannot be loaded.

  • [P2 non-blocking] src/graph-aggregate.ts:124 — Clearing every identifier also claimed by an allowed repository preserves withheld relationships between colliding identifiers. For example, if both repositories define common nodes such as component/App and component/Config, a withheld-only edge between them remains and can boost unrelated allowed pages. Shared nodes may need preserving, but edges require repository-aware ownership.

The previously reported router filtering, reconcile-added node filtering, documentation propagation, malformed-manifest handling, and real-CLI testing gaps are resolved. The PR description now documents sufficient testing.

…ope colliding-slug edges (Tencent#974 review round 5)

codex-review's sixth pass found scopeGlobalGraph still failed OPEN in
one real scenario, plus a narrower P2 it had already flagged as
needing repository-aware edge handling:

- [P1] `teamai codebase --extract` run directly (not through `teamai
  import`'s cache-then-copy orchestration) writes only the global
  `teamwiki/.indices/graph-index.json` and never populates
  `evidence/code/<slug>/.indices/graph-index.json` for that codebase.
  scopeGlobalGraph's withheld-identifier collection only reads that
  per-repo path, so a codebase extracted this way had NO identifiers
  collected for it at all when withheld — its fact-level nodes and
  edges stayed in the "scoped" graph fully exposed, able to affect
  BM25 entry-node matching, graph-boost scoring, or `relatedFiles`.
  Fixed by failing closed instead of open: if any withheld codebase's
  per-repo graph file is missing or unreadable, scopeGlobalGraph now
  returns `null` (no graph at all for this query) rather than a result
  it cannot vouch for. `queryCodeKnowledge` already treats a `null`
  graph as "skip graph-based features," the same as when no graph
  exists at all, so this needed no change on the consumer side.

- [P2] Clearing an identifier shared between an allowed and withheld
  repo (round 3's fix for colliding fact-level slugs like
  `component/App`) correctly keeps both repos' nodes, but a withheld
  repo can also have an EDGE directly between two such colliding
  names — a relationship that only ever existed in the withheld repo,
  which neither endpoint-identifier removal nor the "shared identifier
  survives" rule would catch, since both endpoints end up allowed.
  Fixed by tracking edge pairs (`from|to`) the same way identifiers
  are tracked: a withheld-only pair is subtracted even when both of
  its endpoints individually survive; a pair also present in an
  allowed repo's own graph is still preserved.

Verified via the real built CLI: a codebase with evidence pages but no
per-repo graph file (simulating a direct `--extract`) makes an
unrelated, fully-accounted-for codebase's own graph-derived
"Candidate change files" entry disappear too the moment the
no-graph-file codebase is withheld — confirming the fail-closed
behavior applies to the whole query, not just the unaccounted
codebase.

3 tests added to graph-aggregate.test.ts (fail-closed on a missing
per-repo file; does NOT fail closed when only an allowed, unrelated
codebase lacks one; the colliding-edge subtraction). 138 tests total
across every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both findings in 200c1c6:

  1. P1, fail-open on a missing per-repo graph: confirmed real and the most serious one yet. teamai codebase --extract run directly (not through teamai import) writes only the global teamwiki/.indices/graph-index.json — it never populates evidence/code/<slug>/.indices/graph-index.json. scopeGlobalGraph only ever read that per-repo path, so a codebase extracted this way had zero identifiers collected for it when withheld, leaving its fact-level content fully exposed. Fixed by failing closed: if any withheld codebase's per-repo graph file can't be loaded, scopeGlobalGraph now returns null for the whole query rather than a result it can't vouch for. queryCodeKnowledge already treats null as "no graph-based features," so nothing downstream needed to change.

  2. P2, colliding-edge leak: confirmed real. Clearing a shared identifier (round 3's fix) correctly keeps both repos' nodes, but a withheld-only edge between two colliding names wasn't caught, since both its endpoints individually survive. Added edge-pair (from|to) tracking alongside the identifier tracking: a withheld-only pair is subtracted even when both endpoints survive; a pair also present in an allowed repo is preserved.

Real-CLI re-verification

A codebase with evidence pages but no per-repo graph file (simulating a direct --extract) now makes an unrelated, fully-accounted-for codebase's own "Candidate change files" entry disappear too, the moment the no-graph-file codebase is withheld — confirming fail-closed applies to the whole query.

Tests

3 tests added to graph-aggregate.test.ts (fail-closed on missing file; does not fail closed when only an unrelated allowed codebase lacks one; colliding-edge subtraction). 138 tests total pass; typecheck and lint clean.

PR description updated with the full round-5 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/code-knowledge-recall.ts:273 — A mixed domain retains every unlinked fallback line. For example, routerTemplate() with allowed svc-a plus an unmatched component name for withheld svc-b keeps that component name because the section contains an allowed link and lineSlug() returns null for the bare line. recall --depth route therefore still exposes withheld component information.
  • [P1 blocking] src/graph-aggregate.ts:157 — When allowed and withheld repositories share an unqualified node slug, the slug is retained but its ownership-specific metadata is not restored. Since mergeGraphs() lets the later overlay win, the retained global node can still carry the withheld repository’s title/domain; querying that title then selects it as an entry node and affects recall scoring.
  • [P2 non-blocking] src/graph-aggregate.ts:122 — Edge ownership keys omit relation. If an allowed repository has App → Config as REFERENCES and a withheld repository has the same endpoints as DEPENDS_ON, the allowed pair clears the withheld pair and both global edges survive. Include at least relation, matching the graph’s actual edge identity.
  • [P2 non-blocking] docs/usage-guide.md:1949 — The guide says wiki scoping requires manifest/projects.yaml, although the implementation also supports role-only declarations in manifest/roles.yaml. The corresponding statement in docs/usage-guide.zh-CN.md:1794 is stale too.

The previously reported missing-graph fail-open issue is resolved, and the PR description now includes sufficient representative real-CLI verification.

…ding node's metadata (Tencent#974 review round 6)

codex-review's seventh pass found a real P1 in router filtering that
survived the round-4 fix, a real P1 in the colliding-slug handling
added in round 5, a related P2, and a stale doc claim:

- [P1] A mixed router domain section (some links allowed, some
  withheld) correctly dropped withheld-linked lines, but a bare
  unresolved-component fallback line (`routerTemplate`'s "no project
  match" branch, no link at all) survived regardless, since
  `lineSlug()` can never attribute an unlinked line to either side.
  `filterRouterContent` only ever runs when something IS withheld, so
  that ambiguity can't be resolved safely either way — fixed by
  dropping every unlinked bullet line unconditionally, the same
  fail-closed-on-ambiguity rule `scopeGlobalGraph` already applies to
  graph ownership it cannot verify, rather than trusting an unlinked
  line is innocent just because an allowed link sits nearby.

- [P1] Clearing a slug an allowed repo also claims (round 5's fix for
  cross-repo slug collisions) correctly kept the node, but never
  corrected its DATA: `mergeGraphs` lets the later-processed repo's
  node win outright with no field-level merge, so the surviving global
  node could still carry the withheld repo's title/domain if that
  repo's write happened to win. Querying that title could then select
  it as an entry node and affect scoring. Fixed by re-attaching the
  allowed repo's own copy of a contested node (read in the same
  per-repo scan already in place) so its metadata is actually
  attributable to an allowed source. Required also tracking which
  slugs were contested BEFORE the allowed set clears them out of the
  withheld set, since the existing "nothing to remove" fast path would
  otherwise skip this restoration too.

- [P2] The edge-ownership key omitted `relation`, so an allowed
  `App -REFERENCES-> Config` cleared a withheld `App -DEPENDS_ON->
  Config` out of the withheld set even though it never claimed that
  specific relation — both edges then survived. Fixed by keying edges
  on `from|to|relation`, matching the graph schema's own edge identity
  (`graphEdgeKey` in graph-index.schema.ts already does the same).

- [P2] `usage-guide.md` (+ zh-CN) said wiki scoping needs a team repo
  "with manifest/projects.yaml", but a role-only declaration in
  manifest/roles.yaml works with no projects.yaml at all (and the rest
  of the same sentence already said "any role or project"). Reworded
  to not imply projects.yaml is required.

Verified via the real built CLI and a direct scopeGlobalGraph call: a
mixed router domain's unmatched component name is now stripped
alongside the allowed line staying; a single scopeGlobalGraph call
against a graph where the withheld repo's node metadata won the merge
now returns the allowed repo's title/domain for the shared slug AND
keeps only the allowed REFERENCES edge between the colliding
endpoints, dropping the withheld DEPENDS_ON one.

4 tests added (1 router mixed-domain bare-line test; 2 graph tests for
metadata restoration and relation-aware edge keys — the metadata test
caught the fast-path bug above on its first run). 141 tests total
across every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all four findings in 5bd9f5f:

  1. P1, mixed-section bare-line leak: confirmed real. A bare unresolved-component fallback line carries no link at all, so it can never be attributed to either side — and since this function only ever runs when something is withheld, that ambiguity can't be resolved safely. Now drops every unlinked bullet line unconditionally, same fail-closed principle as scopeGlobalGraph.

  2. P1, colliding-slug metadata leak: confirmed real, and non-trivial to fix. Keeping a shared slug (round 5's fix) never corrected its data — mergeGraphs lets the later-processed repo win outright with no field merge, so the surviving node could carry the withheld repo's title/domain. Fixed by re-attaching the allowed repo's own copy of a contested node. Writing the test for this caught a second bug: the existing "nothing to remove" fast-path skipped this restoration entirely when the only withheld content was a slug that's also allowed (nothing to remove, but still something to correct) — fixed by tracking contested slugs before they get cleared.

  3. P2, relation-blind edge key: confirmed real. Added relation to the edge-ownership key so an allowed edge under one relation can't clear a withheld edge under a different one between the same endpoints.

  4. P2, stale doc: reworded usage-guide.md (+ zh-CN) to not imply manifest/projects.yaml is required — a role-only declaration works too.

Real-CLI re-verification

  • A mixed router domain's unmatched bare component line is now stripped while the allowed linked line stays.
  • A single scopeGlobalGraph call against a graph where the withheld repo's write won the merge now returns the allowed repo's title/domain for the shared slug, and keeps only the allowed REFERENCES edge while dropping the withheld DEPENDS_ON one between the same endpoints.

Tests

4 tests added (1 router mixed-domain bare-line test, 2 graph tests — the metadata-restoration test caught the fast-path bug on its first run before I fixed it). 141 tests total pass; typecheck and lint clean.

PR description updated with the full round-6 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/graph-aggregate.ts:165 — Any syntactically valid JSON is marked as successfully accounted for without schema validation. If a withheld repository’s per-repo graph contains {} or another structurally invalid object while the valid global graph still contains that repository’s nodes, accountedWithheld is populated but no identifiers are removed, so the withheld graph remains exposed. Only mark it accounted after validating the graph shape.

  • [P1 blocking] src/graph-aggregate.ts:163 — Per-repository edge keys use the raw relation, while loadGraphIndex() normalizes legacy imports relations in the global graph to DEPENDS_ON. When both endpoints are also claimed by an allowed repository, they survive identifier filtering and the withheld key from|to|imports cannot remove the normalized global edge from|to|DEPENDS_ON; that withheld relationship can still affect scoring or relatedFiles. Normalize per-repository graphs before computing ownership keys.

  • [P3 nit] src/graph-aggregate.ts:137 — Concatenating edge identity with | aliases distinct edges such as a|b → c and a → b|c. If one belongs to an allowed repository, it can clear the withheld edge’s key and preserve it. The graph schema already avoids this ambiguity with JSON.stringify([from, to, relation]).

The earlier findings are resolved in the current diff, and the PR description now includes sufficient representative real-CLI verification.

…ions before scoping (Tencent#974 review round 7)

codex-review's eighth pass found two more real P1s in scopeGlobalGraph
and a cheap robustness nit:

- [P1] A withheld codebase's per-repo graph file was marked "accounted
  for" the moment JSON.parse succeeded, with no check that the result
  was actually a graph. A truncated or otherwise corrupted write can
  leave syntactically valid JSON (`{}`) that is not a graph at all —
  `nodes ?? []` / `edges ?? []` would silently contribute zero
  identifiers while the global graph still has that codebase's real
  content, so scoping would run as if there were nothing to subtract.
  Fixed by validating the parsed result has `nodes`/`edges` arrays
  before marking it accounted for; anything else now falls into the
  same fail-closed path as an unreadable file.

- [P1] Per-repo-file edge-ownership keys used the raw relation string,
  but `loadGraphIndex` normalizes the legacy `imports` relation name to
  `DEPENDS_ON` when it loads the global graph. scopeGlobalGraph reads
  per-repo files with a plain JSON.parse, bypassing that normalization
  — so a withheld repo's own `imports` edge computed a key that could
  never match the already-normalized `DEPENDS_ON` edge in the loaded
  global graph, and that withheld relationship would survive. Fixed by
  normalizing the relation the same way before computing the key
  (exported `LEGACY_RELATIONS` from graph-index.schema.ts rather than
  duplicating the mapping).

- [P3] The edge-ownership key concatenated `from|to|relation` with a
  plain `|`, which a `|`-containing identifier could alias (`a|b -> c`
  vs `a -> b|c`). Switched to `JSON.stringify([from, to, relation])`,
  matching `graphEdgeKey`'s own approach in graph-index.schema.ts.

Verified via the real built code: a structurally-invalid-but-valid-JSON
per-repo file (`{}`) for a withheld codebase now makes scopeGlobalGraph
return null instead of silently treating it as nothing-to-subtract;
and a withheld edge recorded under the legacy `imports` relation name
is now correctly normalized and removed, leaving only the allowed
REFERENCES edge between the same colliding endpoints.

2 tests added to graph-aggregate.test.ts (structural-validation
fail-closed; legacy-relation-name normalization). 143 tests total
across every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three findings in c04c19f:

  1. P1, unvalidated per-repo graph shape: confirmed real. JSON.parse succeeding on {} (or any structurally wrong object) was enough to mark a withheld codebase "accounted for," contributing zero identifiers while the global graph could still have its real content. Fixed by validating nodes/edges are actually arrays before marking it accounted for — anything else now falls into the existing fail-closed path.

  2. P1, relation-normalization mismatch: confirmed real. loadGraphIndex normalizes the legacy imports relation to DEPENDS_ON when it loads the global graph, but scopeGlobalGraph's per-repo-file read is a plain JSON.parse that never normalizes — so a withheld repo's own imports edge could never match its normalized counterpart in the loaded global graph. Fixed by exporting LEGACY_RELATIONS from graph-index.schema.ts and normalizing before computing the key, rather than duplicating the mapping.

  3. P3, edge-key aliasing: switched from |-concatenation to JSON.stringify([from, to, relation]), matching the graph schema's own graphEdgeKey approach.

Real-CLI re-verification

  • A withheld codebase's per-repo file containing {} (valid JSON, invalid graph) now makes scopeGlobalGraph return null.
  • A withheld edge recorded under the legacy imports name is now correctly normalized and removed, leaving only the allowed REFERENCES edge between the same colliding endpoints.

Tests

2 tests added to graph-aggregate.test.ts. 143 tests total pass; typecheck and lint clean.

PR description updated with the full round-7 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/graph-aggregate.ts:174 — The “structural validation” only checks that nodes and edges are arrays. A withheld repo file such as {"nodes":[],"edges":[]} or one containing schema-invalid node objects is marked accounted for, while its valid fact-level nodes remain in the global graph. Scoping then returns that graph unchanged, exposing the withheld repository through matching and graph boosts. Validate the complete graph schema before adding accountedWithheld.
  • [P1 blocking] src/graph-aggregate.ts:241 — Contested-node restoration spreads the raw per-repo node over the normalized global node. For a supported legacy graph using id/label/kind, the allowed node’s label does not replace title; therefore, if the withheld repository’s colliding node won aggregation, its title remains searchable after scoping. Normalize the allowed per-repo graph with the same schema as loadGraphIndex() before storing restoration nodes.

The previously reported router filtering, documentation/help propagation, malformed-manifest handling, edge-key normalization, and real-CLI testing-record issues are resolved.

…se shape check (Tencent#974 review round 8)

codex-review's ninth pass found the round-7 fix for an unvalidated
per-repo graph file was itself too shallow, which also explained a
second P1 in the metadata-restoration path:

- [P1] The "structural validation" added last round only checked that
  `nodes`/`edges` were arrays. A node that doesn't conform to
  GraphNodeSchema (missing required fields, wrong types) still passed
  that check and was silently skipped (no slug extracted, contributing
  nothing) while counting the whole file as "successfully read
  ownership from" — so a withheld codebase with any schema-invalid
  nodes mixed into real ones could still leave those real nodes
  exposed in the global graph. Fixed by reusing `parseGraphIndex` (now
  exported from graph-index.schema.ts) — the exact same schema
  `loadGraphIndex` validates the global graph with — instead of a
  loose array check. Anything that doesn't validate now falls into the
  same fail-closed path as an unreadable file.

- [P1] Restoring a contested node's metadata (round 6's fix) spread
  the RAW per-repo JSON object over the surviving global node. For a
  node using the legacy `id`/`label`/`kind` shape, the raw object has
  no `title` key at all (only `label`) — so the spread set `id`,
  `label`, `kind` but never actually overrode `title`, leaving the
  withheld repo's title in place if its write had won the merge.
  Fixed by the same `parseGraphIndex` reuse: it already normalizes
  `label`→`title`, `id`→`slug`, `kind`→`type` the same way the global
  graph itself was normalized on load, so the node now stored for
  restoration carries the right field names, not just the right file.

Reusing one validator for both problems let the manual relation
normalization added last round (`normalizeRelation`) come out too —
`parseGraphIndex`'s edge schema already normalizes the legacy
`imports` relation name, so a second, hand-rolled copy of that mapping
is no longer needed.

Verified via the real compiled code: a withheld codebase's per-repo
file with syntactically-valid-but-schema-invalid nodes now makes
scopeGlobalGraph return null; and restoring a contested node whose
allowed repo used the legacy label field now correctly shows that
repo's title, not the stale one left by whichever repo's write won
the merge.

2 tests added to graph-aggregate.test.ts (schema-invalid node
fail-closed; legacy-field-normalized restoration). 145 tests total
across every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erring it from per-repo files at query time (Tencent#974 review round 10)

codex-review's eleventh pass pointed out that every per-repo-file-based
fail-closed guard added over the last several rounds (missing,
unreadable, schema-invalid, empty) still shares one root assumption:
that a withheld codebase's CURRENT per-repo file completely and
accurately describes everything it ever contributed to the global
graph. An interrupted `teamai import` breaks exactly that assumption —
it can replace a withheld codebase's per-repo graph with a different,
valid, non-empty one and be interrupted before the next
aggregateGlobalGraph() call folds the change in, leaving the global
graph holding OLD nodes/edges that no longer appear anywhere in the
per-repo file scopeGlobalGraph would read. No amount of validating
that file harder closes this, since the file can be perfectly valid
and just describe something else now.

Fixed at the root instead, per the review's own suggestion: ownership
is now stamped onto the data itself at the moment it is merged.
`buildAggregatedGraph` tags every node and edge from a per-repo file
with `origin: <that codebase's slug>` before merging it in (`origin`
is now a first-class optional field on `GraphNode`/`GraphEdge`, not a
passthrough accident). That tag travels with the data in the global
graph forever after — correct regardless of whatever the per-repo
file is later rewritten to, emptied to, or deleted to.

`scopeGlobalGraph` now checks tags first: a withheld codebase with ANY
`origin`-tagged content in the global graph is "tag-covered," and its
node/edge removal is read directly off those tags — no per-repo file
read at all. Only a codebase tagging has never covered (predates this
field, or was extracted directly via `teamai codebase --extract`
outside `teamai import`'s per-repo-file-producing orchestration) falls
back to the existing per-repo-file mechanism, with all of its existing
fail-closed guards intact for exactly that narrower, legacy case.
Allowed codebases are still always read regardless of tagging, since
collision handling (two codebases sharing one unqualified fact-level
slug) needs an allowed codebase's own data to restore — the merged
graph keeps only one node per colliding slug, so tagging alone can't
tell "this slug is ALSO legitimately an allowed codebase's."

Verified via the real built CLI and a direct scopeGlobalGraph call
reproducing the review's exact scenario: aggregate once (tagging
svc-b's node), replace svc-b's per-repo file with a newer, valid,
non-overlapping graph with no re-aggregation, then confirm the OLD
tagged node is still correctly removed — without the function ever
needing to read (or being misled by) the replaced file.

3 of the existing per-repo-file fail-closed tests now assert the
strictly better outcome this enables (precise scoping instead of
failing the whole query closed, since tagging already covers those
cases) rather than being weakened; a 4th, reproducing the review's
exact interrupted-reimport scenario, is new, alongside a kept
regression test confirming the per-repo-file fallback still fails
closed for a codebase tagging has never covered. 148 tests total
across every touched/related file pass; typecheck and lint are clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in b569a02 — this one went to the root cause rather than another patch.

Confirmed real and correctly identified as the common thread: every per-repo-file fail-closed guard from the last several rounds (missing, unreadable, schema-invalid, empty) shared one assumption — that a withheld codebase's current per-repo file completely describes everything it ever contributed to the global graph. An interrupted teamai import breaks that: it can replace the per-repo file with a different, valid, non-empty graph and be interrupted before the next re-aggregation, leaving the global graph holding old content the new file no longer mentions at all. No amount of validating the file harder closes that, since it's genuinely valid — just describing something else.

Fix: took your second suggested option — ownership is now stamped onto the data at merge time, not re-inferred from a separate, independently-mutable file at query time. buildAggregatedGraph tags every node/edge with origin: <codebase-slug> when it merges a per-repo file in (origin is now a first-class field on GraphNode/GraphEdge). That tag travels with the data permanently — correct no matter what the per-repo file is later rewritten to, emptied to, or deleted to. scopeGlobalGraph now reads tags first: a withheld codebase with any tagged content is removed directly by tag, no per-repo file touched at all. The per-repo-file mechanism (with all its existing fail-closed guards) becomes a fallback used only for a codebase tagging has never covered — predates the field, or was extracted directly via codebase --extract outside import's per-repo-file-producing path.

Collision handling (two codebases sharing one unqualified slug) still reads an allowed codebase's own file regardless of tagging — tagging alone can't express "this slug is also legitimately someone else's," since the merged graph keeps only one node per slug.

Real-CLI re-verification

Reproduced your exact scenario directly: aggregate once (tagging svc-b's node), replace svc-b's per-repo file with a newer, valid, non-overlapping graph, skip re-aggregation, then confirm the OLD tagged node is still correctly removed from a real scopeGlobalGraph call — the replaced file is never read or trusted.

Tests

3 of the existing per-repo-file fail-closed tests now assert the strictly better outcome tagging enables (precise scoping instead of failing the whole query closed) rather than being weakened; a 4th reproduces the interrupted-reimport scenario exactly; a kept test confirms the fallback still fails closed for a codebase tagging has never covered. 148 tests total pass; typecheck and lint clean.

PR description updated with the full round-10 record.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/graph-aggregate.ts:214 — tagCovered skips the per-repo ownership fallback when any tagged object exists. After teamai codebase --reconcile, missing endpoints of tagged code-ast/code-heuristic edges are materialized as nodes without origin. Withholding that codebase removes the tagged edges but leaves those originless file nodes searchable, allowing withheld data to affect entry-node matching and graph boosts.
  • [P2 non-blocking] docs/product-overview.md:70 — The resource matrix and namespace overview still stop at docs and omit the new resources.wiki scoping behavior. Update this and docs/product-overview.zh-CN.md:70 to keep affected bilingual documentation synchronized.

The prior stale-generation issue is resolved by aggregation-time origin tagging. The PR description includes sufficient representative real-CLI verification.

…es (Tencent#974 review round 11)

`teamai codebase --reconcile` repairs a code-ast/code-heuristic edge whose
endpoint isn't a node yet by minting one, but never copied the edge's own
`origin` tag onto it. A withheld codebase's tagged edges got removed from
scopeGlobalGraph's output, but these origin-less placeholder nodes stayed
behind and searchable, letting withheld content affect entry-node matching
and graph boosts.

Both endpoints of an intra-repo AST/heuristic edge belong to the same
codebase that produced it, so the fix stamps `origin: edge.origin` on the
node at the moment it's minted — consistent with round 10's "tag ownership
at creation, not inferred later" approach. scopeGlobalGraph needs no changes:
its existing origin-tag pass already removes a tagged node.

Also fills the P2 doc gap: product-overview.md's resource-namespace summary
(and its zh-CN counterpart) covered docs scoping but never mentioned that
the Team Context codebase graph is scoped the same way via resources.wiki.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — confirmed, both findings are real.

P1 (graph-aggregate.ts:214 / knowledge-reconciler.ts). Root cause was in the reconciler, not in the tagCovered check itself: loadReconciliationBase's endpoint-repair loop (for code-ast/code-heuristic edges whose from/to isn't a node yet) minted a placeholder node but never copied the edge's own origin tag onto it. So after --reconcile, a withheld codebase's tagged edge got removed by scopeGlobalGraph's origin pass, but these origin-less placeholder nodes survived — and because the codebase already had other tagged content, tagCovered correctly skipped the (now unnecessary, and per round 10, actively unsafe) per-repo-file fallback for it, so nothing else caught them either.

Fixed at the source rather than adding another branch to scopeGlobalGraph: both endpoints of an intra-repo AST/heuristic edge belong to the one codebase that produced the edge, so loadReconciliationBase now stamps origin: edge.origin on the node the moment it mints it. scopeGlobalGraph needed zero changes — its existing origin-tag removal pass already handles a node once it's tagged, which is exactly the point of tagging ownership at creation time instead of inferring it later.

Added codebase-reconcile.test.ts coverage that aggregates a per-repo graph (tagging it), runs the real --reconcile command, confirms both repaired endpoint nodes carry the edge's origin, and confirms scopeGlobalGraph removes both when that codebase is withheld. Also reproduced this via the actual compiled CLI's codebase --reconcile (not just the unit test) against a hand-built fixture, confirmed in the PR body's Real-CLI Verification section.

P2 (docs). Added a one-line cross-reference from product-overview.md's (+ zh-CN) resource-namespace summary to the existing, already-detailed resources.wiki coverage in usage-guide.md.

150 tests passing (51 new/rewritten), typecheck and lint clean. Pushed as df85d42. PR description updated with a "Review follow-up round 11" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/graph-aggregate.ts:201 — Synthesized cross-repository edges have no origin, so they are removed only when an endpoint is removed. If the withheld endpoint’s unqualified slug is also claimed by an allowed repository, collision handling preserves that endpoint and the withheld-derived cross-repo edge survives, affecting graph boosts despite the originating repository being inactive.
  • [P2 non-blocking] src/graph-aggregate.ts:292 — The global-only cleanup checks only node slugs. import-iwiki.ts can add originless MAPS_TO edges directly to evidence/code/<withheld>/<page>.md without creating endpoint nodes, so those edges remain in the scoped graph. Prefix-filter originless edge endpoints as well.

The previously reported originless repaired-endpoint issue is resolved. The PR description includes sufficient representative real-CLI verification.

… catch originless withheld edge endpoints (Tencent#974 review round 12)

Two gaps in scopeGlobalGraph's origin-tag removal, both P2:

- detectCrossRepoEdges synthesizes an edge spanning two codebases and left
  it fully untagged. If the matched node's unqualified slug later collided
  with an allowed repo's (last write wins the merge), the edge survived
  scoping even though it was only ever detected because of the withheld
  repo's component. Fixed by tagging the edge with the origin of whichever
  side was matched by label lookup at detection time (captured before any
  later collision can reattribute it), not re-derived from the final graph.

- The evidence/code/<slug>/ prefix pass that catches --reconcile-added
  content only scanned node slugs. import-iwiki.ts's own reconciler can
  write a MAPS_TO edge straight to the global graph pointing at a code
  page path with no node ever created for it, so that edge's endpoint was
  never caught. Extended the same prefix check to edge endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both P2 findings are real, confirmed and fixed.

graph-aggregate.ts:201 (cross-repo edge survives a slug collision). detectCrossRepoEdges synthesizes an edge spanning two codebases and left it fully untagged (correct in isolation — no single origin fully describes a two-codebase edge). But if the matched node's unqualified slug was later also minted by a third, allowed repo whose write won the merge, the edge had no tag path left at all: the node it pointed at no longer carried the withheld origin, and the edge never did either, so it survived.

Fixed by tagging the edge, at the exact moment detectCrossRepoEdges matches it, with the origin of whichever side was found via label lookup in the OTHER graph — the specific codebase this edge's existence actually depends on, captured before any later collision can reattribute it. No changes needed in scopeGlobalGraph itself: its edge-level origin-tag removal already handles any tagged edge.

graph-aggregate.ts:292 (originless edge endpoint from import-iwiki.ts). Confirmed — the prefix pass only scanned nodes[], so import-iwiki.ts's own direct-to-global-graph MAPS_TO edges (pointing at a code page path with no node ever created for it) slipped through. Extended the identical prefix check to edge endpoints.

Added regression coverage for both: cross-repo-edges.test.ts for the origin-tagging itself, and a graph-aggregate.test.ts test that reproduces the full aggregate → collision → scope pipeline end to end (confirms the cross-repo edge is gone while the now-allowed node and an unrelated edge both survive), plus one for the originless import-iwiki.ts-style edge. Also re-verified both against the compiled source directly (not just the unit tests), matching the PR body's Real-CLI Verification section.

158 tests passing (55 new/rewritten), typecheck and lint clean. Pushed as e1784f7. PR description updated with a "Review follow-up round 12" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/import-repo.ts:120 — Cross-repo edges record only the matched dependency’s origin, although the relationship depends on both repositories. For example, if withheld svc-b imports allowed svc-a, while allowed svc-c shares svc-b’s unqualified source-node slug, collision restoration keeps both endpoints and the edge’s origin: svc-a prevents scopeGlobalGraph() from removing it. The withheld svc-b relationship can therefore still affect graph boosts. Track both participating origins, or remove the edge when either repository is withheld.

The previously reported originless-edge endpoint gaps are resolved. The PR description includes sufficient representative real-CLI verification.

…tched side (Tencent#974 review round 13)

Round 12 tagged a synthesized cross-repo edge with only the origin of
whichever side detectCrossRepoEdges matched by label lookup. But the
relationship depends on BOTH sides: the repo whose own import produced the
match, and the repo that was matched. If the OTHER side's unqualified slug
later collided with a third, allowed repo (last write wins the merge), the
edge had no tag left pointing at the real withheld repo and survived.

Replaced the single-valued origin tag on cross-repo edges with a new
GraphEdge.crossOrigins: string[] field carrying both sides' origins,
captured at detection time before any later collision can reattribute
either endpoint. scopeGlobalGraph now withholds the edge if EITHER entry
is in the withheld set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — confirmed, this is real, and your example pinpoints exactly why round 12's fix was incomplete.

Round 12 tagged a synthesized cross-repo edge with only the origin of whichever side detectCrossRepoEdges matched by label lookup — I'd only tested a collision on that matched/target side. Your example flips it to the source/importer side: withheld svc-b imports allowed svc-a, and a third, allowed svc-c collides on svc-b's own importer-node slug instead. Collision handling correctly lets svc-c's node survive, but the edge's single tag was svc-a (allowed) — so withholding svc-b left nothing on the edge pointing back at it.

Fixed by replacing the single-valued origin tag on a cross-repo edge with a new GraphEdge.crossOrigins: string[] field carrying BOTH sides' origins (the importer's and the matched one's), captured at the moment detectCrossRepoEdges detects the edge — before either endpoint can be silently reattributed by a later collision. scopeGlobalGraph's edge-tag pass now withholds the edge if EITHER entry is in the withheld set; a plain single-origin edge (the common case) still works exactly as before.

Added a new test reproducing your exact scenario (source-side collision) alongside the existing matched-side one, plus unit coverage in cross-repo-edges.test.ts for crossOrigins itself. Re-verified both end to end against the compiled source directly (not just the unit tests) — confirmed in the PR body's Real-CLI Verification section.

161 tests passing (58 new/rewritten), typecheck and lint clean. Pushed as c446bbd. PR description updated with a "Review follow-up round 13" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/import-repo.ts:145 — The reverse cross-repo scan attributes the importing side using the current node found by edge.from, rather than the import edge’s authoritative edge.origin. If a withheld importer’s node slug was already overwritten by an allowed repository before the target repository is processed, the synthesized edge is tagged only with the two allowed origins and survives scoping, allowing the withheld import relationship to affect graph boosts. Use edge.origin ?? fromNode.origin for the importing side.
  • [P1 blocking] src/graph-aggregate.ts:294 — The legacy untagged-graph fallback still cannot remove synthesized cross-repo edges when the withheld endpoint slug is also claimed by an allowed repository. The shared slug is removed from withheldIds, while the originless synthesized edge is absent from every per-repo edge set, so an existing pre-upgrade graph can retain a withheld-derived relationship. Legacy graphs with unverifiable cross-repo edge ownership must fail closed or persist ownership separately.
  • [P2 non-blocking] src/graph-aggregate.ts:213 — Cross-repo edges are removed by the shared from/to/relation key. If both an allowed and a withheld importer independently produce the same synthesized edge, the withheld copy marks the key and filtering removes both copies, discarding the valid allowed relationship. Track whether an equivalent cross-edge has fully allowed provenance before removing the identity.

The other previously reported findings appear resolved, and the PR description includes sufficient representative real-CLI verification.

…ttributable legacy cross-edges, and union crossOrigins on merge (Tencent#974 review round 14)

Three gaps, two P1 and one P2:

- detectCrossRepoEdges' reverse scan derived the importing side's origin
  by re-looking-up whichever node CURRENTLY sits at the import edge's
  `from` slug, instead of using that edge's own origin tag. If a withheld
  importer's node slug was already overwritten by a later, unrelated
  allowed repo before the target repo gets processed, the synthesized
  edge ends up tagged with two allowed origins and survives scoping. Fixed
  by preferring edge.origin (stable once tagged, immune to node
  collisions) over a fresh fromNode lookup, falling back to it only for
  edges that predate origin tagging.

- The per-repo-file fallback (for codebases not covered by origin tagging)
  can never discover a cross-repo edge at all, since cross edges are
  synthesized straight into the global graph and never written to any
  per-repo file. When such a withheld codebase's node survives a slug
  collision with an allowed repo, an untagged legacy cross-edge touching
  that slug has no path left to removal. Fixed by failing closed
  (returning null) when a fallback-withheld codebase's contested slug is
  an endpoint of an edge that looks like a legacy, unattributable
  cross-repo edge (DEPENDS_ON, no origin/crossOrigins/source).

- mergeGraphs silently discarded one side's crossOrigins whenever two
  codebases independently produced a cross-repo edge with the identical
  from/to/relation identity, making removal depend on merge order. Fixed
  by unioning crossOrigins on collision instead of overwriting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three confirmed and fixed.

import-repo.ts:145 (stale fromNode lookup). Confirmed exactly as described: the reverse scan re-derived the importing side's origin from whichever node currently sits at the import edge's from slug in the accumulated existing graph, which a LATER, unrelated repo's collision can silently reattribute before the matching repo is even processed — losing the withheld importer's identity entirely. Fixed by using edge.origin ?? fromNode.origin: the import edge's own tag, stamped once at the time it was aggregated, is immune to any node collision that happens afterward. Applied to both the forward and reverse scans for consistency, though only the reverse one was actually exploitable given current processing order.

graph-aggregate.ts:294 (unattributable legacy cross-edge). Confirmed — per-repo files never list a synthesized cross-repo edge (it's written straight to the global graph, never to any per-repo file), so the fallback mechanism used for a codebase origin tagging has never covered has no way to discover one at all. When such a codebase's node survives a legitimate slug collision with an allowed repo, an untagged legacy cross-edge touching that slug had no remaining removal path, and there's no way to retroactively recover its true provenance. Took your suggested direction: fail closed. scopeGlobalGraph now returns null when a fallback-withheld codebase's contested slug is the endpoint of an edge that looks like a legacy, unattributable cross-repo edge (DEPENDS_ON, no origin/crossOrigins/source) — scoped precisely to fallback-sourced slugs so the common, fully tag-covered case is unaffected.

graph-aggregate.ts:213 (shared edge-identity collision). Confirmed — mergeGraphs let whichever side won its evidence-based tie-break silently discard the other side's crossOrigins outright, making removal depend on merge order (and, in the worst order, making a withheld repo's contribution to that edge simply vanish, un-removable, rather than merely risk over-removing an allowed one). Fixed by unioning crossOrigins on collision. I kept this to union + fail-closed-removal rather than full independent-pair tracking (which relation/edge-key alone can't disambiguate without a larger data model change) — an edge touched by any withheld origin is still removed even if an allowed repo also independently produces the identical shape, erring toward the safe direction for what both of us read as a genuinely rare case; flagging that tradeoff explicitly in case you'd rather see it taken further.

Added regression tests for all three plus a direct-source probe reproducing each end to end (details in the PR body's Real-CLI Verification section). 168 tests passing, typecheck and lint clean. Pushed as 07ba878. PR description updated with a "Review follow-up round 14" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/wiki-engine/core/graph-index.schema.ts:579 — Flattening crossOrigins conflates alternative derivations with dependencies. If svc-w → svc-z and svc-y → svc-z independently produce the same edge, the merged origins become [svc-w, svc-y, svc-z]; withholding only svc-w then removes the edge even though the fully allowed svc-y/svc-z derivation remains valid. Preserve separate provenance sets, or retain the edge when at least one complete derivation is allowed.

The other previously reported findings appear resolved. The PR description includes sufficient representative real-CLI verification.

…ot a flat origin set (Tencent#974 review round 15)

Round 14 unioned a cross-repo edge's two origins into a flat crossOrigins
set on merge collision, so withholding EITHER origin removed the edge —
discarding a different, fully-allowed repo pair's equally valid claim on
the identical edge identity. Replaced crossOrigins: string[] with
crossOriginPairs: string[][], one pair per independent detection event;
scopeGlobalGraph now withholds the edge only when EVERY pair has a
withheld member, so an edge that remains independently producible by a
fully-allowed pair survives.

This also uncovered that buildAggregatedGraph appended newly-detected
cross-edges to the global graph with a raw push, bypassing mergeGraphs
entirely — so two independent detections sharing one from/to/relation key
never actually got unioned onto one edge object; they sat as separate
array entries both removable by the same shared key. Routed cross-edges
through mergeGraphs instead, which is what makes the pair-tracking above
actually take effect in the real aggregation pipeline, not just in a
direct mergeGraphs unit test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — this is real, and it's the exact tradeoff I flagged when I said "flagging that tradeoff explicitly in case you'd rather see it taken further." Taking it further now.

Replaced crossOrigins: string[] with crossOriginPairs: string[][] — one pair per independent detection event, instead of a flat set that loses which origins actually came paired together. scopeGlobalGraph now withholds a cross-repo edge only when EVERY pair has at least one withheld member, so an edge that's also independently producible by a fully-allowed pair survives, even though the identical edge identity was separately produced by a withheld one too.

Implementing this surfaced a second bug the round-14 unit test alone never would have caught: buildAggregatedGraph was appending newly-detected cross-edges to the global graph with a raw .push(), completely bypassing mergeGraphs. So in the real aggregation pipeline, two independent detections sharing one from/to/relation key never actually landed on one merged edge object at all — they sat as two separate array entries, and scopeGlobalGraph's removal is keyed, so marking either one withheld removed both regardless of any pair-tracking logic. Routed cross-edges through mergeGraphs too, which is what actually makes the pair-tracking fix effective end to end rather than just in a direct mergeGraphs call.

Added a mergeGraphs-level unit test for the pair union/dedup itself, plus an end-to-end test reproducing your exact scenario through the real aggregateGlobalGraph → scopeGlobalGraph pipeline (confirmed in the PR body's Real-CLI Verification section against the compiled source). 174 tests passing, typecheck and lint clean. Pushed as 101b1c4. PR description updated with a "Review follow-up round 15" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/graph-aggregate.ts:383 — Originless global edges into a contested unqualified slug can still survive. import-iwiki.ts’s direct-match path writes an untagged MAPS_TO edge to identifiers such as component/App; if withheld svc-b supplied that match while allowed svc-a shares the slug, collision restoration keeps the node, the evidence/code/<slug>/ prefix pass cannot identify the edge, and it is absent from per-repo edge sets. The withheld-derived relationship therefore remains and can affect graph boosts or relatedFiles. Tag these edges with the matched codebase origin or fail closed for unattributable edges touching contested slugs.

The previously reported findings appear resolved, and the PR description includes sufficient representative real-CLI verification.

…igin, and broaden the unattributable-edge fail-closed check (Tencent#974 review round 16)

import-iwiki.ts's direct-match path writes a MAPS_TO edge straight to the
global graph pointing at a bare fact-level node slug (e.g. component/App),
with no origin tag at all. If the withheld codebase that supplied the
matched node later collides with an allowed codebase on that same
unqualified slug, collision restoration correctly keeps the node, but the
untagged edge had no way to be identified as withheld — the
evidence/code/<slug>/ prefix pass can't help (bare slugs carry no
codebase prefix), and the edge never lived in any per-repo file either.

Fixed at the source, consistent with the cross-repo-edge fixes: capture
the matched node's own origin at match time and stamp it onto the edge,
immune to any later collision reattributing the node. scopeGlobalGraph
needed no changes for the common case — its existing single-origin tag
pass already handles it.

Also generalized the round-14 fail-closed guard (for a withheld,
not-tag-covered codebase's contested slug touched by an unattributable
edge) beyond DEPENDS_ON-relation edges: a legacy, untagged MAPS_TO edge
from before this fix existed poses the identical risk, and nothing about
the guard's reasoning was actually relation-specific.

Also fixed a latent crash in reconcileIwikiWithCodebase: it read raw
node.label/node.id (the legacy field names) with no fallback to the
current title/slug schema, since it does a plain JSON.parse rather than
going through loadGraphIndex's field normalization.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — this is the exact same class of bug as the cross-repo edges (rounds 12-15), just via import-iwiki.ts's own direct-match writer instead of detectCrossRepoEdges.

Fixed the same way: reconcileIwikiWithCodebase now captures the matched node's own origin at the moment of the match and stamps it onto the MAPS_TO edge, immune to a later collision reattributing the node. scopeGlobalGraph needed no changes for this — its existing single-origin tag pass already handles any edge once it carries one.

Also generalized the round-14 fail-closed guard (for a legacy, not-tag-covered codebase's contested slug touched by an edge we can't attribute) beyond DEPENDS_ON — a legacy, untagged MAPS_TO edge predating this fix is the identical risk, and the guard's reasoning was never actually relation-specific.

Writing the regression test surfaced an unrelated, pre-existing latent crash: reconcileIwikiWithCodebase read the legacy node.label/node.id field names with no fallback to the current title/slug schema (it parses the graph file directly, bypassing loadGraphIndex's normalization) — it would throw against any graph written in the modern schema. Fixed that too, since the new test couldn't otherwise run against realistic data.

Added a new import-iwiki-reconcile.test.ts (exported reconcileIwikiWithCodebase for direct testing, matching how detectCrossRepoEdges is already tested) plus a graph-aggregate.test.ts case for the broadened fail-closed guard. Re-verified end to end against the compiled source — details in the PR body's Real-CLI Verification section. 177 tests passing, typecheck and lint clean. Pushed as 89aeeda. PR description updated with a "Review follow-up round 16" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/graph-aggregate.ts:227 — When an edge has both origin and crossOriginPairs, the nullish fallback ignores origin. mergeGraphs() can create this shape when an allowed repository’s ordinary edge collides with a synthesized cross-repo edge. If the allowed repository’s per-repo graph is then replaced or unavailable before re-aggregation, withholding one cross-edge origin removes the edge despite its still-authoritative allowed origin, dropping valid graph boosts and related files. Treat origin as an additional independent provenance alternative.

All previously reported findings appear resolved. The PR description includes sufficient representative real-CLI verification.

…enance alongside crossOriginPairs (Tencent#974 review round 17)

scopeGlobalGraph's edge-withholding check used `edge.crossOriginPairs ??
(edge.origin ? [[edge.origin]] : [])`, so when an edge carried BOTH
fields the nullish fallback discarded `origin` entirely. mergeGraphs can
produce exactly that shape: an allowed repo's own plain origin-tagged
edge colliding (same from/to/relation) with a synthesized cross-repo
edge. If every crossOriginPairs entry was withheld, the edge got removed
even though its own, separately-allowed origin tag was an equally valid,
independent derivation of it.

Fixed by concatenating origin onto crossOriginPairs as one more length-1
pair instead of letting either field win outright — an edge is withheld
only when every pair, origin included, has a withheld member.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — a real gap in round 15's fix, exactly as described.

The ?? fallback treated crossOriginPairs and origin as mutually exclusive, so an edge carrying both (which mergeGraphs can produce when an allowed repo's own plain edge collides, same from/to/relation, with a separately-detected cross-repo edge) lost its origin entirely from consideration. Fixed by concatenating origin onto crossOriginPairs as one more independent, length-1 pair rather than letting either field win outright — the edge is now withheld only when every pair, origin included, has a withheld member.

Added a direct scopeGlobalGraph test with a hand-written edge carrying both fields: confirms it survives when only the crossOriginPairs side is withheld, and is removed once origin is withheld too. Re-verified against the compiled source — details in the PR body's Real-CLI Verification section. 179 tests passing, typecheck and lint clean. Pushed as 1f689f9. PR description updated with a "Review follow-up round 17" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/wiki-engine/core/graph-index.schema.ts:599 — The round-17 fix assumes a colliding ordinary edge retains its origin, but mergeGraphs() drops the losing edge’s origin. If an allowed repo’s ordinary edge collides with a newly synthesized cross-repo edge, the cross edge wins equal-evidence ties and retains only crossOriginPairs; withholding one cross-edge origin then removes the edge despite the independent allowed derivation. Preserve the losing origin as another provenance alternative during merging.

All other previously reported findings appear resolved. The PR description includes sufficient representative real-CLI verification.

…pair during mergeGraphs collisions (Tencent#974 review round 18)

Round 17 fixed scopeGlobalGraph to treat an edge's origin and
crossOriginPairs as independent alternatives when BOTH happened to be
present on the same edge object. But mergeGraphs itself never actually
produced that combined shape correctly: when a plain origin-tagged edge
collided with a crossOriginPairs-only edge sharing the same
from/to/relation identity, mergeGraphs only unioned crossOriginPairs —
the losing side's plain origin was never read at all, so it vanished
from the merged edge entirely rather than surviving as one more
independent provenance pair.

Fixed by treating each side's origin as its own one-element pair before
unioning, symmetrically for both sides of the collision, not just
whichever one happens to still be present on the winning object
afterward. This also generalizes correctly to two plain edges colliding
(previously left both untouched with only the tie-break winner's origin
surviving) and is proven end-to-end through the real aggregation
pipeline, not just a direct mergeGraphs call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — round 17 fixed the consumer (scopeGlobalGraph) but not the producer (mergeGraphs), which never actually created the combined origin+crossOriginPairs shape correctly in the first place.

When a plain origin-tagged edge collided with a crossOriginPairs-only edge sharing the same identity, mergeGraphs only unioned crossOriginPairs — the losing side's origin was never read, so it vanished entirely rather than surviving as an independent pair. Fixed by converting each side's origin into its own one-element pair before unioning, symmetrically, regardless of which side wins the tie-break.

This also generalizes correctly to two plain edges colliding (same gap, previously just not flagged) and I proved it end-to-end through the real aggregation pipeline — not just a direct mergeGraphs call — reproducing your scenario (an allowed repo's own plain edge coincidentally sharing from/to/relation with a cross-repo edge synthesized from a withheld importer). Details in the PR body's Real-CLI Verification section. 182 tests passing, typecheck and lint clean. Pushed as 0e7ba9c. PR description updated with a "Review follow-up round 18" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/graph-aggregate.ts:308 — The legacy fallback marks a withheld codebase accounted for using any valid per-repo graph, without proving it matches the current global graph. After an import creates evidence/code/svc-b/.indices/graph-index.json, a later direct teamai codebase --extract --project svc-b rewrites only the global graph with untagged data while leaving that per-repo file stale. Newly extracted unqualified nodes and file edges absent from the stale file then survive scoping and can affect matching, graph boosts, or relatedFiles. Direct extraction must stamp ownership too, or fallback needs generation consistency.

The previously reported findings appear resolved. The PR description includes sufficient representative real-CLI verification.

…global graph (Tencent#974 review round 19)

A standalone `teamai codebase --extract` (outside `teamai import`'s
cache-dir-then-copy orchestration) writes its graph straight into the
real teamwiki's global `.indices/graph-index.json` — the only tagging
step, buildAggregatedGraph, never runs for it. A withheld codebase
re-extracted this way afterward produced untagged nodes/edges with no
path to origin-based removal, while its evidence/code/<slug>/ per-repo
file (from an earlier `teamai import`) sat stale and unreadable as a
reliable fallback either.

Fixed at the source, consistent with every other origin-tagging fix in
this PR: stamp origin = project on every node and edge extractCodebase
writes, immediately before saveGraphIndex, regardless of whether this
call is a standalone extract (writes straight to the real global file)
or part of import's orchestration (writes to a cache location later
copied into the per-repo file and re-tagged identically by
aggregateGlobalGraph — redundant but harmless there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

STiFLeR7 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — a real, previously-unaddressed gap. Traced it down to confirm the exact mechanics: a standalone teamai codebase --extract writes its graph directly into the real teamwiki's global .indices/graph-index.json (not a per-repo file — wikiRoot there resolves to the real teamwiki root, unlike teamai import's cache-dir redirection). buildAggregatedGraph, the only step that stamps origin, never runs for this path at all, so a withheld codebase re-extracted this way produced completely untagged content with no way to be caught — and its evidence/code/<slug>/ per-repo file (if one exists from an earlier teamai import) is simply stale, describing different content, so the fallback can't help either.

Fixed at the source, same as every other origin-tagging gap in this PR: extractCodebase now stamps origin = project on every node and edge immediately before writing. This covers both invocation paths — standalone extract (now correctly tag-covers the real global file it writes) and import's orchestration (redundant re-tagging there, since aggregateGlobalGraph already does it, but harmless).

Added a new test block in codebase-extract-fallback.test.ts confirming every written node/edge carries the project's origin, plus a scoping test with a deliberately stale per-repo file alongside it. Re-verified with the actual built CLI (codebase --extract, standalone, no prior import) against a fresh fixture — confirmed in the PR body's Real-CLI Verification section. 184 tests passing, typecheck and lint clean. Pushed as eded9fc. PR description updated with a "Review follow-up round 19" section.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

No findings.

The previously reported direct teamai codebase --extract ownership gap is resolved by stamping nodes and edges before writing the global graph. The PR description also includes sufficient representative real-CLI verification for the runtime changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Scope teamwiki recall by project, like docs

3 participants