@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
@@ -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.2.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
  }
@@ -47,6 +47,12 @@
47
47
  "items": {
48
48
  "type": "string"
49
49
  }
50
+ },
51
+ "provider_release_pending": {
52
+ "type": "boolean"
53
+ },
54
+ "provider_release_next_command": {
55
+ "type": "string"
50
56
  }
51
57
  }
52
58
  }
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). |
@@ -104,23 +104,50 @@ function loadCiResults(repoRoot) {
104
104
  };
105
105
  }
106
106
 
107
+ /**
108
+ * Discover the delivery trust.bundle across the flat and per-session (#379) layouts.
109
+ * Prefers the flat `delivery/trust.bundle` (back-compat), then `delivery/<slug>/trust.bundle`
110
+ * for each immediate subdirectory (sorted). Returns the first existing path, or null.
111
+ * This mirrors scripts/ci/trust-reconcile.js resolveDeliveryCandidates() precedence so the
112
+ * attestation subject binds to the same bundle the reconciler read. best-effort — a missing
113
+ * or unreadable delivery/ yields null.
114
+ */
115
+ function discoverDeliveryBundle(repoRoot) {
116
+ const deliveryRoot = path.join(repoRoot, 'delivery');
117
+ const flat = path.join(deliveryRoot, 'trust.bundle');
118
+ if (fs.existsSync(flat)) return flat;
119
+ let entries = [];
120
+ try {
121
+ entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
122
+ } catch {
123
+ return null;
124
+ }
125
+ const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
126
+ for (const name of subdirs) {
127
+ const candidate = path.join(deliveryRoot, name, 'trust.bundle');
128
+ if (fs.existsSync(candidate)) return candidate;
129
+ }
130
+ return null;
131
+ }
132
+
107
133
  /**
108
134
  * Determine the subject artifact for the in-toto statement.
109
135
  *
110
- * If delivery/trust.bundle is present, use it as the subject (sha256 of its bytes).
111
- * Otherwise synthesize a results artifact from the predicate and use that.
136
+ * If a delivery trust.bundle is present (flat or per-session, #379), use it as the subject
137
+ * (sha256 of its bytes). Otherwise synthesize a results artifact from the predicate.
112
138
  *
113
139
  * @param {object} predicate — the predicate that will appear in the statement
114
140
  * @param {string} repoRoot
115
141
  * @returns {{ subjectName: string, subjectDigest: string, fromBundle: boolean }}
116
142
  */
