@kontourai/flow-agents 3.0.0 → 3.1.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  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 +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Sync / materialize: import the file/work-item providers into Neo4j.
3
+ *
4
+ * The file stores remain the SOURCE OF TRUTH; the graph is a queryable
5
+ * materialized view. Sync reuses the #317 providers as READERS (no forked
6
+ * extractors) and MERGEs their combined graph into Neo4j with MERGE semantics,
7
+ * so a re-sync of unchanged data reports zero writes (AC1). A snapshot digest +
8
+ * timestamp is recorded on the graph so the second run can short-circuit and so
9
+ * the graph is point-in-time attributable.
10
+ *
11
+ * Marker tolerance (spike finding 3: 3/19 metadata markers failed JSON.parse in
12
+ * the wild): a provider that throws while reading MUST NOT kill the whole sync.
13
+ * Each provider is read behind a guard that falls back to an empty contribution
14
+ * and records the error; the work-item provider already falls back from a
15
+ * malformed metadata marker to prose refs, and this guard covers the rest.
16
+ *
17
+ * Pure planning (planSync/computeDigest) is separated from IO (syncToNeo4j) so
18
+ * idempotency is unit-testable with no driver and no Docker.
19
+ *
20
+ * @module providers/neo4j/sync
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import {
25
+ typeToLabel,
26
+ edgeTypeToRel,
27
+ nodeToProps,
28
+ edgeToProps,
29
+ SNAPSHOT_LABEL,
30
+ SNAPSHOT_ID,
31
+ READ_SNAPSHOT_CYPHER,
32
+ } from "./cypher.js";
33
+
34
+ /** Deterministic JSON (sorted keys) for hashing. */
35
+ function canonical(value) {
36
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
37
+ if (value && typeof value === "object") {
38
+ return `{${Object.keys(value)
39
+ .sort()
40
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
41
+ .join(",")}}`;
42
+ }
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ /**
47
+ * The graph is a materialized view keyed on CONTENT, not on when it was read.
48
+ * Provenance carries volatile fields (retrieved_at, agent) that change every
49
+ * read — including them would defeat idempotency (a re-sync of unchanged stores
50
+ * would rewrite every element). Strip them for identity; the snapshot's
51
+ * synced_at records the point-in-time separately.
52
+ */
53
+ function contentIdentity(el) {
54
+ const clone = JSON.parse(JSON.stringify(el));
55
+ if (clone.provenance) {
56
+ delete clone.provenance.retrieved_at;
57
+ delete clone.provenance.agent;
58
+ }
59
+ return clone;
60
+ }
61
+
62
+ /** Content hash of a single node/edge (drives the idempotent hash-guarded SET). */
63
+ export function hashElement(el) {
64
+ return createHash("sha256").update(canonical(contentIdentity(el))).digest("hex").slice(0, 16);
65
+ }
66
+
67
+ /** Digest of the whole materialized graph (drives the zero-write short-circuit). */
68
+ export function computeDigest(graph) {
69
+ const nodes = [...(graph.nodes || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
70
+ const edges = [...(graph.edges || [])].map(contentIdentity).sort((a, b) => a.id.localeCompare(b.id));
71
+ return createHash("sha256").update(canonical({ nodes, edges })).digest("hex");
72
+ }
73
+
74
+ /** Read one provider's graph behind a guard; never let one provider kill sync. */
75
+ async function safeReadGraph(provider, errors) {
76
+ try {
77
+ const g = await provider.readGraph();
78
+ return { nodes: g.nodes || [], edges: g.edges || [] };
79
+ } catch (err) {
80
+ errors.push({ provider: provider?.id || provider?.constructor?.name || "unknown", error: String(err && err.message || err) });
81
+ return { nodes: [], edges: [] };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Materialize the combined graph from the provider readers. Dedupes nodes and
87
+ * edges by id (last write wins on id collision across providers).
88
+ * @param {object[]} providers #317 provider instances (vault, git-repo, work-item, …)
89
+ * @returns {Promise<{ nodes:object[], edges:object[], errors:object[] }>}
90
+ */
91
+ export async function materializeGraph(providers) {
92
+ const errors = [];
93
+ const nodeById = new Map();
94
+ const edgeById = new Map();
95
+ for (const provider of providers) {
96
+ const { nodes, edges } = await safeReadGraph(provider, errors);
97
+ for (const n of nodes) nodeById.set(n.id, n);
98
+ for (const e of edges) edgeById.set(e.id, e);
99
+ }
100
+ return { nodes: [...nodeById.values()], edges: [...edgeById.values()], errors };
101
+ }
102
+
103
+ /**
104
+ * Pure sync plan. Given the materialized graph and the prior snapshot digest,
105
+ * decide whether anything changed and produce the parameterized MERGE
106
+ * statements. When the digest is unchanged, the plan is a no-op (AC1: second run
107
+ * reports zero writes) unless `force` is set.
108
+ *
109
+ * @param {{nodes:object[],edges:object[]}} graph
110
+ * @param {string|null} priorDigest
111
+ * @param {object} [options]
112
+ * @param {boolean} [options.force=false] emit statements even when unchanged
113
+ * @returns {{ digest:string, unchanged:boolean, statements:{cypher:string,params:object}[] }}
114
+ */
115
+ export function planSync(graph, priorDigest, options = {}) {
116
+ const digest = computeDigest(graph);
117
+ const unchanged = digest === priorDigest;
118
+ if (unchanged && !options.force) {
119
+ return { digest, unchanged, statements: [] };
120
+ }
121
+
122
+ const statements = [];
123
+
124
+ // Nodes grouped by contract type -> one MERGE per Neo4j label. Hash-guarded
125
+ // SET so an unchanged node produces zero propertiesSet on re-run.
126
+ const nodesByLabel = new Map();
127
+ for (const n of graph.nodes || []) {
128
+ const label = typeToLabel(n.type);
129
+ if (!nodesByLabel.has(label)) nodesByLabel.set(label, []);
130
+ nodesByLabel.get(label).push(nodeToProps(n, hashElement(n)));
131
+ }
132
+ for (const [label, rows] of nodesByLabel) {
133
+ statements.push({
134
+ cypher: `UNWIND $rows AS r MERGE (n:${label} {id:r.id}) WITH n, r WHERE coalesce(n._kg_hash,'') <> r._kg_hash SET n += r`,
135
+ params: { rows },
136
+ });
137
+ }
138
+
139
+ // Edges grouped by rel type. Endpoints matched by id regardless of label
140
+ // (spike: polyglot property bags "just work"); edges to a not-yet-synced node
141
+ // are skipped by the MATCH (external/dangling refs stay out of the view).
142
+ const edgesByRel = new Map();
143
+ for (const e of graph.edges || []) {
144
+ const rel = edgeTypeToRel(e.type);
145
+ if (!edgesByRel.has(rel)) edgesByRel.set(rel, []);
146
+ edgesByRel.get(rel).push(edgeToProps(e, hashElement(e)));
147
+ }
148
+ for (const [rel, rows] of edgesByRel) {
149
+ statements.push({
150
+ cypher: `UNWIND $rows AS r MATCH (a {id:r.from}) MATCH (b {id:r.to}) MERGE (a)-[x:${rel} {id:r.id}]->(b) WITH x, r WHERE coalesce(x._kg_hash,'') <> r._kg_hash SET x += r`,
151
+ params: { rows },
152
+ });
153
+ }
154
+
155
+ return { digest, unchanged, statements };
156
+ }
157
+
158
+ /** Read the prior snapshot digest from Neo4j (null if never synced). */
159
+ async function readPriorDigest(session) {
160
+ const res = await session.run(READ_SNAPSHOT_CYPHER, { id: SNAPSHOT_ID });
161
+ const rec = res.records && res.records[0];
162
+ if (!rec) return null;
163
+ const props = rec.get("props");
164
+ return (props && props.digest) || null;
165
+ }
166
+
167
+ function sumWrites(counters) {
168
+ const u = typeof counters.updates === "function" ? counters.updates() : counters;
169
+ return (u.nodesCreated || 0) + (u.relationshipsCreated || 0) + (u.propertiesSet || 0);
170
+ }
171
+
172
+ /**
173
+ * Materialize the providers and MERGE them into Neo4j idempotently.
174
+ *
175
+ * @param {object} options
176
+ * @param {object} options.driver neo4j driver (or the injected fake)
177
+ * @param {string} [options.database]
178
+ * @param {object[]} [options.providers] #317 providers to read (or pass graph)
179
+ * @param {{nodes,edges}} [options.graph] a pre-materialized graph (skips readers)
180
+ * @param {boolean} [options.force=false] re-run MERGEs even if the digest matches
181
+ * @returns {Promise<{ writes:number, unchanged:boolean, digest:string, nodes:number, edges:number, snapshot:object, errors:object[] }>}
182
+ */
183
+ export async function syncToNeo4j(options = {}) {
184
+ const { driver, database, force = false } = options;
185
+ let errors = [];
186
+ let graph = options.graph;
187
+ if (!graph) {
188
+ const mat = await materializeGraph(options.providers || []);
189
+ graph = { nodes: mat.nodes, edges: mat.edges };
190
+ errors = mat.errors;
191
+ }
192
+
193
+ const session = driver.session(database ? { database } : undefined);
194
+ try {
195
+ const priorDigest = await readPriorDigest(session);
196
+ const plan = planSync(graph, priorDigest, { force });
197
+
198
+ if (plan.unchanged && !force) {
199
+ return {
200
+ writes: 0,
201
+ unchanged: true,
202
+ digest: plan.digest,
203
+ nodes: graph.nodes.length,
204
+ edges: graph.edges.length,
205
+ snapshot: { digest: plan.digest },
206
+ errors,
207
+ };
208
+ }
209
+
210
+ let writes = 0;
211
+ for (const stmt of plan.statements) {
212
+ const res = await session.run(stmt.cypher, stmt.params);
213
+ writes += sumWrites(res.summary.counters);
214
+ }
215
+
216
+ const syncedAt = new Date().toISOString();
217
+ const snapRes = await session.run(
218
+ `MERGE (s:${SNAPSHOT_LABEL} {id:$id}) WITH s, $digest AS d WHERE coalesce(s.digest,'') <> d SET s.digest = d, s.synced_at = $syncedAt, s.node_count = $nc, s.edge_count = $ec`,
219
+ { id: SNAPSHOT_ID, digest: plan.digest, syncedAt, nc: graph.nodes.length, ec: graph.edges.length },
220
+ );
221
+ writes += sumWrites(snapRes.summary.counters);
222
+
223
+ return {
224
+ writes,
225
+ unchanged: plan.unchanged,
226
+ digest: plan.digest,
227
+ nodes: graph.nodes.length,
228
+ edges: graph.edges.length,
229
+ snapshot: { digest: plan.digest, synced_at: syncedAt },
230
+ errors,
231
+ };
232
+ } finally {
233
+ await session.close();
234
+ }
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -139,6 +139,7 @@
139
139
  "check:content-boundary": "node scripts/check-content-boundary.cjs",
140
140
  "check:decisions": "node scripts/check-decisions.cjs check",
141
141
  "gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
142
+ "freeze:adrs": "node scripts/freeze-adrs.mjs",
142
143
  "prepack": "npm run build --silent && npm run validate:source --",
143
144
  "kit": "npm run build --silent && node build/src/cli.js kit"
144
145
  },
@@ -152,6 +153,7 @@
152
153
  },
153
154
  "optionalDependencies": {
154
155
  "@kontourai/surface": "^2.0.0",
155
- "hachure": "^0.9.0"
156
+ "hachure": "^0.9.0",
157
+ "neo4j-driver": "^5.28.0"
156
158
  }
157
159
  }
package/scripts/README.md CHANGED
@@ -70,6 +70,7 @@ renamed, or changes category, update the table and the validator together.
70
70
  | `lib/actor-identity.js` | shared hook library | `evals/integration/test_actor_identity.sh` | Shared runtime-agnostic actor identity resolver (`resolveActor`) consumed by `workflow-steering.js` and `workflow-sidecar.js` to retire the shared `"local"` liveness-actor default (issue #287). |
71
71
  | `lib/audit-transport.sh` | shared hook library | `evals/integration/test_hook_category_behaviors.sh`, `evals/integration/test_telemetry.sh` | Shared audit event transport functions. |
72
72
  | `lib/config-protection-remedies.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Sanctioned-remedy table for `config-protection.js` (WS8/ADR 0020 extraction). |
73
+ | `lib/current-pointer.js` | shared hook library | `evals/integration/test_current_json_per_actor.sh` | Shared per-actor "current" pointer reader/writer (`readCurrentPointer`, `writePerActorCurrent`) — the compat-shim fallback (per-actor `current/<actor>.json` first, legacy global `current.json` fallback) every actor-aware consumer routes through (issue #291). |
73
74
  | `lib/hook-flags.js` | shared hook library | `evals/integration/test_hook_category_behaviors.sh` | Shared profile/disable flag parsing. |
74
75
  | `lib/liveness-heartbeat.js` | shared hook library | `evals/integration/test_liveness_heartbeat.sh` | Shared tool-activity liveness heartbeat (`maybeEmitHeartbeat`); rides `postToolUse` across all four telemetry hook wrappers, throttled and fail-open (issue #288). |
75
76
  | `lib/liveness-policy.js` | shared hook library | `evals/integration/test_workflow_sidecar_writer.sh`, `evals/integration/test_liveness_heartbeat.sh` | Shared liveness on/off predicate (`isLivenessEnabled`, default-on/opt-out) and TTL/heartbeat-throttle default resolution (issue #288). |
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ // freeze-adrs.mjs — ADR freeze-and-index tooling for flow-agents.
3
+ //
4
+ // Freezes numbered ADRs under docs/adr/ as immutable history and seeds the
5
+ // topic-keyed decision registry (docs/decisions/) with `needs-decision` stubs
6
+ // that carry the frozen ADR(s) as provenance evidence.
7
+ //
8
+ // Origin & provenance: this is the portable, zero-runtime-dependency standalone
9
+ // script (node >=22, ESM) piloted in kontourai/traverse per
10
+ // https://github.com/kontourai/flow-agents/issues/314 and rolled out across the
11
+ // portfolio (traverse#17, surface#115, flow#103, survey#108, and a fifth repo). This
12
+ // copy is the flow-agents cutover (https://github.com/kontourai/flow-agents/issues/332),
13
+ // the 6th application. It carries the two rollout fixes proven in the prior
14
+ // applications (surface#115 / flow#103):
15
+ // FIX 1 (frontmatter-aware banner placement): every flow-agents ADR opens with
16
+ // a YAML frontmatter block (`---\ntitle: ...\n---`). The traverse-original
17
+ // always prepended the banner at byte 0, which would push the frontmatter
18
+ // off the literal first line. `insertBanner()` detects a leading frontmatter
19
+ // block and inserts the banner AFTER its closing `---` instead.
20
+ // FIX 2 (whole-phrase lowercasing for Title Case terms): the traverse-original
21
+ // `defaultStubBody` only lowercased the first character of a subject when
22
+ // used mid-sentence, which read fine for traverse's sentence-case subjects
23
+ // but produced "trust Bundle" for Title Case glossary terms. `lowerPhrase()`
24
+ // lowercases every simple Title Case word (^[A-Z][a-z]+$) while preserving
25
+ // acronyms (MCP), CamelCase (TypeScript-first), and symbol tokens (`/`) —
26
+ // the flow-agents vocabulary contains all three.
27
+ //
28
+ // It does NOT vendor a copy of the registry validator or schema: flow-agents is
29
+ // the source of truth for both (scripts/check-decisions.cjs,
30
+ // schemas/decision-record.schema.json, wired as `npm run check:decisions` /
31
+ // `npm run gen:decisions-index` per #316). This script points at the existing
32
+ // validator to regenerate docs/decisions/index.md.
33
+ //
34
+ // Behavior (idempotent, content-preserving):
35
+ // 1. For each docs/adr/NNNN-*.md: insert a frozen banner as the ONLY body
36
+ // change (after any leading YAML frontmatter). Skipped if already present.
37
+ // 2. Generate docs/adr/index.md deterministically. A second run is diff-clean.
38
+ // 3. For each SUBJECT_GROUPS entry, create/update a `status: needs-decision`
39
+ // topic stub in docs/decisions/<slug>.md whose evidence[] links the frozen
40
+ // ADR(s). Existing stub frontmatter (status/decided) is preserved across
41
+ // reruns; only missing evidence entries are appended.
42
+ //
43
+ // Usage: node scripts/freeze-adrs.mjs (npm run freeze:adrs)
44
+
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import { fileURLToPath } from "node:url";
48
+ import { execFileSync } from "node:child_process";
49
+
50
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
51
+ const ROOT = path.resolve(__dirname, "..");
52
+ const ADR_DIR = path.join(ROOT, "docs", "adr");
53
+ const ADR_INDEX_PATH = path.join(ADR_DIR, "index.md");
54
+ const DECISIONS_DIR = path.join(ROOT, "docs", "decisions");
55
+
56
+ const FROZEN_BANNER_MARKER = "FROZEN — immutable history.";
57
+ const FROZEN_BANNER =
58
+ "> **FROZEN — immutable history.** Superseding/current decisions live in " +
59
+ "[`docs/decisions/`](../decisions/index.md). Do not edit.\n\n";
60
+
61
+ // --- Subject grouping (flow-agents-specific, reviewed by hand) --------------
62
+ //
63
+ // Each ADR's decision subject is a NOUN drawn from (or added to) CONTEXT.md's
64
+ // domain vocabulary, per context/contracts/decision-registry-contract.md's slug
65
+ // rule. Multiple ADRs answering the same subject share one stub with multiple
66
+ // evidence refs (contract: "one file per decision SUBJECT"). Groups reference
67
+ // explicit ADR *filenames* (not numbers) because 0007 has two files sharing the
68
+ // number (0007-flow-skill-kit-tool-boundary.md and 0007-skill-audit.md), which a
69
+ // number-keyed map would collide.
70
+ //
71
+ // The four existing living topics (decision-records, promotion-gate,
72
+ // knowledge-store-provider, standing-directives) are NOT touched: none of the
73
+ // 22 numbered ADRs is about those subjects, so there is no evidence-merge case
74
+ // here — every frozen ADR subject gets a fresh needs-decision stub.
75
+ const SUBJECT_GROUPS = [
76
+ { slug: "workflow-enforcement", subject: "Workflow Enforcement", adrFiles: ["0001-flow-agents-consumes-flow.md"] },
77
+ { slug: "flow-kit", subject: "Flow Kit", adrFiles: ["0002-flow-kits-as-extension-unit.md", "0003-flow-agents-coordinates-kits-and-adapters.md"] },
78
+ { slug: "workflow-trust-state", subject: "Workflow trust state", adrFiles: ["0004-gates-expect-surface-claims.md", "0010-workflow-trust-state-as-hachure-bundle.md"] },
79
+ { slug: "kontour-resource-contract", subject: "Kontour Resource Contract", adrFiles: ["0005-kubernetes-inspired-resource-contracts.md"] },
80
+ { slug: "typescript-source-policy", subject: "TypeScript-first source policy", adrFiles: ["0006-typescript-first-source-policy.md"] },
81
+ { slug: "flow-skill-kit-tool-boundary", subject: "Flow / Skill / Kit / Tool boundary", adrFiles: ["0007-flow-skill-kit-tool-boundary.md", "0007-skill-audit.md"] },
82
+ { slug: "kit-operation-boundary", subject: "Kit operation boundary", adrFiles: ["0008-kit-operation-boundary.md"] },
83
+ { slug: "hook-core-kit-boundary", subject: "Hook core/kit boundary", adrFiles: ["0009-canonical-hook-core-kit-boundary.md"] },
84
+ { slug: "mcp-posture", subject: "MCP posture", adrFiles: ["0011-mcp-posture.md"] },
85
+ { slug: "agent-coordination", subject: "Agent coordination", adrFiles: ["0012-agent-coordination-as-liveness-claims.md", "0021-assignment-leases-and-stale-claim-takeover.md"] },
86
+ { slug: "context-lifecycle", subject: "Context lifecycle", adrFiles: ["0013-context-lifecycle.md"] },
87
+ { slug: "core-domain-kit-boundary", subject: "Core vs domain kit boundary", adrFiles: ["0014-core-vs-domain-kit-boundary.md"] },
88
+ { slug: "flow-flow-agents-boundary", subject: "Flow / Flow Agents boundary", adrFiles: ["0015-flow-flow-agents-boundary-reconciliation.md"] },
89
+ { slug: "three-hard-boundary-model", subject: "Three-hard-boundary model", adrFiles: ["0016-three-hard-boundary-model.md"] },
90
+ { slug: "anti-gaming-trust-security", subject: "Anti-gaming trust security", adrFiles: ["0017-anti-gaming-trust-security-model.md", "0018-freeze-local-shell-heuristics.md"] },
91
+ { slug: "kit-dependency-ownership", subject: "Kit dependency ownership", adrFiles: ["0019-kit-dependency-ownership.md"] },
92
+ { slug: "trust-reconcile", subject: "Trust-reconcile and delivery reconciliation", adrFiles: ["0020-trust-reconcile-manifest-and-claim-classification.md", "0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md"] },
93
+ ];
94
+
95
+ function listAdrFiles() {
96
+ if (!fs.existsSync(ADR_DIR)) return [];
97
+ return fs
98
+ .readdirSync(ADR_DIR)
99
+ .filter((name) => /^\d{4}-.*\.md$/.test(name))
100
+ .sort();
101
+ }
102
+
103
+ function adrNumberFromFilename(name) {
104
+ return parseInt(name.slice(0, 4), 10);
105
+ }
106
+
107
+ // FIX 1: frontmatter-aware banner insertion. If the file opens with a YAML
108
+ // frontmatter block, insert the banner immediately after its closing `---`
109
+ // (and the blank line that follows), never before the frontmatter.
110
+ function insertBanner(raw) {
111
+ if (raw.startsWith("---\n") || raw.startsWith("---\r\n")) {
112
+ const lines = raw.split(/\r?\n/);
113
+ let end = -1;
114
+ for (let i = 1; i < lines.length; i += 1) {
115
+ if (lines[i].trim() === "---") { end = i; break; }
116
+ }
117
+ if (end !== -1) {
118
+ // Skip a single blank line after the closing fence so the banner lands
119
+ // between the frontmatter and the first heading with clean spacing.
120
+ let insertAt = end + 1;
121
+ if (insertAt < lines.length && lines[insertAt].trim() === "") insertAt += 1;
122
+ const before = lines.slice(0, insertAt).join("\n");
123
+ const after = lines.slice(insertAt).join("\n");
124
+ return `${before}\n${FROZEN_BANNER}${after}`;
125
+ }
126
+ }
127
+ return FROZEN_BANNER + raw;
128
+ }
129
+
130
+ function freezeOne(file) {
131
+ const full = path.join(ADR_DIR, file);
132
+ const raw = fs.readFileSync(full, "utf8");
133
+ if (raw.includes(FROZEN_BANNER_MARKER)) {
134
+ return { file, changed: false, raw };
135
+ }
136
+ const frozen = insertBanner(raw);
137
+ fs.writeFileSync(full, frozen, "utf8");
138
+ return { file, changed: true, raw: frozen };
139
+ }
140
+
141
+ // Best-effort title/date extraction (index display only — never touches frozen
142
+ // ADR content). Widened for flow-agents' header forms: `# ADR NNNN: Title`
143
+ // headings and `Date:` / `**Date:**` / parenthesized `(YYYY-MM-DD)` date lines.
144
+ function parseAdrMeta(file, raw) {
145
+ const number = adrNumberFromFilename(file);
146
+ const lines = raw.split(/\r?\n/);
147
+ let title = file;
148
+ for (const line of lines) {
149
+ const m = line.match(/^#\s+(.*)$/);
150
+ if (m) {
151
+ title = m[1].trim().replace(/^ADR\s+\d+\s*[:—-]\s*/, "");
152
+ break;
153
+ }
154
+ }
155
+ const dateMatch =
156
+ raw.match(/^\s*\*{0,2}Date:?\*{0,2}\s*(\d{4}-\d{2}-\d{2})/m) ||
157
+ raw.match(/\((\d{4}-\d{2}-\d{2})\)/);
158
+ const date = dateMatch ? dateMatch[1] : null;
159
+ return { number, file, title, date };
160
+ }
161
+
162
+ function renderAdrIndex(entries) {
163
+ const sorted = [...entries].sort((a, b) => (a.number - b.number) || a.file.localeCompare(b.file));
164
+ const lines = [];
165
+ lines.push("---");
166
+ lines.push("title: ADR Index");
167
+ lines.push("---");
168
+ lines.push("");
169
+ lines.push("# ADR Index");
170
+ lines.push("");
171
+ lines.push("Generated by `node scripts/freeze-adrs.mjs` (`npm run freeze:adrs`). Do not edit by hand.");
172
+ lines.push(
173
+ "Numbered ADRs below are FROZEN immutable history (see the banner on each " +
174
+ "file). Current and superseding decisions live in " +
175
+ "[docs/decisions/](../decisions/index.md); a frozen ADR's subject is " +
176
+ "carried forward there as a `needs-decision` stub or a ratified decision."
177
+ );
178
+ lines.push("");
179
+ lines.push("| Number | Title | Date | Link |");
180
+ lines.push("| --- | --- | --- | --- |");
181
+ for (const e of sorted) {
182
+ const num = String(e.number).padStart(4, "0");
183
+ const date = e.date || "unknown";
184
+ lines.push(`| ${num} | ${e.title} | ${date} | [${e.file}](./${e.file}) |`);
185
+ }
186
+ lines.push("");
187
+ return lines.join("\n");
188
+ }
189
+
190
+ function readExistingStub(slug) {
191
+ const full = path.join(DECISIONS_DIR, `${slug}.md`);
192
+ if (!fs.existsSync(full)) return null;
193
+ const raw = fs.readFileSync(full, "utf8");
194
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
195
+ if (!fmMatch) return null;
196
+ const [, fmBlock, body] = fmMatch;
197
+ const statusMatch = fmBlock.match(/^status:\s*(.+)$/m);
198
+ const decidedMatch = fmBlock.match(/^decided:\s*(.+)$/m);
199
+ const evidence = [];
200
+ const evidenceBlockMatch = fmBlock.match(/^evidence:\n([\s\S]*?)(?:\n[a-z_]+:|$)/m);
201
+ if (evidenceBlockMatch) {
202
+ const itemRe = /-\s*kind:\s*(\S+)\n\s*ref:\s*(.+)/g;
203
+ let m;
204
+ while ((m = itemRe.exec(evidenceBlockMatch[1])) !== null) {
205
+ evidence.push({ kind: m[1].trim(), ref: m[2].trim() });
206
+ }
207
+ }
208
+ return {
209
+ status: statusMatch ? statusMatch[1].trim() : null,
210
+ decided: decidedMatch ? decidedMatch[1].trim() : null,
211
+ evidence,
212
+ body,
213
+ };
214
+ }
215
+
216
+ function evidenceKey(e) {
217
+ return `${e.kind}::${e.ref}`;
218
+ }
219
+
220
+ function renderStub({ subject, decided, evidence, body }) {
221
+ const lines = [];
222
+ lines.push("---");
223
+ lines.push("status: needs-decision");
224
+ lines.push(`subject: ${subject}`);
225
+ lines.push(`decided: ${decided}`);
226
+ lines.push("evidence:");
227
+ for (const e of evidence) {
228
+ lines.push(` - kind: ${e.kind}`);
229
+ lines.push(` ref: ${e.ref}`);
230
+ }
231
+ lines.push("---");
232
+ lines.push(body);
233
+ return lines.join("\n");
234
+ }
235
+
236
+ // FIX 2: whole-phrase lowercasing for Title Case terms, acronym/CamelCase-safe.
237
+ function lowerPhrase(s) {
238
+ return s
239
+ .split(/(\s+)/)
240
+ .map((tok) => (/^[A-Z][a-z]+$/.test(tok) ? tok.toLowerCase() : tok))
241
+ .join("");
242
+ }
243
+
244
+ function defaultStubBody(subject, adrFiles) {
245
+ const adrLinks = adrFiles.map((f) => `[${f}](../adr/${f})`).join(", ");
246
+ return `
247
+ # ${subject}
248
+
249
+ This subject has provenance in frozen ADR history (${adrLinks}) but no living
250
+ decision has been ratified yet under the topic-keyed decision registry
251
+ (\`context/contracts/decision-registry-contract.md\`). This stub records that the
252
+ subject is open and links the frozen ADR(s) as provenance; it is not a decision.
253
+
254
+ When a living decision is ratified for ${lowerPhrase(subject)}, update this
255
+ file's \`status\` to \`current\`, add rationale, and keep the \`adr\` evidence
256
+ links as provenance for the history that led here.
257
+ `.trimStart();
258
+ }
259
+
260
+ function todayIso() {
261
+ return new Date().toISOString().slice(0, 10);
262
+ }
263
+
264
+ function upsertStub(group) {
265
+ const adrFiles = group.adrFiles;
266
+ const desiredEvidence = [
267
+ ...adrFiles.map((f) => ({ kind: "adr", ref: `docs/adr/${f}` })),
268
+ ...(group.extraEvidence || []),
269
+ ];
270
+
271
+ const existing = readExistingStub(group.slug);
272
+ const decided = existing?.decided || todayIso();
273
+ const body = existing?.body || defaultStubBody(group.subject, adrFiles);
274
+
275
+ const mergedEvidence = [];
276
+ const seen = new Set();
277
+ const source = existing ? existing.evidence.concat(desiredEvidence) : desiredEvidence;
278
+ for (const e of source) {
279
+ const key = evidenceKey(e);
280
+ if (seen.has(key)) continue;
281
+ seen.add(key);
282
+ mergedEvidence.push(e);
283
+ }
284
+
285
+ const content = renderStub({ subject: group.subject, decided, evidence: mergedEvidence, body });
286
+
287
+ const full = path.join(DECISIONS_DIR, `${group.slug}.md`);
288
+ const priorRaw = fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null;
289
+ if (priorRaw === content) {
290
+ return { slug: group.slug, changed: false };
291
+ }
292
+ fs.mkdirSync(DECISIONS_DIR, { recursive: true });
293
+ fs.writeFileSync(full, content, "utf8");
294
+ return { slug: group.slug, changed: true };
295
+ }
296
+
297
+ // Fail loudly if any numbered ADR file is unassigned or double-assigned, or a
298
+ // group references a missing file — silent drop of an ADR is exactly the bug a
299
+ // freeze must never ship.
300
+ function assertCoverage(adrFilenames) {
301
+ const assigned = new Map();
302
+ for (const g of SUBJECT_GROUPS) {
303
+ for (const f of g.adrFiles) {
304
+ if (assigned.has(f)) {
305
+ throw new Error(`ADR ${f} is assigned to two groups (${assigned.get(f)} and ${g.slug})`);
306
+ }
307
+ assigned.set(f, g.slug);
308
+ if (!adrFilenames.includes(f)) {
309
+ throw new Error(`group ${g.slug} references missing ADR file ${f}`);
310
+ }
311
+ }
312
+ }
313
+ const unassigned = adrFilenames.filter((f) => !assigned.has(f));
314
+ if (unassigned.length > 0) {
315
+ throw new Error(`ADR file(s) not assigned to any subject group: ${unassigned.join(", ")}`);
316
+ }
317
+ }
318
+
319
+ function main() {
320
+ const adrFilenames = listAdrFiles();
321
+ if (adrFilenames.length === 0) {
322
+ console.log("No docs/adr/NNNN-*.md files found; nothing to freeze.");
323
+ return 0;
324
+ }
325
+
326
+ assertCoverage(adrFilenames);
327
+
328
+ const frozen = adrFilenames.map(freezeOne);
329
+ const bannerChanged = frozen.filter((f) => f.changed);
330
+ for (const f of bannerChanged) {
331
+ console.log(`Froze ${path.relative(ROOT, path.join(ADR_DIR, f.file))} (banner inserted).`);
332
+ }
333
+
334
+ const meta = frozen.map((f) => parseAdrMeta(f.file, f.raw));
335
+ const adrIndexContent = renderAdrIndex(meta);
336
+ const priorIndex = fs.existsSync(ADR_INDEX_PATH) ? fs.readFileSync(ADR_INDEX_PATH, "utf8") : null;
337
+ if (priorIndex !== adrIndexContent) {
338
+ fs.writeFileSync(ADR_INDEX_PATH, adrIndexContent, "utf8");
339
+ console.log(`Wrote ${path.relative(ROOT, ADR_INDEX_PATH)}.`);
340
+ }
341
+
342
+ const stubResults = SUBJECT_GROUPS.map((g) => upsertStub(g));
343
+ for (const r of stubResults.filter((r) => r.changed)) {
344
+ console.log(`Wrote docs/decisions/${r.slug}.md (needs-decision stub).`);
345
+ }
346
+
347
+ // Chain the existing (not vendored) registry index generator so a single
348
+ // `npm run freeze:adrs` leaves docs/decisions/index.md current too.
349
+ const checkDecisions = path.join(ROOT, "scripts", "check-decisions.cjs");
350
+ if (fs.existsSync(checkDecisions)) {
351
+ execFileSync(process.execPath, [checkDecisions, "gen-index"], { cwd: ROOT, stdio: "inherit" });
352
+ }
353
+
354
+ const anyChange =
355
+ bannerChanged.length > 0 ||
356
+ priorIndex !== adrIndexContent ||
357
+ stubResults.some((r) => r.changed);
358
+ console.log(
359
+ anyChange ? "freeze-adrs: changes written." : "freeze-adrs: no-op (already frozen and current)."
360
+ );
361
+ return 0;
362
+ }
363
+
364
+ process.exit(main());
@@ -131,6 +131,18 @@ function checkProtectedPathPattern(filePath) {
131
131
  };
132
132
  }
133
133
 
134
+ // .kontourai/flow-agents/current/<actor>.json (#291) — the per-actor projection of the same
135
+ // pointer above. An agent could forge active_flow_id/active_step_id here exactly as it could
136
+ // via the legacy global file, so this is protected identically (same reason text).
137
+ // SAFE: the workflow CLI writes this via writePerActorCurrent → fs.writeFileSync,
138
+ // NOT via the Write/Edit tool — blocking the tool path does not break legit sidecar.
139
+ if (/(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$/.test(norm)) {
140
+ return {
141
+ name: '.kontourai/flow-agents/current/<actor>.json',
142
+ reason: 'an agent could forge active_flow_id/active_step_id to route the gate to a permissive FlowDefinition',
143
+ };
144
+ }
145
+
134
146
  // .kontourai/flow-agents/.goal-fit-block-streak.json controls soft-block
135
147
  // release counting. An agent could force early advisory-gate release by
136
148
  // writing a high count.
@@ -403,12 +415,13 @@ function checkCommandForBypass(command) {
403
415
  * Regex that matches a normalized (forward-slash) path ending with a protected
404
416
  * kill-switch file: shell profiles, .claude/settings*.json,
405
417
  * .kontourai/flow-agents/current.json,
418
+ * .kontourai/flow-agents/current/<actor>.json (#291 per-actor projection),
406
419
  * .kontourai/flow-agents/.goal-fit-block-streak.json,
407
420
  * .kontourai/flow-agents/<slug>/state.json,
408
421
  * .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
409
422
  * .flow-agents equivalents.
410
423
  */
411
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
424
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
412
425
 
413
426
  /**
414
427
  * Return true when a token (an unquoted redirect target or tee argument) matches