diff --git a/.augment/skills b/.augment/skills deleted file mode 120000 index 2b7a412b..00000000 --- a/.augment/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8f3b349c..84ab885b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ tmp/ # codetours .tours/ + +# skill quarantine +.agents/_quarantine diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..e5ba56c9 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,146 @@ +# Cards — intent-graph-semantics + +Temporary execution queue for the `intent-graph-semantics` frontier on `ln/fe-700-intent-graph-semantics`. Delete or replace when exhausted/superseded. + +## Orientation + +- **Containing seam:** intent graph semantics, especially `src/server/knowledge-relationship-policy.ts`, graph relationship projection, cascade/reconciliation impact, and future context snapshot builders. +- **Frontier item:** `intent-graph-semantics` / FE-700. +- **Current branch:** `ln/fe-700-intent-graph-semantics`. +- **Main risk:** raw `knowledge_edge.from_item_id` / `to_item_id` direction currently does too much semantic work; relation policy must own validation, endpoint-relative labels, snapshot buckets, and change impact before broader ontology expansion. + +## Recommended agent mode + +Use **surveyor/scaffolder first, then backfiller threads**, not concurrent worktrees. + +- Do **not** ask multiple builders to write code in the same checkout at the same time. +- A surveyor/scaffolder should take Card 1 and establish the exported relation-policy API. +- After Card 1 lands, separate follow-up threads can each receive a complete brief for Card 2 or Card 3, but they should run serially against the current branch state unless you explicitly create isolation another way. +- If you want apparent parallelism without worktrees, make one thread read-only: have it review/scout fixtures, API shape, or tests while the active builder owns writes. +- Avoid Card 4+ until Cards 1–3 reveal whether edge metadata or schema changes are needed. + +--- + +## Card 1 — Relation-policy registry scaffold + +**Status:** done +**Weight:** full scope card + +### Target Behavior + +Existing intent-edge validation is preserved while a richer relation-policy registry exposes endpoint-relative labels, snapshot buckets, and source/target change-impact behavior for every current edge relation. + +### Boundary Crossings + +```text +→ shared persisted edge relation vocabulary (`EdgeRelation`) +→ server relation-policy module +→ observer / route validation callers +→ cascade/context-facing tests +``` + +### Risks and Assumptions + +- RISK: Renaming the policy seam breaks observer and graph-edit validation unexpectedly → MITIGATION: preserve `supportsKnowledgeRelationship()` as a compatibility export over the richer policy until callers are migrated. +- RISK: Endpoint-relative labels accidentally imply a universal upstream/downstream direction → MITIGATION: test mixed-direction relations (`constrains`, `depends_on`/`assumes` semantics, `verifies`) from both endpoints. +- ASSUMPTION: Current relation enum is sufficient for the first policy scaffold → VALIDATE: relation-policy tests cover all existing `EdgeRelation` values; new relation kinds wait for later ontology expansion. + +### Acceptance Criteria + +- ✓ `knowledge-relationship-policy.test.ts` — every current relation has a policy row with validation source/target kinds, source/target labels, snapshot bucket behavior, and source/target change behavior. +- ✓ `knowledge-relationship-policy.test.ts` — `supportsKnowledgeRelationship()` preserves current allow/deny behavior for representative existing cases. +- ✓ `knowledge-relationship-policy.test.ts` — endpoint rendering distinguishes source and target perspective without string-reversing raw relation names. +- ✓ `knowledge-relationship-policy.test.ts` — change-impact lookup returns an explicit policy result for both endpoint-change directions. + +### Verification Approach + +- Inner: focused unit tests — prove the registry is exhaustive, current validation behavior is preserved, and endpoint-relative semantics are explicit. +- Middle: existing observer/graph route tests — prove current callers still work through the compatibility export. +- Outer: not required for this slice. + +--- + +## Card 2 — Read-only intent neighborhood snapshot projection + +**Status:** next +**Weight:** full scope card + +### Target Behavior + +A read-only context-snapshot builder can summarize selected intent items and their relation-policy-bucketed neighborhoods without mutating chat, turn, or graph state. + +### Boundary Crossings + +```text +→ DB graph read model (`knowledge_item`, `knowledge_edge`, `reconciliation_need` where useful) +→ relation-policy registry from Card 1 +→ context-pack / snapshot builder module +→ structured JSON assertions and selected golden rendering +``` + +### Risks and Assumptions + +- RISK: Snapshot builders overfit exact prose too early → MITIGATION: assert structured JSON shape/ids/buckets first, keep only selected golden renderings. +- RISK: Builder accidentally creates chat-context authority → MITIGATION: keep this slice read-only; no chat handles, no turn persistence, no hidden context table. +- ASSUMPTION: Immediate, dependencies, dependents, evidence, and reconciliation modes can be represented over current graph rows plus relation policy → VALIDATE: fixture tests for at least immediate/dependencies/dependents; evidence/reconciliation may be sparse when current relation vocabulary lacks examples/invariants. + +### Acceptance Criteria + +- ✓ `context-snapshot.test.ts` — item snapshots include requested item ids, kind/reference metadata, content/rationale, and relation-policy-rendered edge groups. +- ✓ `context-snapshot.test.ts` — neighborhood modes produce distinct dependency/dependent groupings from the same mixed-direction graph fixture. +- ✓ `context-snapshot.test.ts` — economic whole-graph snapshot returns compact grouped graph data without creating item handles or requiring chat state. +- ✓ selected golden fixture — rendered snapshot remains reviewable while structured assertions carry the correctness burden. + +### Verification Approach + +- Inner: focused unit/integration tests over seeded in-memory DB graph fixtures. +- Middle: structured snapshot assertions + selected golden rendering. +- Outer: later manual review when chat runtime consumes artifacts. + +--- + +## Card 3 — Cascade impact uses relation policy + +**Status:** queued; independent of Card 2, but write serially unless isolated +**Weight:** full scope card + +### Target Behavior + +Hard-impact edit cascade consults relation-policy endpoint-change behavior instead of deriving reconciliation impact from relation name or raw edge direction. + +### Boundary Crossings + +```text +→ edit route / cascade producer +→ relation-policy registry from Card 1 +→ reconciliation_need creation +→ existing side-chat/reconciliation tests +``` + +### Risks and Assumptions + +- RISK: Current tests encode the old raw-direction assumption → MITIGATION: update fixtures to name changed endpoint and expected affected endpoint explicitly. +- RISK: Policy change alters user-visible reconciliation counts → MITIGATION: preserve conservative behavior for ambiguous current relations unless policy says no reconciliation. +- ASSUMPTION: No schema change is needed; relation policy can compute impact from composite edge coordinates and changed item id → VALIDATE: tests cover both source-changed and target-changed variants. + +### Acceptance Criteria + +- ✓ `cascade-producer.test.ts` — source-change and target-change behavior is driven by relation policy for every current relation. +- ✓ `edit-route.test.ts` / reconciliation tests — hard edits open needs for the policy-affected endpoint(s), not merely for raw incident-edge neighbors. +- ✓ compatibility assertion — existing dense-fixture cascade remains conservative where policy keeps current behavior. + +### Verification Approach + +- Inner: focused cascade policy unit tests and edit-route integration tests. +- Middle: existing reconciliation classifier/context tests remain green. +- Outer: manual dense cascade walkthrough deferred to reconciliation-runtime work. + +--- + +## Later candidates — do not pre-build yet + +These likely remain in FE-700 but should be scoped after Cards 1–3 land: + +1. Ontology registry expansion for `invariant` and `example`, including subtypes and reference prefixes. +2. Observer prompt/parser enrichment for examples, counterexamples, invariants, and stricter decisions. +3. Negative relations and edge metadata (`family`, `support`, `status`, `rationale`, provenance), coordinated with FE-701 if stable edge identity becomes necessary. diff --git a/memory/SPEC.md b/memory/SPEC.md index c518b1ff..04fff8ca 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -234,7 +234,7 @@ Each invariant is a formalization candidate: the property is stated in human lan | I115 | The agent capability CLI remains an adapter over Brunch capability contracts: calls validate explicit resource ids/schemas, mutating calls dispatch through server-owned handlers, and probes exercise only the JSONL boundary. | planned: capabilities, agent-jsonl, probe-runner tests | Requirements 42, 43; A89; D143, D147 | | I116 | Each active/resumable chat has at most one open assistant/system-first frontier turn; user responses complete it through normalized semantics, and strategy is chat-local process state. | planned: chat/transition/capability tests | Requirement 44; D138, D148 | | I117 | Open proposal turns are stamped with the latest applied changeset id at creation and conservatively stale when the specification's latest changeset advances before completion. | planned: changeset/transition/app tests | A92; D149 | -| I118 | Reconciliation/direct-edit cascade never infers affected endpoints from raw edge direction alone; it consults relation policy source-change / target-change behavior. | planned: relation-policy/edit-impact/reconciliation tests | A93; D137, D150 | +| I118 | Reconciliation/direct-edit cascade never infers affected endpoints from raw edge direction alone; it consults relation policy source-change / target-change behavior. | `knowledge-relationship-policy.test.ts`; planned: edit-impact/reconciliation tests | A93; D137, D150 | | I119 | Scenario-option candidate bundles can become canonical only by accepting a coherent bundle changeset; accepted-with-issues candidates also create durable follow-on review/process debt. | planned: scenario-runner, turn-artifacts, changeset tests | A90, A91; D151, D152 | | I120 | Secondary chats remain conversational process containers, not workflow or semantic truth: inline rendering, collapse/reload state, turn-level context snapshot replay, and item-version-gated stale-handle refresh may organize discussion, but accepted mutations still flow through Brunch-owned handlers and changesets. | planned: chat-runtime, context-provision, changeset/app tests | Requirement 45; A94, A95; D143, D149, D153, D154 | diff --git a/src/server/knowledge-relationship-policy.test.ts b/src/server/knowledge-relationship-policy.test.ts new file mode 100644 index 00000000..efe63737 --- /dev/null +++ b/src/server/knowledge-relationship-policy.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { edgeRelationSchema, type EdgeRelation } from '@/shared/api-types.js'; + +import { + getKnowledgeRelationshipChangeImpact, + getKnowledgeRelationshipEndpointLabel, + knowledgeRelationshipPolicies, + supportsKnowledgeRelationship, +} from './knowledge-relationship-policy.js'; + +const edgeRelations = edgeRelationSchema.options; + +describe('knowledge relationship policy registry', () => { + it('declares endpoint validation, labels, snapshot buckets, and change impact for every relation', () => { + expect(Object.keys(knowledgeRelationshipPolicies).sort()).toEqual([...edgeRelations].sort()); + + expect(knowledgeRelationshipPolicies).toMatchObject({ + depends_on: { + sourceSnapshotBucket: 'dependencies', + targetSnapshotBucket: 'dependents', + sourceChanged: { affectedEndpoint: null, kind: null }, + targetChanged: { affectedEndpoint: 'source', kind: 'needs_confirmation' }, + }, + derived_from: { + sourceSnapshotBucket: 'dependencies', + targetSnapshotBucket: 'dependents', + sourceChanged: { affectedEndpoint: null, kind: null }, + targetChanged: { affectedEndpoint: 'source', kind: 'supersedes' }, + }, + constrains: { + sourceSnapshotBucket: 'dependents', + targetSnapshotBucket: 'dependencies', + sourceChanged: { affectedEndpoint: 'target', kind: 'needs_confirmation' }, + targetChanged: { affectedEndpoint: null, kind: null }, + }, + verifies: { + sourceSnapshotBucket: 'evidence', + targetSnapshotBucket: 'evidence', + sourceChanged: { affectedEndpoint: 'target', kind: 'needs_confirmation' }, + targetChanged: { affectedEndpoint: 'source', kind: 'needs_confirmation' }, + }, + refines: { + sourceSnapshotBucket: 'refinements', + targetSnapshotBucket: 'refinements', + sourceChanged: { affectedEndpoint: null, kind: null }, + targetChanged: { affectedEndpoint: 'source', kind: 'supersedes' }, + }, + }); + + for (const relation of edgeRelations) { + const policy = knowledgeRelationshipPolicies[relation]; + + expect(policy.relation).toBe(relation); + expect(policy.sourceKinds.length).toBeGreaterThan(0); + expect(policy.targetKinds.length).toBeGreaterThan(0); + expect(policy.sourceLabel.length).toBeGreaterThan(0); + expect(policy.targetLabel.length).toBeGreaterThan(0); + } + }); + + it('preserves existing allow/deny behavior through supportsKnowledgeRelationship()', () => { + const cases: Array< + [ + EdgeRelation, + Parameters[1], + Parameters[2], + boolean, + ] + > = [ + ['depends_on', 'requirement', 'goal', true], + ['depends_on', 'goal', 'requirement', false], + ['derived_from', 'context', 'goal', true], + ['derived_from', 'goal', 'context', false], + ['constrains', 'constraint', 'requirement', true], + ['constrains', 'decision', 'requirement', false], + ['verifies', 'criterion', 'requirement', true], + ['verifies', 'requirement', 'criterion', false], + ['refines', 'term', 'assumption', true], + ]; + + for (const [relation, sourceKind, targetKind, expected] of cases) { + expect(supportsKnowledgeRelationship(relation, sourceKind, targetKind)).toBe(expected); + } + }); + + it('renders endpoint-relative labels without reversing raw relation names', () => { + expect(getKnowledgeRelationshipEndpointLabel('depends_on', 'source')).toBe('depends on'); + expect(getKnowledgeRelationshipEndpointLabel('depends_on', 'target')).toBe('is depended on by'); + + expect(getKnowledgeRelationshipEndpointLabel('constrains', 'source')).toBe('constrains'); + expect(getKnowledgeRelationshipEndpointLabel('constrains', 'target')).toBe('is constrained by'); + + expect(getKnowledgeRelationshipEndpointLabel('verifies', 'source')).toBe('verifies'); + expect(getKnowledgeRelationshipEndpointLabel('verifies', 'target')).toBe('is verified by'); + }); + + it('returns explicit change-impact policy for source and target endpoint changes', () => { + expect(getKnowledgeRelationshipChangeImpact('depends_on', 'source')).toEqual({ + affectedEndpoint: null, + kind: null, + }); + expect(getKnowledgeRelationshipChangeImpact('depends_on', 'target')).toEqual({ + affectedEndpoint: 'source', + kind: 'needs_confirmation', + }); + + expect(getKnowledgeRelationshipChangeImpact('derived_from', 'source')).toEqual({ + affectedEndpoint: null, + kind: null, + }); + expect(getKnowledgeRelationshipChangeImpact('derived_from', 'target')).toEqual({ + affectedEndpoint: 'source', + kind: 'supersedes', + }); + + expect(getKnowledgeRelationshipChangeImpact('constrains', 'source')).toEqual({ + affectedEndpoint: 'target', + kind: 'needs_confirmation', + }); + expect(getKnowledgeRelationshipChangeImpact('constrains', 'target')).toEqual({ + affectedEndpoint: null, + kind: null, + }); + + expect(getKnowledgeRelationshipChangeImpact('verifies', 'source')).toEqual({ + affectedEndpoint: 'target', + kind: 'needs_confirmation', + }); + expect(getKnowledgeRelationshipChangeImpact('verifies', 'target')).toEqual({ + affectedEndpoint: 'source', + kind: 'needs_confirmation', + }); + + expect(getKnowledgeRelationshipChangeImpact('refines', 'source')).toEqual({ + affectedEndpoint: null, + kind: null, + }); + expect(getKnowledgeRelationshipChangeImpact('refines', 'target')).toEqual({ + affectedEndpoint: 'source', + kind: 'supersedes', + }); + }); +}); diff --git a/src/server/knowledge-relationship-policy.ts b/src/server/knowledge-relationship-policy.ts index af935c96..ddd55676 100644 --- a/src/server/knowledge-relationship-policy.ts +++ b/src/server/knowledge-relationship-policy.ts @@ -1,37 +1,115 @@ import type { EdgeRelation } from '@/shared/api-types.js'; import { knowledgeKinds, type KnowledgeKind } from '@/shared/knowledge.js'; -const relationPolicies: Record< - EdgeRelation, - { sourceKinds: readonly KnowledgeKind[]; targetKinds: readonly KnowledgeKind[] } -> = { +export type KnowledgeRelationshipEndpoint = 'source' | 'target'; +export type KnowledgeRelationshipSnapshotBucket = 'dependencies' | 'dependents' | 'evidence' | 'refinements'; +export type KnowledgeRelationshipChangeKind = 'needs_confirmation' | 'supersedes'; + +export interface KnowledgeRelationshipChangeImpact { + affectedEndpoint: KnowledgeRelationshipEndpoint | null; + kind: KnowledgeRelationshipChangeKind | null; +} + +export interface KnowledgeRelationshipPolicy { + relation: EdgeRelation; + sourceKinds: readonly KnowledgeKind[]; + targetKinds: readonly KnowledgeKind[]; + sourceLabel: string; + targetLabel: string; + sourceSnapshotBucket: KnowledgeRelationshipSnapshotBucket; + targetSnapshotBucket: KnowledgeRelationshipSnapshotBucket; + sourceChanged: KnowledgeRelationshipChangeImpact; + targetChanged: KnowledgeRelationshipChangeImpact; +} + +const noChangeImpact = Object.freeze({ + affectedEndpoint: null, + kind: null, +}) satisfies KnowledgeRelationshipChangeImpact; + +export const knowledgeRelationshipPolicies = Object.freeze({ depends_on: { + relation: 'depends_on', sourceKinds: ['decision', 'assumption', 'requirement', 'criterion'], targetKinds: ['goal', 'context', 'constraint', 'decision', 'assumption', 'requirement'], + sourceLabel: 'depends on', + targetLabel: 'is depended on by', + sourceSnapshotBucket: 'dependencies', + targetSnapshotBucket: 'dependents', + sourceChanged: noChangeImpact, + targetChanged: { affectedEndpoint: 'source', kind: 'needs_confirmation' }, }, derived_from: { + relation: 'derived_from', sourceKinds: ['context', 'constraint', 'requirement', 'criterion', 'decision', 'assumption'], targetKinds: ['goal', 'term', 'context', 'constraint', 'decision', 'assumption', 'requirement'], + sourceLabel: 'is derived from', + targetLabel: 'is source for', + sourceSnapshotBucket: 'dependencies', + targetSnapshotBucket: 'dependents', + sourceChanged: noChangeImpact, + targetChanged: { affectedEndpoint: 'source', kind: 'supersedes' }, }, constrains: { + relation: 'constrains', sourceKinds: ['constraint'], targetKinds: ['goal', 'decision', 'requirement', 'criterion'], + sourceLabel: 'constrains', + targetLabel: 'is constrained by', + sourceSnapshotBucket: 'dependents', + targetSnapshotBucket: 'dependencies', + sourceChanged: { affectedEndpoint: 'target', kind: 'needs_confirmation' }, + targetChanged: noChangeImpact, }, verifies: { + relation: 'verifies', sourceKinds: ['criterion'], targetKinds: ['requirement'], + sourceLabel: 'verifies', + targetLabel: 'is verified by', + sourceSnapshotBucket: 'evidence', + targetSnapshotBucket: 'evidence', + sourceChanged: { affectedEndpoint: 'target', kind: 'needs_confirmation' }, + targetChanged: { affectedEndpoint: 'source', kind: 'needs_confirmation' }, }, refines: { + relation: 'refines', sourceKinds: knowledgeKinds, targetKinds: knowledgeKinds, + sourceLabel: 'refines', + targetLabel: 'is refined by', + sourceSnapshotBucket: 'refinements', + targetSnapshotBucket: 'refinements', + sourceChanged: noChangeImpact, + targetChanged: { affectedEndpoint: 'source', kind: 'supersedes' }, }, -}; +}) satisfies Readonly>; + +export function getKnowledgeRelationshipPolicy(relation: EdgeRelation): KnowledgeRelationshipPolicy { + return knowledgeRelationshipPolicies[relation]; +} + +export function getKnowledgeRelationshipEndpointLabel( + relation: EdgeRelation, + endpoint: KnowledgeRelationshipEndpoint, +): string { + const policy = getKnowledgeRelationshipPolicy(relation); + return endpoint === 'source' ? policy.sourceLabel : policy.targetLabel; +} + +export function getKnowledgeRelationshipChangeImpact( + relation: EdgeRelation, + changedEndpoint: KnowledgeRelationshipEndpoint, +): KnowledgeRelationshipChangeImpact { + const policy = getKnowledgeRelationshipPolicy(relation); + return changedEndpoint === 'source' ? policy.sourceChanged : policy.targetChanged; +} export function supportsKnowledgeRelationship( relation: EdgeRelation, sourceKind: KnowledgeKind, targetKind: KnowledgeKind, ): boolean { - const policy = relationPolicies[relation]; + const policy = getKnowledgeRelationshipPolicy(relation); return policy.sourceKinds.includes(sourceKind) && policy.targetKinds.includes(targetKind); }