117
143
  function resolveSubject(predicate, repoRoot) {
118
- const bundlePath = path.join(repoRoot, 'delivery', 'trust.bundle');
119
- if (fs.existsSync(bundlePath)) {
144
+ const bundlePath = discoverDeliveryBundle(repoRoot);
145
+ if (bundlePath) {
120
146
  const bundleBytes = fs.readFileSync(bundlePath);
121
147
  const digest = sha256hex(bundleBytes);
122
- process.stdout.write(`[mint-attestation] subject: delivery/trust.bundle sha256=${digest.slice(0, 16)}...\n`);
123
- return { subjectName: 'delivery/trust.bundle', subjectDigest: digest, fromBundle: true };
148
+ const rel = path.relative(repoRoot, bundlePath);
149
+ process.stdout.write(`[mint-attestation] subject: ${rel} sha256=${digest.slice(0, 16)}...\n`);
150
+ return { subjectName: rel, subjectDigest: digest, fromBundle: true };
124
151
  }
125
152
 
126
153
  // No bundle present: synthesize subject from predicate JSON (self-attesting the results).
@@ -522,32 +522,139 @@ const DELIVERY_DIR = 'delivery';
522
522
 
523
523
  /**
524
524
  * Single seam: every delivery/ path resolution in this file routes through here.
525
- * #335 (per-session delivery paths, e.g. delivery/<session-slug>/<filename> instead of
526
- * one shared path) is expected to change ONLY this function's body — e.g. glob
527
- * delivery/<slug-glob>/<filename> and return every match in a defined precedence order,
528
- * or
529
- * resolve the current PR's session slug and return its single subpath. discoverBundle()
530
- * and discoverDeclaredMarker() both iterate whatever candidates this returns and pick the
531
- * first existing one, so a future per-session layout composes with zero call-site changes.
525
+ *
526
+ * #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
527
+ * `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
528
+ * deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
529
+ * the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
530
+ * The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
531
+ * contend.
532
+ *
533
+ * Returned candidate order (precedence, first is highest):
534
+ * 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
535
+ * adopts per-session layout sees byte-identical behavior; an already-committed flat
536
+ * bundle from before this change still resolves.
537
+ * 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
538
+ * by name for determinism.
539
+ *
540
+ * Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
541
+ * decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
542
+ * order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
543
+ * of THIS change wins, and stale siblings from other concurrent sessions are ignored.
544
+ * discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
545
+ * per-session layout composes with zero call-site changes.
532
546
  */
533
547
  function resolveDeliveryCandidates(repoRoot, filename) {
534
- return [path.join(repoRoot, DELIVERY_DIR, filename)];
548
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
549
+ const candidates = [path.join(deliveryRoot, filename)];
550
+ // #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
551
+ // readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
552
+ // flat candidate, never throws.
553
+ let entries = [];
554
+ try {
555
+ entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
556
+ } catch {
557
+ entries = [];
558
+ }
559
+ const subdirs = entries
560
+ .filter((e) => e.isDirectory())
561
+ .map((e) => e.name)
562
+ .sort();
563
+ for (const name of subdirs) {
564
+ candidates.push(path.join(deliveryRoot, name, filename));
565
+ }
566
+ return candidates;
535
567
  }
536
568
 
537
569
  /**
538
570
  * Auto-discover the delivery bundle when no explicit path is provided.
539
- * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
540
- * (via resolveDeliveryCandidates()). Returns null if neither is present — the caller
541
- * then falls through to delivery/DECLARED marker resolution (ADR 0022 §1); bundle
542
- * absence is no longer fail-open on its own.
571
+ *
572
+ * Gathers every existing candidate flat `delivery/trust.bundle`, flat
573
+ * `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
574
+ * resolveDeliveryCandidates()) with trust.bundle preferred over trust.checkpoint.json.
575
+ *
576
+ * #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
577
+ * whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
578
+ * bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
579
+ * siblings from OTHER concurrent sessions are ignored.
580
+ *
581
+ * PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
582
+ * change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
583
+ * merge-commit repo (it was committed on the trunk's linear history before this branch
584
+ * point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
585
+ * commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
586
+ * bundle purely because it sorts first — reconciling last delivery's claims against this
587
+ * change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
588
+ * (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
589
+ * commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
590
+ * makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
591
+ * fresh per-session bundle wins on recency, not on being deleted first.
592
+ *
593
+ * When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
594
+ * TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
595
+ * gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
596
+ * one still raises the Step 2 read error.
597
+ *
598
+ * Returns null if no candidate exists at all — the caller then falls through to
599
+ * delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
543
600
  */
544
- function discoverBundle(repoRoot) {
601
+ function discoverBundle(repoRoot, changeSha) {
602
+ const candidates = [];
545
603
  for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
546
604
  for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
547
- if (fs.existsSync(candidate)) return candidate;
605
+ if (fs.existsSync(candidate)) candidates.push(candidate);
548
606
  }
549
607
  }
550
- return null;
608
+ if (candidates.length === 0) return null;
609
+
610
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
611
+ const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
612
+
613
+ // Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
614
+ // candidate that fails to parse is skipped here (never treated as owning) — if it turns
615
+ // out to be the only candidate, it is still returned below so Step 2's "failed to read
616
+ // bundle" diagnostic fires unchanged.
617
+ if (changeSha) {
618
+ const owning = []; // { candidate, bundleSha } — every candidate that attests this change
619
+ for (const candidate of candidates) {
620
+ let json = null;
621
+ try {
622
+ json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
623
+ } catch {
624
+ json = null;
625
+ }
626
+ if (!json) continue;
627
+ const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
628
+ if (fresh) owning.push({ candidate, bundleSha });
629
+ }
630
+ if (owning.length > 0) {
631
+ // Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
632
+ // upgraded to a candidate whose commit is a strict descendant of the current best's —
633
+ // i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
634
+ // (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
635
+ // keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
636
+ let best = owning[0];
637
+ for (let i = 1; i < owning.length; i++) {
638
+ const cand = owning[i];
639
+ if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
640
+ best = cand;
641
+ }
642
+ }
643
+ if (hasPerSession) {
644
+ process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
645
+ }
646
+ return best.candidate;
647
+ }
648
+ // No candidate attests this change. When per-session candidates were in play, emit a
649
+ // loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
650
+ // per-session collision apart from a plain stale/absent bundle — the per-candidate
651
+ // stale line still prints in runTrustReconcile's staleness gate for the returned one.
652
+ if (hasPerSession) {
653
+ const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
654
+ process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
655
+ }
656
+ }
657
+ return candidates[0];
551
658
  }
552
659
 
553
660
  /**
@@ -813,22 +920,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
813
920
  * src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
814
921
  * (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
815
922
  * by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
816
- * sibling delivery/trust.checkpoint.json (same resolveDeliveryCandidates() seam) for the
817
- * binding publishDelivery() always copies both together.
923
+ * SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
924
+ * together into the SAME directory.
925
+ *
926
+ * #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
927
+ * not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
928
+ * gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
929
+ * per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
930
+ * reading the wrong commit binding. Same-directory resolution is identical to the prior
931
+ * behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
932
+ * correct for per-session.
818
933
  */
819
934
  function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
820
935
  if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
821
936
  return bundleJson.commit_sha.trim();
822
937
  }
823
- for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
824
- if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
938
+ const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
939
+ if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
825
940
  try {
826
- const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
941
+ const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
827
942
  if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
828
943
  return checkpoint.commit_sha.trim();
829
944
  }
830
945
  } catch {
831
- // Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
946
+ // Malformed sibling checkpoint — no usable binding from it; fall through to null.
832
947
  }
833
948
  }
834
949
  return null;
@@ -907,14 +1022,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
907
1022
  // (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
908
1023
  // auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
909
1024
  const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
910
- let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
911
- const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
912
1025
 
913
- // Scope-matching context (ref/actor/sha) — resolved once, reused by both the
914
- // bundle-ownership staleness check below and the delivery/DECLARED exemption path
915
- // further down (bundle-absent branch).
1026
+ // Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
1027
+ // auto-discovery (#379, immediately below), the bundle-ownership staleness check further
1028
+ // down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
1029
+ // branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
1030
+ // to pick, among per-session candidates, the one that attests THIS change.
916
1031
  const scopeCtx = resolveScopeContext(resolvedRepoRoot);
917
1032
 
1033
+ let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
1034
+ const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
1035
+
918
1036
  // Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
919
1037
  // (and the staleness-gate consequence of it, immediately below) applies ONLY when this
920
1038
  // run is gating a proposed change. A push run on main happens AFTER the gating PR run