Knowledge base scopes + tags + shared common vault (ADT-234) - #39
Conversation
…n vault Rename the Base panel to Knowledge and give each note a scope (project or common) plus stack/kind tags carried in markdown front-matter (the source of truth, so the hub and the memory hooks agree). Project notes stay in the project's vault; common notes live in one shared user-level vault and surface into every project that matches their stack — a Java project sees Java and stack-agnostic common knowledge, never another stack's specifics. Adding a note takes a scope (a server-validated enum that selects the vault, never a client path) and tags, reusing the realpath-contained, no-overwrite, size-capped, guarded write against the chosen vault root. The match predicate and the front-matter parser are a single source of truth, parity-locked between the hub projection and the memory recall filter; a malformed front-matter never throws and drops prototype-polluting keys. `global` reads as `common`; nothing is uploaded. Refs: ADT-234
There was a problem hiding this comment.
Pull request overview
This PR upgrades the existing project “Base” knowledge surface into a scoped Knowledge system: notes gain scope (project/common) plus stack/kind tags stored in markdown front-matter, with a shared common vault that is merged into each project’s knowledge view under a strict stack-matching rule. It includes UI updates in the cockpit, backend support in the hub, and hub↔memory parity tests to keep projection and recall behavior aligned.
Changes:
- Introduces the Knowledge scope/tag data model (front-matter parsing + scope/stack match predicate) and exposes a merged
state.knowledgeprojection (project + common). - Extends
kb/addto support a server-validatedscopeenum plus optionalstack/kindtags, writing self-describing notes with front-matter (including common-vault writes). - Updates the cockpit Knowledge panel and add form to support scope toggling, chips/filters, and adds parity + negative/security tests across hub and memory.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| studio/cockpit/src/app/shell/project-shell.component.ts | Prefer state.knowledge for the panel, fallback to state.base. |
| studio/cockpit/src/app/shell/project-shell.component.spec.ts | Updates rich state fixtures to the new knowledge shape. |
| studio/cockpit/src/app/shell/glyph.component.ts | Adds new glyph names + SVGs for scope and tag chips. |
| studio/cockpit/src/app/shell/base-panel.component.ts | Reworks “Base” panel UI into scoped Knowledge list with filters/chips. |
| studio/cockpit/src/app/shell/base-panel.component.spec.ts | Adds tests for scope toggle, chips, filters, and XSS escaping. |
| studio/cockpit/src/app/shell/add-note-form.component.ts | Adds scoped add UI + stack/kind tagging; posts new fields. |
| studio/cockpit/src/app/shell/add-note-form.component.spec.ts | Updates add form tests for scope + tag payload and honesty copy. |
| studio/cockpit/src/app/projects/project-card.component.spec.ts | Updates fixtures to new knowledge shape and asserts card doesn’t show KB count. |
| studio/cockpit/src/app/core/projects.store.ts | Counts docs from knowledge per-scope counts. |
| studio/cockpit/src/app/core/models.ts | Introduces Knowledge types and documents base/knowledge back-compat intent. |
| studio/cockpit/src/app/core/control-plane.service.ts | Extends kb/add input/body to include scope/stack/kind. |
| hub/lib/knowledge.js | Adds bounded front-matter parser + canonical scopeMatches predicate + stack detection + common vault root resolution. |
| hub/lib/write.js | Extends addKbNote to scoped writes and emits front-matter header. |
| hub/lib/state.js | Adds merged knowledge projection + vault scanning w/ parsed front-matter. |
| hub/lib/api.js | Accepts scope/stack/kind on kb/add and forwards to writer. |
| hub/lib/scope-fixtures.json | Shared fixture truth table for scope/stack visibility parity. |
| hub/test/knowledge.test.js | Unit tests for front-matter parsing + scopeMatches + project stack precedence. |
| hub/test/scope-parity.test.js | Parity test: evaluates JS + TS mirror over shared fixtures for identical results. |
| hub/test/mutation-guard.test.js | Adds guard negatives for scoped kb/add (project + common). |
| hub/test/kb-write.test.js | Adjusts expectations for front-matter header inclusion. |
| hub/test/kb-scope-write.test.js | Adds extensive scoped write security/negative tests for common vault and scope enum. |
| hub/test/kb-projection.test.js | End-to-end tests for merged projection, isolation, and honest method. |
| claude/memory/src/lib/knowledge-match.ts | TS mirror of hub scopeMatches predicate (with global→common alias). |
| claude/memory/test/knowledge-match.test.ts | Validates TS mirror against shared hub fixture table. |
| claude/memory/src/lib/project-stack.ts | Mirrors hub project stack resolution logic (manual > auto > any). |
| claude/memory/src/stores/collections.ts | Adds stack as a filterable payload field. |
| claude/memory/src/hooks/restore-context.ts | Narrows global/common recall by project stack via shared predicate. |
| docs/sprints/sprint-06-knowledge-scopes/README.md | Sprint-level overview and gate plan for knowledge scopes work. |
| docs/sprints/sprint-06-knowledge-scopes/DECISION_LOG.md | Records decisions for canonical scope term, stack declaration, and deferrals. |
| docs/sprints/sprint-06-knowledge-scopes/TICKETS.md | Behavior-only acceptance criteria for ADT-234 (and related follow-ons). |
| docs/sprints/sprint-06-knowledge-scopes/reviews/rev-knowledge-234.md | Internal review artifact documenting verification against SECOPS conditions/tests. |
| docs/sprints/sprint-06-knowledge-scopes/approvals/arch-knowledge-scopes.md | Architecture approval writeup for ADT-234/235. |
| docs/sprints/sprint-06-knowledge-scopes/approvals/secops-knowledge-scopes.md | SECOPS gate conditions and negative test checklist for knowledge scopes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @if (visibleDocs().length) { | ||
| <ul class="docs" aria-label="Knowledge documents"> | ||
| @for (doc of visibleDocs(); track doc.name) { | ||
| <li class="doc" data-testid="knowledge-doc"> |
There was a problem hiding this comment.
Resolved in 78af6f3 + 9677b82 — good catches. The common-vault READ path was a real gap: it now uses the same containment-validated resolver as the write path (a commonVaultDir override outside $HOME is no longer read; proven by a new negative test). Knowledge rows now track by scope+file (no cross-vault name collision); the misleading BaseView=KnowledgeView alias is removed and consumers use the accurate types; the resolveCommonKbDir comment, the addKbNote contract doc, and the dead parseFrontMatter import are all corrected.
| // Resolve the user-level common-vault directory. The intended root comes from the | ||
| // knowledge module (default ~/.aidevteam/kb-common, or a bounded config override); | ||
| // it is created if absent (like the project default) and realpath-resolved. The | ||
| // resolved real path must be contained to the home root UNLESS an absolute override | ||
| // deliberately points elsewhere — in which case the override path is itself the | ||
| // containment root and must resolve to a real directory. Returns the realpath of the |
There was a problem hiding this comment.
Resolved in 78af6f3 + 9677b82 — good catches. The common-vault READ path was a real gap: it now uses the same containment-validated resolver as the write path (a commonVaultDir override outside $HOME is no longer read; proven by a new negative test). Knowledge rows now track by scope+file (no cross-vault name collision); the misleading BaseView=KnowledgeView alias is removed and consumers use the accurate types; the resolveCommonKbDir comment, the addKbNote contract doc, and the dead parseFrontMatter import are all corrected.
| // The common-vault doc records. Read-only scan of the SINGLE shared vault; the | ||
| // resolved root must be a real directory contained to the user home (or its bounded | ||
| // override), else the scan is empty (never reads an uncontained path). | ||
| function readCommonKb() { | ||
| let root; | ||
| try { |
There was a problem hiding this comment.
Resolved in 78af6f3 + 9677b82 — good catches. The common-vault READ path was a real gap: it now uses the same containment-validated resolver as the write path (a commonVaultDir override outside $HOME is no longer read; proven by a new negative test). Knowledge rows now track by scope+file (no cross-vault name collision); the misleading BaseView=KnowledgeView alias is removed and consumers use the accurate types; the resolveCommonKbDir comment, the addKbNote contract doc, and the dead parseFrontMatter import are all corrected.
| /** | ||
| * The shape carried into the Knowledge panel. The hub serialises the merged projection under | ||
| * `state.knowledge`; older state still carries the pre-scope `base` shape (counts by index state), | ||
| * so {@link KnowledgeView} is the panel's contract and the shell adapts whichever the state holds. | ||
| */ | ||
| export type BaseDoc = KnowledgeDoc; | ||
| export type BaseView = KnowledgeView; |
There was a problem hiding this comment.
Resolved in 78af6f3 + 9677b82 — good catches. The common-vault READ path was a real gap: it now uses the same containment-validated resolver as the write path (a commonVaultDir override outside $HOME is no longer read; proven by a new negative test). Knowledge rows now track by scope+file (no cross-vault name collision); the misleading BaseView=KnowledgeView alias is removed and consumers use the accurate types; the resolveCommonKbDir comment, the addKbNote contract doc, and the dead parseFrontMatter import are all corrected.
| @@ -248,7 +255,11 @@ export class ControlPlaneService { | |||
| * This is an additive create, not a CAS mutation, so there is no `expectedRev` and no conflict. | |||
There was a problem hiding this comment.
Resolved in 78af6f3 + 9677b82 — good catches. The common-vault READ path was a real gap: it now uses the same containment-validated resolver as the write path (a commonVaultDir override outside $HOME is no longer read; proven by a new negative test). Knowledge rows now track by scope+file (no cross-vault name collision); the misleading BaseView=KnowledgeView alias is removed and consumers use the accurate types; the resolveCommonKbDir comment, the addKbNote contract doc, and the dead parseFrontMatter import are all corrected.
The common-vault read now uses the same containment-validated resolver as the write path (proven a commonVaultDir override outside $HOME is not read); correct the resolveCommonKbDir comment to match the contained-to-$HOME behavior; drop the unused parseFrontMatter import.
…ls, addKbNote contract doc Track Knowledge rows by scope+file so same-named project/common notes don't share a DOM node; make the client base/knowledge types match the actual wire shapes; document that addKbNote now sends scope/stack/kind.
| const globalHitsRaw = await store.query("dev-rules", qv, { scope: "global" }, 5); | ||
| // Narrow cross-project (global/common) rows by the project's declared stack via | ||
| // the SHARED scope predicate — the same any-wildcard/intersection rule the hub | ||
| // panel applies — so a stack-specific shared rule never leaks into a project of | ||
| // a different stack. A row with no stack tag is treated as "any" (recalled). | ||
| const declaredStack = projectStackOf(cwd); | ||
| const globalHits = globalHitsRaw.filter((h) => { | ||
| const tags = (h.payload && Array.isArray((h.payload as Record<string, unknown>).stack) | ||
| ? ((h.payload as Record<string, unknown>).stack as string[]) | ||
| : ["any"]); | ||
| return scopeMatches({ scope: "common", status: "approved-common", stack: tags }, { stack: declaredStack }); | ||
| }); |
There was a problem hiding this comment.
Resolved in e2b8cc2 + 5422edf — the redundant literal 'any' is excluded from the Stack filter options (the default empty option already means all/any; any-tagged docs still show under the default); and the global dev-rules recall now applies the stack/scope filter to a bounded candidate pool BEFORE taking the top 5, so a stack-mismatched top hit no longer under-fills the section.
The Stack filter's default empty option already means all/any, so the literal any tag is excluded from the option list to avoid a duplicate; any-tagged docs still show under the default.
Apply the stack/scope match to the global-rule candidates before taking the top 5, so a stack-mismatched top result no longer under-fills the Global Dev Rules section; candidate fetch stays bounded.
| <div class="scope" role="radiogroup" aria-label="Knowledge scope"> | ||
| <button | ||
| type="button" | ||
| class="seg__opt" | ||
| data-testid="knowledge-scope-project" | ||
| role="radio" | ||
| [attr.aria-checked]="scopeFilter() === 'project'" | ||
| [class.seg__opt--on]="scopeFilter() === 'project'" | ||
| (click)="setScope('project')" |
There was a problem hiding this comment.
Resolved in bd0a1c3 + 965363e — both scope radiogroups now support ArrowLeft/Right/Up/Down roving selection + focus (matching the preset radiogroup pattern), with roving tabindex; the two test fixtures now match the real wire shapes (method 'filename-only'; common doc file root-relative); and the parseScalarValue comment is corrected to state it does not strip inline comments.
| <fieldset class="scope" data-testid="note-scope" role="radiogroup" aria-label="Scope"> | ||
| <legend class="lbl">Scope</legend> | ||
| <div class="seg"> | ||
| <button | ||
| type="button" | ||
| class="seg__opt" | ||
| data-testid="note-scope-project" | ||
| role="radio" | ||
| [attr.aria-checked]="scope() === 'project'" | ||
| [class.seg__opt--on]="scope() === 'project'" | ||
| (click)="setScope('project')" | ||
| > |
There was a problem hiding this comment.
Resolved in bd0a1c3 + 965363e — both scope radiogroups now support ArrowLeft/Right/Up/Down roving selection + focus (matching the preset radiogroup pattern), with roving tabindex; the two test fixtures now match the real wire shapes (method 'filename-only'; common doc file root-relative); and the parseScalarValue comment is corrected to state it does not strip inline comments.
| function parseScalarValue(raw) { | ||
| let s = String(raw == null ? '' : raw).trim(); | ||
| if (s === '') return undefined; // a bare `key:` opens a nested block → reject | ||
| // strip a trailing inline comment that is clearly a comment (not inside quotes) | ||
| if (s[0] === '[') { | ||
| const close = s.lastIndexOf(']'); |
There was a problem hiding this comment.
Resolved in bd0a1c3 + 965363e — both scope radiogroups now support ArrowLeft/Right/Up/Down roving selection + focus (matching the preset radiogroup pattern), with roving tabindex; the two test fixtures now match the real wire shapes (method 'filename-only'; common doc file root-relative); and the parseScalarValue comment is corrected to state it does not strip inline comments.
The front-matter scalar parser does not strip inline comments; the value after the colon is taken and trimmed in full, so a trailing '#' is kept. The comment now matches the implementation.
…est fixtures The Knowledge scope toggle and the add-note scope picker now support ArrowLeft/Right/Up/Down roving selection like the other segmented radios; align two test fixtures (method filename-only, common doc file path) with the real wire shapes.
| async submit(): Promise<void> { | ||
| if (!this.canSubmit()) return; | ||
| this.lifecycle.set('saving'); | ||
| this.message.set(''); | ||
| const res = await this.cp.addKbNote({ title: this.title().trim(), body: this.body() }); | ||
| const stack = this.stack() ? [this.stack()] : ['any']; | ||
| const res = await this.cp.addKbNote({ | ||
| title: this.title().trim(), | ||
| body: this.body(), | ||
| scope: this.scope(), | ||
| stack, | ||
| kind: this.kind(), | ||
| }); |
There was a problem hiding this comment.
Resolved in ec7ac69 + 0834f02 — selectGlobalRules now passes each candidate's actual status to scopeMatches, so pending/rejected global rows are no longer recalled (only approved); the collections comment now states the real vec0 columns and that stack is matched post-fetch in the hook (not a store column); and the add-note form blocks an empty/whitespace body before posting.
| const matching = candidates.filter((h) => { | ||
| const tags = (h.payload && Array.isArray((h.payload as Record<string, unknown>).stack) | ||
| ? ((h.payload as Record<string, unknown>).stack as string[]) | ||
| : ["any"]); | ||
| return scopeMatches({ scope: "common", status: "approved-common", stack: tags }, { stack: declaredStack }); | ||
| }); |
There was a problem hiding this comment.
Resolved in ec7ac69 + 0834f02 — selectGlobalRules now passes each candidate's actual status to scopeMatches, so pending/rejected global rows are no longer recalled (only approved); the collections comment now states the real vec0 columns and that stack is matched post-fetch in the hook (not a store column); and the add-note form blocks an empty/whitespace body before posting.
| * Payload fields we filter on — indexed in Qdrant, real columns in sqlite-vec. | ||
| * `stack` is the cross-type knowledge-scope dimension: a common/global row may carry | ||
| * a stack tag, and recall narrows it against the project's declared stack via the | ||
| * shared scope predicate (see lib/knowledge-match.ts), with an "any" wildcard. | ||
| */ |
There was a problem hiding this comment.
Resolved in ec7ac69 + 0834f02 — selectGlobalRules now passes each candidate's actual status to scopeMatches, so pending/rejected global rows are no longer recalled (only approved); the collections comment now states the real vec0 columns and that stack is matched post-fetch in the hook (not a store column); and the add-note form blocks an empty/whitespace body before posting.
…umn comment selectGlobalRules now matches each candidate's actual status so pending/rejected global rows are not recalled; the collections comment now states the real vec0 columns and that stack is matched post-fetch in the hook, not filtered by the store.
body: the add-note form now requires a non-empty body before posting, matching the title guard, so the UI no longer fires a request the hub will reject.
| const home = aidevteamHome(); | ||
| const intended = commonVaultRoot(); | ||
| const isDefault = intended === path.join(home, 'kb-common'); | ||
|
|
||
| if (isDefault) { | ||
| try { fs.mkdirSync(intended, { recursive: true }); } catch { return null; } | ||
| } else { | ||
| // a non-existent / non-directory override is refused rather than created or followed | ||
| let real; | ||
| try { real = fs.realpathSync(intended); } catch { return null; } | ||
| try { if (!fs.statSync(real).isDirectory()) return null; } catch { return null; } |
| import type { ScoredPoint, VectorStore } from "../types.ts"; | ||
| import { formatContext, readStdinJson, withDeadline } from "./common.ts"; | ||
| import { scopeMatches } from "../lib/knowledge-match.ts"; | ||
| import { projectStackOf } from "../lib/project-stack.ts"; |
| /** True when this module is the process entrypoint (run directly as the hook). */ | ||
| function isEntrypoint(): boolean { | ||
| return import.meta.url === `file://${process.argv[1]}`; | ||
| } |
Rename Base → Knowledge and add scopes/tags (design /aura+/apex; arch + HARD secops gated).
What
~/.aidevteam/kb-common/) — one physical file surfaced into every matching project; cross-type match: a Java project sees Java + stack-agnostic common, never another stack's.Security (HARD gate, verified)
Common-vault containment (sibling-prefix + symlink-escape refused), O_EXCL, size/text caps, write-guard on every new path, enum-not-path scope, cross-stack/cross-project isolation, front-matter never-throws + proto-safe, all untrusted text escaped (no innerHTML). Conditions C-201…C-242 / negatives N-201…N-219 all green.
Tests
283 hub + 25 memory (tsc clean) + 417 cockpit; build succeeds. Internal /rev passed (0 blocking).
🤖 Generated with Claude Code