@kontourai/flow-agents 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -13,6 +13,19 @@ Plan artifact in, implemented code out. Fans out to tool-worker subagents in par
13
13
  |---|---|
14
14
  | tool-worker | Implementation per task spec (up to 4 parallel) |
15
15
 
16
+ ## Model Routing
17
+
18
+ Worker slices (`tool-worker`) route by task shape: `delegate-implementation` for
19
+ precisely-planned implementation (the default), `delegate-mechanical` for
20
+ fully-specified mechanical slices (issue sync, doc/scan bookkeeping),
21
+ `delegate-design` when a slice genuinely needs design latitude. Resolve the role
22
+ from `.datum/config.json` (`npx @kontourai/datum resolve <role> --json`) and pass
23
+ the model explicitly. On a review/verify gate failure of a slice, re-dispatch its
24
+ fix one tier higher and record the escalation. See
25
+ `context/contracts/execution-contract.md` § Delegation: Model Routing (and
26
+ § Escalation on gate failure). Fallback: inherit the session model when
27
+ datum/config is absent, noted in the artifact.
28
+
16
29
  ## Orchestrator Rule
17
30
 
18
31
  You do not write source files. You read the plan artifact, fan out tasks to tool-worker, and update the session file between waves.
@@ -20,6 +20,23 @@ Inherited from primitives + diagnosis:
20
20
  | tool-verifier | verify-work |
21
21
  | tool-playwright | diagnosis (reproduce) + verify-work |
22
22
 
23
+ ## Model Routing
24
+
25
+ Delegates are spawned with an explicit model override resolved from
26
+ `.datum/config.json` via `npx @kontourai/datum resolve <role> --json` — see
27
+ `context/contracts/execution-contract.md` § Delegation: Model Routing:
28
+
29
+ | Delegate | Role |
30
+ |---|---|
31
+ | tool-worker | `delegate-mechanical` for fully-specified mechanical slices, `delegate-implementation` for precisely-planned implementation, `delegate-design` when a slice needs design latitude |
32
+ | tool-planner | `delegate-design` |
33
+ | tool-code-reviewer / tool-security-reviewer | `delegate-implementation` by default, raised to the worker's tier when higher — never below the tier of the checked work (Goodhart guard) |
34
+ | tool-verifier / tool-playwright | `delegate-implementation` by default, raised to the worker's tier when higher — never below the tier of the checked work (Goodhart guard) |
35
+
36
+ On a review/verify gate failure, re-dispatch the fix one tier higher and record
37
+ the escalation (contract § Escalation on gate failure). Fallback: inherit the
38
+ session model when datum/config is absent, noted in the artifact.
39
+
23
40
  ## Orchestrator Rule
24
41
 
25
42
  You never use `read`, `glob`, `grep`, or `code` on source files. All codebase analysis goes through tool-planner. All review goes through review-work. All verification goes through tool-verifier or tool-playwright.
@@ -21,6 +21,16 @@ Convert raw ideas into shaped, prioritized, executable backlog without starting
21
21
  - Keep separate ideas separate until a shared outcome, hard dependency, or sequencing reason justifies bundling them.
22
22
  - Push back when the user blends unrelated ideas, and ask them to justify why the ideas belong together before shaping bundled work.
23
23
 
24
+ ## Model Routing
25
+
26
+ When this skill delegates its shaping work (opportunity review, option
27
+ exploration, slicing), resolve the `delegate-design` role from
28
+ `.datum/config.json` (`npx @kontourai/datum resolve delegate-design --json`) and
29
+ pass the resolved model explicitly — shaping a raw idea into a backlog needs
30
+ design latitude, so it routes to the design tier. See
31
+ `context/contracts/execution-contract.md` § Delegation: Model Routing. Fallback:
32
+ inherit the session model when datum/config is absent, noted in the artifact.
33
+
24
34
  ## Artifact Contract
25
35
 
26
36
  Create or update `.kontourai/flow-agents/<slug>/<slug>--idea-to-backlog.md` with:
