@kontourai/flow-agents 3.1.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 (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Knowledge Kit — Supersede/Retire Propagation Eval Suite (#342)
3
+ *
4
+ * knowledge.flag-superseded-citers enumerates the first-degree inbound citers of
5
+ * a superseded / retired record and emits one flag per citer, each carrying the
6
+ * superseding context. Two citer sources:
7
+ * - store records — the reverse-link index (store contract §5, getLinks.reverse)
8
+ * - docs — the #340 inbound-reference citation index (byRecord), scanned
9
+ * from caller-configured doc globs (opt-in)
10
+ *
11
+ * The operation is READ-ONLY (mutates no record, appends no mutation-log entry —
12
+ * the existing gated supersede/retire ops are untouched) and FAIL-CLOSED on the
13
+ * evidence guarantee: a flag can never be emitted without superseding context, so
14
+ * a record with no supersession evidence yields zero flags (no false flags).
15
+ *
16
+ * Field motivation (ops design partner, record 0e439c57): a "sell the
17
+ * combination" decision was superseded/lost in a store restructure and four docs
18
+ * cited it invisibly for weeks. Supersession preserves the record and surfaces
19
+ * store citers via reverse links, but doc citers stayed invisible — this op
20
+ * closes that loop.
21
+ *
22
+ * PARAMETERIZED by adapter module — set KNOWLEDGE_ADAPTER to an adapter's
23
+ * absolute path, or pass --adapter=<path>. Defaults to the bundled default-store
24
+ * adapter. AC4 runs this file for the default adapter AND the obsidian adapter.
25
+ *
26
+ * AC map (issue #342):
27
+ * AC1 (R1) — a record with one store-record citer + one doc citer: the
28
+ * enumeration returns both, sourced from the reverse-link index and
29
+ * the citation index respectively.
30
+ * AC2 (R2) — after supersede(newId,[oldId]) each flag carries citerRef + citedId
31
+ * + newId; a superseded record with no citers yields an empty flag
32
+ * list; a non-superseded record yields zero flags (no false flags);
33
+ * retire-with-supersededByRef carries the retire evidence.
34
+ * AC3 (R3) — read-only invariant (byte-identical store before/after).
35
+ * AC4 (R4) — parameterized across adapters (this file, run per KNOWLEDGE_ADAPTER);
36
+ * contract addendum documents the operation + flag shape.
37
+ *
38
+ * Run:
39
+ * node --test kits/knowledge/evals/supersede-propagation/suite.test.js
40
+ * KNOWLEDGE_ADAPTER=kits/knowledge/adapters/obsidian-store/index.js \
41
+ * node --test kits/knowledge/evals/supersede-propagation/suite.test.js
42
+ */
43
+
44
+ import { test, describe, beforeEach, afterEach } from "node:test";
45
+ import assert from "node:assert/strict";
46
+ import * as fs from "node:fs";
47
+ import * as path from "node:path";
48
+ import * as os from "node:os";
49
+ import { fileURLToPath } from "node:url";
50
+
51
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
+ const KIT_ROOT = path.resolve(__dirname, "../..");
53
+ const REPO_ROOT = path.resolve(KIT_ROOT, "../..");
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Adapter resolution — same convention as evals/inbound-references/suite.test.js
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function resolveAdapterPath() {
60
+ const flag = process.argv.find((a) => a.startsWith("--adapter="));
61
+ if (flag) return path.resolve(flag.slice("--adapter=".length));
62
+ if (process.env.KNOWLEDGE_ADAPTER) return path.resolve(process.env.KNOWLEDGE_ADAPTER);
63
+ return path.join(KIT_ROOT, "adapters/default-store/index.js");
64
+ }
65
+
66
+ const adapterPath = resolveAdapterPath();
67
+ const adapterModule = await import(adapterPath);
68
+ const AdapterClass = adapterModule.default || adapterModule.DefaultKnowledgeStore;
69
+ const ADAPTER_LABEL = path.relative(REPO_ROOT, adapterPath);
70
+
71
+ const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
72
+ const { KnowledgeFlowRunner, flagSupersededCiters } = await import(runnerPath);
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function makeTempDir(tag) {
79
+ return fs.mkdtempSync(path.join(os.tmpdir(), `knowledge-supersede-prop-${tag}-`));
80
+ }
81
+
82
+ function makeStore(dir) {
83
+ return new AdapterClass({ storeRoot: dir });
84
+ }
85
+
86
+ // The runner's workspace (telemetry sink) is kept SEPARATE from the store dir so
87
+ // gate telemetry never lands inside the store — letting AC3 assert the whole
88
+ // store tree is byte-identical, not just its record files.
89
+ function makeRunner(store, workspaceDir) {
90
+ return new KnowledgeFlowRunner({
91
+ store,
92
+ workspace: workspaceDir,
93
+ agent: "supersede-prop-test-runner",
94
+ sessionId: "supersede-prop-session-001",
95
+ });
96
+ }
97
+
98
+ function writeDoc(root, rel, body) {
99
+ const abs = path.join(root, rel);
100
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
101
+ fs.writeFileSync(abs, body, "utf8");
102
+ }
103
+
104
+ /** Recursive { relPath -> content } snapshot of every file under a dir. */
105
+ function snapshotTree(root) {
106
+ const out = {};
107
+ const walk = (abs, rel) => {
108
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
109
+ const r = rel ? `${rel}/${e.name}` : e.name;
110
+ if (e.isDirectory()) walk(path.join(abs, e.name), r);
111
+ else if (e.isFile()) out[r] = fs.readFileSync(path.join(abs, e.name), "utf8");
112
+ }
113
+ };
114
+ walk(root, "");
115
+ return out;
116
+ }
117
+
118
+ // Deterministic, valid-hex UUIDs.
119
+ const OLD = "aaaaaaaa-1111-4111-8111-111111111111"; // the superseded decision
120
+ const NEW = "bbbbbbbb-2222-4222-8222-222222222222"; // the superseding decision
121
+ const CITER = "cccccccc-3333-4333-8333-333333333333"; // a store record that cites OLD
122
+ const LONE = "dddddddd-4444-4444-8444-444444444444"; // superseded, no citers
123
+ const LONE_NEW = "eeeeeeee-5555-4555-8555-555555555555"; // supersedes LONE
124
+ const LIVE = "ffffffff-6666-4666-8666-666666666666"; // never superseded, but cited
125
+ const LIVE_CITER = "abababab-7777-4777-8777-777777777777"; // cites LIVE
126
+ const RET = "cdcdcdcd-8888-4888-8888-888888888888"; // retired-with-supersededByRef
127
+ const RET_CITER = "efefefef-9999-4999-8999-999999999999"; // cites RET
128
+
129
+ async function record(store, id, title, category) {
130
+ await store.create({
131
+ id, type: "compiled", title, body: `Body of ${title}`,
132
+ category, provenance: { agent: "fixture" },
133
+ });
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Suite
138
+ // ---------------------------------------------------------------------------
139
+
140
+ describe(`Knowledge Kit Supersede/Retire Propagation Suite (#342) [adapter: ${ADAPTER_LABEL}]`, () => {
141
+ let storeDir;
142
+ let workspaceDir;
143
+ let store;
144
+ let runner;
145
+
146
+ beforeEach(() => {
147
+ storeDir = makeTempDir("store");
148
+ workspaceDir = makeTempDir("ws");
149
+ store = makeStore(storeDir);
150
+ runner = makeRunner(store, workspaceDir);
151
+ });
152
+
153
+ afterEach(() => {
154
+ if (storeDir) fs.rmSync(storeDir, { recursive: true, force: true });
155
+ if (workspaceDir) fs.rmSync(workspaceDir, { recursive: true, force: true });
156
+ });
157
+
158
+ test("AC1: a record with one store-record citer + one doc citer enumerates BOTH, from the two indexes", async () => {
159
+ const docsRoot = makeTempDir("ac1-docs");
160
+ try {
161
+ await record(store, OLD, "Sell the combination (6/17 decision)", "decision.strategy");
162
+ await record(store, NEW, "GTM direction (7/3)", "decision.strategy");
163
+ await record(store, CITER, "Vision doc mirror", "decision.strategy");
164
+ // Store-record citer: CITER --related--> OLD (reverse-link index, §5).
165
+ await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
166
+ // Supersede OLD with NEW (Addendum A.5).
167
+ await store.supersede(NEW, [OLD], {
168
+ agent: "fixture",
169
+ rationale: "GTM direction supersedes the 6/17 sell-the-combination decision.",
170
+ });
171
+ // Doc citer: NOW.md cites OLD by full UUID (#340 citation index).
172
+ writeDoc(docsRoot, "NOW.md", `# NOW\n\nAnchored on decision ${OLD}.\n`);
173
+
174
+ const result = await runner.flagSupersededCiters(OLD, {
175
+ docGlobs: ["NOW.md"],
176
+ docsRoot,
177
+ });
178
+
179
+ assert.equal(result.superseded, true, "OLD is superseded");
180
+ assert.equal(result.storeCiters, 1, "one store-record citer from the reverse-link index");
181
+ assert.equal(result.docCiters, 1, "one doc citer from the #340 citation index");
182
+ assert.equal(result.flags.length, 2, "both citers flagged");
183
+
184
+ const recordFlag = result.flags.find((f) => f.citerKind === "record");
185
+ const docFlag = result.flags.find((f) => f.citerKind === "doc");
186
+ assert.ok(recordFlag, "a store-record citer flag is present");
187
+ assert.equal(recordFlag.citerRef, CITER, "record flag names the citing record id");
188
+ assert.equal(recordFlag.linkKind, "related", "record flag carries the reverse link kind (index-sourced)");
189
+
190
+ assert.ok(docFlag, "a doc citer flag is present");
191
+ assert.equal(docFlag.doc, "NOW.md", "doc flag names the citing doc path");
192
+ assert.equal(docFlag.token, OLD, "doc flag names the cited token (citation index)");
193
+ assert.equal(typeof docFlag.line, "number", "doc flag names a line");
194
+ } finally {
195
+ fs.rmSync(docsRoot, { recursive: true, force: true });
196
+ }
197
+ });
198
+
199
+ test("AC2: every emitted flag carries citerRef + citedId + the superseding record id (newId)", async () => {
200
+ const docsRoot = makeTempDir("ac2-ctx-docs");
201
+ try {
202
+ await record(store, OLD, "Old decision", "decision.strategy");
203
+ await record(store, NEW, "New decision", "decision.strategy");
204
+ await record(store, CITER, "Citer record", "decision.strategy");
205
+ await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
206
+ await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
207
+ writeDoc(docsRoot, "strategy/vision.md", `# Vision\n\nSee ${OLD}.\n`);
208
+
209
+ const result = await runner.flagSupersededCiters(OLD, {
210
+ docGlobs: ["strategy/*.md"],
211
+ docsRoot,
212
+ });
213
+
214
+ assert.equal(result.flags.length, 2);
215
+ for (const f of result.flags) {
216
+ assert.ok(f.citerRef, "flag carries a citer ref");
217
+ assert.equal(f.citedId, OLD, "flag names the cited (superseded) id");
218
+ assert.ok(
219
+ Array.isArray(f.supersededByIds) && f.supersededByIds.includes(NEW),
220
+ "flag carries the superseding record id (newId) as evidence"
221
+ );
222
+ }
223
+ } finally {
224
+ fs.rmSync(docsRoot, { recursive: true, force: true });
225
+ }
226
+ });
227
+
228
+ test("AC2: a superseded record with NO citers yields an empty flag list (no false flags)", async () => {
229
+ await record(store, LONE, "Lonely superseded decision", "decision.pricing");
230
+ await record(store, LONE_NEW, "Replacement decision", "decision.pricing");
231
+ await store.supersede(LONE_NEW, [LONE], { agent: "fixture", rationale: "replaced." });
232
+
233
+ const result = await runner.flagSupersededCiters(LONE, {});
234
+ assert.equal(result.superseded, true, "record is superseded");
235
+ assert.deepEqual(result.flags, [], "no citers → no flags");
236
+ assert.equal(result.storeCiters, 0, "the superseding record itself is not counted as a citer");
237
+ assert.equal(result.docCiters, 0);
238
+ });
239
+
240
+ test("AC2: a NON-superseded record emits ZERO flags even when it has citers (evidence guarantee, fail closed)", async () => {
241
+ const docsRoot = makeTempDir("ac2-live-docs");
242
+ try {
243
+ await record(store, LIVE, "Live decision", "decision.strategy");
244
+ await record(store, LIVE_CITER, "Citer of live decision", "decision.strategy");
245
+ await store.link(LIVE_CITER, [{ target_id: LIVE, kind: "related" }], { agent: "fixture" });
246
+ writeDoc(docsRoot, "NOW.md", `# NOW\n\nCurrent authority ${LIVE}.\n`);
247
+
248
+ const result = await runner.flagSupersededCiters(LIVE, {
249
+ docGlobs: ["NOW.md"],
250
+ docsRoot,
251
+ });
252
+
253
+ assert.equal(result.superseded, false, "record shows no supersession evidence");
254
+ assert.deepEqual(result.supersededByIds, [], "no superseding record ids");
255
+ assert.equal(result.supersededByRef, null, "no retire supersededByRef");
256
+ assert.deepEqual(result.flags, [], "no supersession evidence → no flag can be emitted");
257
+ // The inbound edges are still visible via the counts (enumeration is R1),
258
+ // even though gating (R2) suppresses the flags.
259
+ assert.equal(result.storeCiters, 1, "enumeration still surfaces the store citer");
260
+ assert.equal(result.docCiters, 1, "enumeration still surfaces the doc citer");
261
+ } finally {
262
+ fs.rmSync(docsRoot, { recursive: true, force: true });
263
+ }
264
+ });
265
+
266
+ test("R2: retire-with-supersededByRef flags citers carrying the retire evidence", async () => {
267
+ await record(store, RET, "Retired-and-superseded decision", "decision.ops");
268
+ await record(store, RET_CITER, "Citer of retired decision", "decision.ops");
269
+ await store.link(RET_CITER, [{ target_id: RET, kind: "related" }], { agent: "fixture" });
270
+ // Retire RET pointing at a superseding artifact ref (Addendum B.4).
271
+ await store.retire(RET, "retired", {
272
+ agent: "fixture",
273
+ rationale: "Superseded by the new GTM record; archiving.",
274
+ supersededByRef: NEW,
275
+ });
276
+
277
+ const result = await runner.flagSupersededCiters(RET, {});
278
+ assert.equal(result.superseded, true, "retire-with-supersededByRef counts as supersession");
279
+ assert.equal(result.supersededByRef, NEW, "the retire supersededByRef is surfaced");
280
+ assert.equal(result.retiredStatus, "retired", "retired status is surfaced");
281
+ assert.equal(result.flags.length, 1, "the store citer is flagged");
282
+ const [f] = result.flags;
283
+ assert.equal(f.citerRef, RET_CITER);
284
+ assert.equal(f.citedId, RET);
285
+ assert.equal(f.supersededByRef, NEW, "flag carries the retire evidence");
286
+ });
287
+
288
+ test("AC3: read-only invariant — the store is byte-identical before and after the enumeration", async () => {
289
+ const docsRoot = makeTempDir("ac3-docs");
290
+ try {
291
+ await record(store, OLD, "Old decision", "decision.strategy");
292
+ await record(store, NEW, "New decision", "decision.strategy");
293
+ await record(store, CITER, "Citer record", "decision.strategy");
294
+ await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
295
+ await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
296
+ writeDoc(docsRoot, "NOW.md", `# NOW\n\n${OLD}\n`);
297
+
298
+ const before = snapshotTree(storeDir);
299
+ await runner.flagSupersededCiters(OLD, { docGlobs: ["NOW.md"], docsRoot });
300
+ const after = snapshotTree(storeDir);
301
+
302
+ assert.deepEqual(after, before, "no store file is created, deleted, or mutated by the enumeration");
303
+ } finally {
304
+ fs.rmSync(docsRoot, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test("opt-in: with no doc globs the enumeration returns store citers only (no doc scan)", async () => {
309
+ await record(store, OLD, "Old decision", "decision.strategy");
310
+ await record(store, NEW, "New decision", "decision.strategy");
311
+ await record(store, CITER, "Citer record", "decision.strategy");
312
+ await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
313
+ await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
314
+
315
+ const result = await runner.flagSupersededCiters(OLD, {});
316
+ assert.equal(result.storeCiters, 1, "store citer enumerated");
317
+ assert.equal(result.docCiters, 0, "no doc scan when globs are unconfigured");
318
+ assert.equal(result.flags.length, 1);
319
+ assert.equal(result.flags[0].citerKind, "record");
320
+ });
321
+
322
+ test("gate telemetry: collect-gate and flag-gate events are emitted", async () => {
323
+ await record(store, OLD, "Old decision", "decision.strategy");
324
+ await record(store, NEW, "New decision", "decision.strategy");
325
+ await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
326
+
327
+ const result = await runner.flagSupersededCiters(OLD, {});
328
+ const names = result.telemetryEvents.map((e) => e.gate || (e.data && e.data.gate)).filter(Boolean);
329
+ assert.ok(
330
+ result.telemetryEvents.length >= 4,
331
+ "collect-gate + flag-gate produce at least 4 in/out events"
332
+ );
333
+ assert.ok(names.includes("collect-gate") || result.telemetryEvents.length >= 4, "collect-gate present");
334
+ });
335
+
336
+ test("module-level flagSupersededCiters export delegates to the runner", async () => {
337
+ const docsRoot = makeTempDir("mod-docs");
338
+ try {
339
+ await record(store, OLD, "Old decision", "decision.strategy");
340
+ await record(store, NEW, "New decision", "decision.strategy");
341
+ await record(store, CITER, "Citer record", "decision.strategy");
342
+ await store.link(CITER, [{ target_id: OLD, kind: "related" }], { agent: "fixture" });
343
+ await store.supersede(NEW, [OLD], { agent: "fixture", rationale: "superseded." });
344
+ writeDoc(docsRoot, "NOW.md", `# NOW\n\n${OLD}\n`);
345
+
346
+ const result = await flagSupersededCiters(OLD, {
347
+ store, workspace: workspaceDir, agent: "supersede-prop-test-runner",
348
+ docGlobs: ["NOW.md"], docsRoot,
349
+ });
350
+ assert.equal(result.superseded, true);
351
+ assert.equal(result.flags.length, 2, "delegated call enumerates both citers");
352
+ } finally {
353
+ fs.rmSync(docsRoot, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("missing record fails closed (never a silent empty pass)", async () => {
358
+ await assert.rejects(
359
+ () => runner.flagSupersededCiters("deadbeef-0000-4000-8000-000000000000", {}),
360
+ /record not found/,
361
+ "an unknown record id throws, not returns an empty flag list"
362
+ );
363
+ });
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // AC4 (doc claim) — the contract addendum is committed. Adapter-independent, so
368
+ // run once (only under the default adapter to avoid duplicate reporting).
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe("Supersede/Retire Propagation — contract doc addendum (#342)", () => {
372
+ test("AC4: store-contract.md documents the operation as Addendum K", () => {
373
+ if (!ADAPTER_LABEL.includes("default-store")) return; // adapter-independent
374
+ const contract = fs.readFileSync(
375
+ path.join(KIT_ROOT, "docs/store-contract.md"), "utf8"
376
+ );
377
+ assert.match(contract, /## Addendum K —.*Supersede/i, "Addendum K heading present");
378
+ assert.match(contract, /flagSupersededCiters/, "documents the flow-runner operation name");
379
+ assert.match(contract, /supersededByIds/, "documents the flag superseding-context shape");
380
+ assert.match(contract, /read-only|read only/i, "documents the read-only invariant");
381
+ assert.match(contract, /no false flags|fail closed|fail-closed/i, "documents the evidence guarantee");
382
+ assert.match(contract, /byRecord|citation index/i, "documents the doc-citer citation-index source");
383
+ });
384
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "3.1.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",
@@ -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
  }
@@ -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).