@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.
- package/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +143 -0
- package/build/src/cli/assignment-provider.js +106 -49
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +392 -35
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/contracts/assignment-provider-contract.md +9 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +19 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +4 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +4 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +17 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/package.json +4 -2
- package/scripts/README.md +1 -0
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +137 -55
- package/src/cli/workflow-sidecar.ts +430 -33
- package/src/lib/flow-resolver.ts +35 -14
- 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.
|
|
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
|