@@ -115,15 +115,15 @@ When the gate fails, record `--status fail` with `--expectation pickup-probe-rea
115
115
 
116
116
 
117
117
 
118
- ## Docs And ADR Policy
118
+ ## Docs-Write Policy: Two-Delta Emission
119
119
 
120
- `pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow:
120
+ `pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow. Per `context/contracts/probe-docs-write-contract.md`, a **decision subject** crystallizing during pickup probing (a durable, reusable answer to a named domain question — not an implementation detail or transient planning choice) gets both deltas in the same motion:
121
121
 
122
- - Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`.
122
+ - Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`. Coin the subject term first if it is not already a glossary entry — vocabulary is the decision registry's topic namespace (`context/contracts/decision-registry-contract.md` § Slug rules) — or refine the existing entry when a decision changes what it says.
123
123
  - If a `CONTEXT-MAP.md` exists, update the relevant context instead of assuming the root glossary owns the term.
124
124
  - Create a lazy context file only when a resolved term or workflow concept has no existing home.
125
125
  - Do not create context files for transient planning notes, open questions, or provider snapshots.
126
- - Propose or create ADRs sparingly, only when all three are true: the decision is hard to reverse, surprising without context, and the result of a real trade-off.
126
+ - Record a decision delta at `docs/decisions/<slug>.md`: consult `docs/decisions/index.md` first and propose revise-vs-create (an existing topic file, including a `needs-decision` stub, is revised in place; only an uncovered subject gets a new file), then run `npm run gen:decisions-index && npm run check:decisions`. Link this Probe's own session artifact as a `session-archive` evidence ref (transcript provenance). Never propose or create a numbered ADR — `docs/adr/` is frozen history; decisions go into the topic-keyed registry.
127
127
  - Prefer workflow artifacts for pickup decisions, unresolved questions, accepted gaps, and route reasons until the work is accepted.
128
128
 
129
129
  ## Handoff To Plan Work
@@ -13,6 +13,15 @@ Goal + directory in, structured plan artifact out. Pure planning primitive.
13
13
  |---|---|
14
14
  | tool-planner | Codebase analysis, structured execution plan, writes plan artifact |
15
15
 
16
+ ## Model Routing
17
+
18
+ Planning delegation (`tool-planner`) resolves the `delegate-design` role from
19
+ `.datum/config.json` (`npx @kontourai/datum resolve delegate-design --json`) and
20
+ passes the resolved model explicitly — turning a goal into a plan needs design
21
+ latitude, so it routes to the design tier. See
22
+ `context/contracts/execution-contract.md` § Delegation: Model Routing. Fallback:
23
+ inherit the session model when datum/config is absent, noted in the artifact.
24
+
16
25
  ## Orchestrator Rule
17
26
 
18
27
  You do not read source files. You delegate to tool-planner and read the artifact it produces.
@@ -26,6 +26,16 @@ Select ready backlog work and prepare a bounded execution handoff without implem
26
26
  - Every pull-work artifact must correlate to selected backlog refs, shepherding refs for active PRs/sidecars/issues being finished before new work, or `backlog_gap=true` with a route to `idea-to-backlog`; direct audits with no new selection must record `shepherding_item_ids` or `backlog_gap`, not free-floating implementation scope.
27
27
  - A stale broad continuation instruction, such as "keep going", "pick up the next two", or "continue after merge", may allow queue inspection but must not bypass per-item pickup Probe evidence.
28
28
 
29
+ ## Model Routing
30
+
31
+ Board selection, WIP/shepherding scans, dependency joins, liveness preflight, and
32
+ issue-sync-style bookkeeping are fully-specified mechanical work: when this skill
33
+ delegates them, resolve the `delegate-mechanical` role from `.datum/config.json`
34
+ (`npx @kontourai/datum resolve delegate-mechanical --json`) and pass the resolved
35
+ model explicitly. See `context/contracts/execution-contract.md` § Delegation:
36
+ Model Routing. Fallback: inherit the session model when datum/config is absent,
37
+ noted in the artifact.
38
+
29
39
  ## Inputs
30
40
 
31
41
  - Repository or working directory.
@@ -29,6 +29,17 @@ Keeping them separate makes failures route cleanly:
29
29
  | tool-dependencies-updater | Dependency review when package manifests, dependency manifests, package manager config, or lockfiles change |
30
30
  | configured architecture/domain/IaC/policy reviewer | Optional reviewer when the project or user configures one |
31
31
 
32
+ ## Model Routing
33
+
34
+ Review roles never resolve **below** the tier of the work they check (Goodhart
35
+ guard): default `delegate-implementation`, raised to match or exceed the tier
36
+ that produced the work under review — a reviewer of `delegate-design` work
37
+ resolves `delegate-design` or `orchestrator`, never a cheaper tier. Resolve the
38
+ role from `.datum/config.json` (`npx @kontourai/datum resolve <role> --json`) and
39
+ pass the model explicitly. See `context/contracts/execution-contract.md`
40
+ § Delegation: Model Routing and § Goodhart guard. Fallback: inherit the session
41
+ model when datum/config is absent, noted in the artifact.
42
+
32
43
  ## Shared Contracts
33
44
 
34
45
  Follow:
@@ -25,6 +25,23 @@ Same as deliver (inherited from primitives):
25
25
  | tool-verifier | verify-work (with coverage check) |
26
26
  | tool-playwright | verify-work (if UI) |
27
27
 
28
+ ## Model Routing
29
+
30
+ Delegates are spawned with an explicit model override resolved from
31
+ `.datum/config.json` via `npx @kontourai/datum resolve <role> --json` — see
32
+ `context/contracts/execution-contract.md` § Delegation: Model Routing:
33
+
34
+ | Delegate | Role |
35
+ |---|---|
36
+ | tool-worker | `delegate-mechanical` for fully-specified mechanical slices, `delegate-implementation` for precisely-planned implementation (RED/GREEN/REFACTOR), `delegate-design` when a slice needs design latitude |
37
+ | tool-planner | `delegate-design` |
38
+ | tool-code-reviewer / tool-security-reviewer | `delegate-implementation` by default, raised to the worker's tier when higher — never below the tier of the checked work (Goodhart guard) |
39
+ | tool-verifier / tool-playwright | `delegate-implementation` by default, raised to the worker's tier when higher — never below the tier of the checked work (Goodhart guard) |
40
+
41
+ On a review/verify gate failure, re-dispatch the fix one tier higher and record
42
+ the escalation (contract § Escalation on gate failure). Fallback: inherit the
43
+ session model when datum/config is absent, noted in the artifact.
44
+
28
45
  ## Orchestrator Rule
29
46
 
30
47
  Same as deliver: you never touch source files. You coordinate the primitives with TDD-specific context.
@@ -16,6 +16,17 @@ Verification is not critique. Run `review-work` first when the task needs mainta
16
16
  | tool-verifier | Code verification, acceptance criteria checking, structured verdicts |
17
17
  | tool-playwright | Visual verification, screenshots, accessibility checks |
18
18
 
19
+ ## Model Routing
20
+
21
+ Verify roles never resolve **below** the tier of the work they check (Goodhart
22
+ guard): default `delegate-implementation`, raised to match or exceed the tier
23
+ that produced the work under verification — a verifier of `delegate-design` work
24
+ resolves `delegate-design` or `orchestrator`, never a cheaper tier. Resolve the
25
+ role from `.datum/config.json` (`npx @kontourai/datum resolve <role> --json`) and
26
+ pass the model explicitly. See `context/contracts/execution-contract.md`
27
+ § Delegation: Model Routing and § Goodhart guard. Fallback: inherit the session
28
+ model when datum/config is absent, noted in the artifact.
29
+
19
30
  ## Orchestrator Rule
20
31
 
21
32
  You do not review source files. You delegate to tool-verifier and tool-playwright, then read the verdict artifact.
@@ -16,6 +16,22 @@ import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { randomUUID } from "node:crypto";
18
18
 
19
+ // Record-identity resolution layer (short-id prefix + slug aliases, issue #339).
20
+ // Single-sourced in the shared codec so both bundled adapters resolve
21
+ // identically; the rest of this adapter keeps its own zero-import helpers.
22
+ import {
23
+ resolveRecordId,
24
+ normalizeAliases,
25
+ emptyAliasIndex,
26
+ loadAliasIndex,
27
+ saveAliasIndex,
28
+ registerAliases,
29
+ // Record-carried freshness + derived staleness (issue #341, Addendum J).
30
+ freshnessPatch,
31
+ decodeFreshnessFields,
32
+ isRecordStale,
33
+ } from "../shared/codec.js";
34
+
19
35
  // ---------------------------------------------------------------------------
20
36
  // Error helpers
21
37
  // ---------------------------------------------------------------------------
@@ -140,7 +156,17 @@ function parseYamlLines(lines, start, baseIndent) {
140
156
  }
141
157
 
142
158
  function unquote(s) {
143
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
159
+ if (s.startsWith('"') && s.endsWith('"')) {
160
+ // Double-quoted scalars are escaped by yamlScalar; reverse those escapes
161
+ // (backslash, quote, newline, carriage-return, tab) left-to-right so a
162
+ // frontmatter value can carry newlines/quotes and round-trip intact. This
163
+ // keeps every scalar on ONE physical line, so an embedded "\n---\n" can
164
+ // never be mistaken for the frontmatter/body terminator (parseMarkdown).
165
+ return s.slice(1, -1).replace(/\\(["\\nrt])/g, (_, c) =>
166
+ c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c
167
+ );
168
+ }
169
+ if (s.startsWith("'") && s.endsWith("'")) {
144
170
  return s.slice(1, -1);
145
171
  }
146
172
  return s;
@@ -198,9 +224,20 @@ function serializeYaml(obj, indent = 0) {
198
224
 
199
225
  function yamlScalar(v) {
200
226
  if (typeof v === "string") {
201
- // Quote if it contains special chars
202
- if (/[:#\[\]{},&*?|<>=!%@`"'\n]/.test(v) || v.trim() !== v || v === "") {
203
- return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
227
+ // Quote if it contains special chars (incl. any whitespace control char that
228
+ // would otherwise span lines newline, carriage-return, tab).
229
+ if (/[:#\[\]{},&*?|<>=!%@`"'\n\r\t]/.test(v) || v.trim() !== v || v === "") {
230
+ // Escape backslash first, then quote and the control chars, so the value
231
+ // stays on ONE physical line. Reversed by unquote(). Without newline
232
+ // escaping a value like a multi-section proposal body would inject a raw
233
+ // "\n---\n" into the frontmatter and corrupt the record on read.
234
+ const escaped = v
235
+ .replace(/\\/g, "\\\\")
236
+ .replace(/"/g, '\\"')
237
+ .replace(/\n/g, "\\n")
238
+ .replace(/\r/g, "\\r")
239
+ .replace(/\t/g, "\\t");
240
+ return `"${escaped}"`;
204
241
  }
205
242
  return v;
206
243
  }
@@ -340,6 +377,7 @@ export class DefaultKnowledgeStore {
340
377
  this._root = path.resolve(storeRoot);
341
378
  this._recordsDir = path.join(this._root, "records");
342
379
  this._graphPath = path.join(this._root, "graph-index.json");
380
+ this._aliasPath = path.join(this._root, "alias-index.json");
343
381
  fs.mkdirSync(this._recordsDir, { recursive: true });
344
382
  }
345
383
 
@@ -351,12 +389,42 @@ export class DefaultKnowledgeStore {
351
389
  return path.join(this._recordsDir, `${id}.md`);
352
390
  }
353
391
 
392
+ // -- Record-identity resolution (short-id prefix + slug alias, issue #339) --
393
+
394
+ /** Does a record file exist for this exact full id? */
395
+ _idExists(id) {
396
+ return fs.existsSync(this._recordPath(id));
397
+ }
398
+
399
+ /** All full record ids, derived cheaply from the records/ directory. */
400
+ _listIds() {
401
+ if (!fs.existsSync(this._recordsDir)) return [];
402
+ return fs.readdirSync(this._recordsDir)
403
+ .filter((f) => f.endsWith(".md"))
404
+ .map((f) => f.slice(0, -3));
405
+ }
406
+
407
+ /**
408
+ * Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
409
+ * single full record id, or null when it resolves to nothing. Throws
410
+ * AMBIGUOUS_ID when a prefix matches more than one record.
411
+ */
412
+ _resolveId(input) {
413
+ const aliasIndex = loadAliasIndex(this._aliasPath);
414
+ return resolveRecordId(input, {
415
+ idExists: (rid) => this._idExists(rid),
416
+ listIds: () => this._listIds(),
417
+ bySlug: aliasIndex.by_slug,
418
+ });
419
+ }
420
+
354
421
  _readRecord(id) {
355
422
  const p = this._recordPath(id);
356
423
  if (!fs.existsSync(p)) return null;
357
424
  const text = fs.readFileSync(p, "utf8");
358
425
  const { meta, body } = parseMarkdown(text);
359
- return { ...meta, body };
426
+ // Coerce record-carried freshness fields to canonical types (#341).
427
+ return decodeFreshnessFields({ ...meta, body });
360
428
  }
361
429
 
362
430
  _writeRecord(record) {
@@ -393,6 +461,19 @@ export class DefaultKnowledgeStore {
393
461
  const id = input.id || randomUUID();
394
462
  const now = this._now();
395
463
 
464
+ // Validate slug aliases (issue #339) and reserve them in the alias map
465
+ // BEFORE any write, so a SLUG_CONFLICT aborts create without a partial record.
466
+ const aliases = normalizeAliases(input.aliases);
467
+ let aliasIndex = null;
468
+ if (aliases.length) {
469
+ aliasIndex = loadAliasIndex(this._aliasPath);
470
+ registerAliases(aliasIndex, id, aliases);
471
+ }
472
+
473
+ // Validate optional freshness fields (#341) before any write, so a malformed
474
+ // expires_at / ttl_seconds aborts create without a partial record.
475
+ const fresh = freshnessPatch(input);
476
+
396
477
  // Merge explicit links + wikilinks from body
397
478
  const explicitLinks = input.links || [];
398
479
  const wikilinks = extractWikilinks(input.body || "");
@@ -404,9 +485,11 @@ export class DefaultKnowledgeStore {
404
485
  title: input.title,
405
486
  category: input.category,
406
487
  tags: input.tags || [],
488
+ ...(aliases.length ? { aliases } : {}),
407
489
  status: "active",
408
490
  created_at: now,
409
491
  updated_at: now,
492
+ ...fresh,
410
493
  provenance: {
411
494
  agent: input.provenance.agent,
412
495
  ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
@@ -425,6 +508,9 @@ export class DefaultKnowledgeStore {
425
508
  addLinksToGraph(graph, id, links);
426
509
  saveGraph(this._graphPath, graph);
427
510
 
511
+ // Persist the alias map only after the record is on disk.
512
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
513
+
428
514
  return id;
429
515
  }
430
516
 
@@ -439,7 +525,7 @@ export class DefaultKnowledgeStore {
439
525
  const record = this._readRecord(id);
440
526
  if (!record) throw notFoundError(id);
441
527
 
442
- const mutableKeys = ["title", "body", "category", "tags", "links"];
528
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases", "expires_at", "ttl_seconds"];
443
529
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
444
530
  if (supplied.length === 0)
445
531
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -447,8 +533,24 @@ export class DefaultKnowledgeStore {
447
533
  if (fields.category !== undefined && !validateCategory(fields.category))
448
534
  throw missingEvidenceError(`update: invalid category: ${fields.category}`);
449
535
 
536
+ // Validate/normalize supplied freshness fields (#341). A field set to null/""
537
+ // clears it (patch value undefined → dropped by the serializer).
538
+ const fresh = freshnessPatch(fields);
539
+
450
540
  const now = this._now();
451
541
 
542
+ // Slug aliases are append-only: supplied aliases are UNIONED with existing
543
+ // ones so a previously issued slug keeps resolving after a restructure (R3).
544
+ let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
545
+ let aliasIndex = null;
546
+ if (fields.aliases !== undefined) {
547
+ const incoming = normalizeAliases(fields.aliases);
548
+ const seen = new Set(mergedAliases);
549
+ for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
550
+ aliasIndex = loadAliasIndex(this._aliasPath);
551
+ registerAliases(aliasIndex, id, mergedAliases);
552
+ }
553
+
452
554
  // Merge links if updated
453
555
  let newLinks = record.links || [];
454
556
  if (fields.links !== undefined) {
@@ -465,6 +567,8 @@ export class DefaultKnowledgeStore {
465
567
  ...(fields.body !== undefined ? { body: fields.body } : {}),
466
568
  ...(fields.category !== undefined ? { category: fields.category } : {}),
467
569
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
570
+ ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
571
+ ...fresh,
468
572
  links: newLinks,
469
573
  updated_at: now,
470
574
  mutation_log: [
@@ -486,6 +590,8 @@ export class DefaultKnowledgeStore {
486
590
  saveGraph(this._graphPath, graph);
487
591
 
488
592
  this._writeRecord(updated);
593
+
594
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
489
595
  }
490
596
 
491
597
  // -------------------------------------------------------------------------
@@ -843,7 +949,12 @@ export class DefaultKnowledgeStore {
843
949
  // -------------------------------------------------------------------------
844
950
 
845
951
  async get(id) {
846
- return this._readRecord(id);
952
+ // Accept an exact id, a slug alias, or an unambiguous id prefix (issue #339).
953
+ // Unresolved → null (unchanged missing-record semantics); ambiguous prefix
954
+ // → throws AMBIGUOUS_ID.
955
+ const resolvedId = this._resolveId(id);
956
+ if (!resolvedId) return null;
957
+ return this._readRecord(resolvedId);
847
958
  }
848
959
 
849
960
  // -------------------------------------------------------------------------
@@ -851,10 +962,13 @@ export class DefaultKnowledgeStore {
851
962
  // -------------------------------------------------------------------------
852
963
 
853
964
  async getLinks(id) {
965
+ // Resolve prefix/slug to a full id; fall back to the raw token when it
966
+ // resolves to nothing so unknown ids still return empty arrays (not throw).
967
+ const key = this._resolveId(id) || id;
854
968
  const graph = loadGraph(this._graphPath);
855
969
  return {
856
- forward: (graph.forward[id] || []).map((l) => ({ ...l })),
857
- reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
970
+ forward: (graph.forward[key] || []).map((l) => ({ ...l })),
971
+ reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
858
972
  };
859
973
  }
860
974
 
@@ -865,18 +979,19 @@ export class DefaultKnowledgeStore {
865
979
  async listByCategory(category, options = {}) {
866
980
  const records = this._allRecords();
867
981
  const includeRetired = options.includeRetired === true;
982
+ // Derived staleness filter (#341): keep only records past their own effective
983
+ // expiry at `now` (injectable for tests). Absent → no staleness filtering.
984
+ const staleOnly = options.stale === true;
985
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
986
+ const keep = (r) =>
987
+ (includeRetired || (r.status || "active") !== "retired") &&
988
+ (!staleOnly || isRecordStale(r, nowMs));
868
989
  if (options.prefix) {
869
990
  return records.filter(
870
- (r) =>
871
- (r.category === category || r.category.startsWith(`${category}.`)) &&
872
- (includeRetired || (r.status || "active") !== "retired")
991
+ (r) => (r.category === category || r.category.startsWith(`${category}.`)) && keep(r)
873
992
  );
874
993
  }
875
- return records.filter(
876
- (r) =>
877
- r.category === category &&
878
- (includeRetired || (r.status || "active") !== "retired")
879
- );
994
+ return records.filter((r) => r.category === category && keep(r));
880
995
  }
881
996
 
882
997
  // -------------------------------------------------------------------------
@@ -885,10 +1000,14 @@ export class DefaultKnowledgeStore {
885
1000
 
886
1001
  async listByType(type, options = {}) {
887
1002
  const includeRetired = options.includeRetired === true;
1003
+ // Derived staleness filter (#341) — see listByCategory.
1004
+ const staleOnly = options.stale === true;
1005
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
888
1006
  return this._allRecords().filter(
889
1007
  (r) =>
890
1008
  r.type === type &&
891
- (includeRetired || (r.status || "active") !== "retired")
1009
+ (includeRetired || (r.status || "active") !== "retired") &&
1010
+ (!staleOnly || isRecordStale(r, nowMs))
892
1011
  );
893
1012
  }
894
1013
 
@@ -911,6 +1030,16 @@ export class DefaultKnowledgeStore {
911
1030
  const links = Object.values(rebuilt.forward).reduce((n, arr) => n + arr.length, 0);
912
1031
  const changed = canonicalGraph(loadGraph(this._graphPath)) !== canonicalGraph(rebuilt);
913
1032
  saveGraph(this._graphPath, rebuilt);
1033
+
1034
+ // The alias map is likewise derived from records' `aliases` — rebuild it so
1035
+ // a lost/hand-edited alias-index.json recovers on the same recovery path.
1036
+ const rebuiltAliases = emptyAliasIndex();
1037
+ for (const record of records) {
1038
+ const slugs = normalizeAliases(record.aliases);
1039
+ if (slugs.length) registerAliases(rebuiltAliases, record.id, slugs);
1040
+ }
1041
+ saveAliasIndex(this._aliasPath, rebuiltAliases);
1042
+
914
1043
  return {
915
1044
  records: records.length,
916
1045
  links,