@kontourai/flow-agents 3.5.0 → 3.7.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 (111) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +32 -3
  4. package/build/src/builder-flow-run-adapter.js +113 -20
  5. package/build/src/builder-flow-runtime.d.ts +26 -3
  6. package/build/src/builder-flow-runtime.js +502 -49
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +14 -7
  10. package/build/src/cli/assignment-provider.js +128 -65
  11. package/build/src/cli/builder-run.js +46 -9
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +30 -3
  14. package/build/src/cli/workflow-sidecar.js +825 -99
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +769 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/flow-kit/validate.d.ts +17 -0
  19. package/build/src/flow-kit/validate.js +340 -2
  20. package/build/src/index.d.ts +4 -0
  21. package/build/src/index.js +7 -5
  22. package/build/src/lib/observed-command.d.ts +7 -0
  23. package/build/src/lib/observed-command.js +100 -0
  24. package/build/src/lib/package-version.d.ts +2 -0
  25. package/build/src/lib/package-version.js +13 -0
  26. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  27. package/build/src/lib/pinned-cli-command.js +21 -0
  28. package/build/src/tools/generate-context-map.js +5 -3
  29. package/context/contracts/artifact-contract.md +1 -1
  30. package/context/scripts/hooks/config-protection.js +8 -1
  31. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  32. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  33. package/context/scripts/hooks/stop-goal-fit.js +79 -10
  34. package/docs/context-map.md +24 -20
  35. package/docs/developer-architecture.md +1 -1
  36. package/docs/public-workflow-cli.md +132 -0
  37. package/docs/skills-map.md +51 -27
  38. package/docs/spec/builder-flow-runtime.md +78 -40
  39. package/docs/workflow-usage-guide.md +110 -38
  40. package/evals/ci/run-baseline.sh +2 -0
  41. package/evals/fixtures/hook-influence/cases.json +2 -2
  42. package/evals/integration/test_builder_entry_enforcement.sh +57 -45
  43. package/evals/integration/test_builder_step_producers.sh +297 -6
  44. package/evals/integration/test_bundle_install.sh +258 -55
  45. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  46. package/evals/integration/test_current_json_per_actor.sh +12 -0
  47. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  48. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  49. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  50. package/evals/integration/test_gate_lockdown.sh +3 -5
  51. package/evals/integration/test_goal_fit_hook.sh +60 -2
  52. package/evals/integration/test_liveness_verdict.sh +14 -17
  53. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  54. package/evals/integration/test_public_workflow_cli.sh +573 -0
  55. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  56. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  57. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  58. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  59. package/evals/run.sh +2 -0
  60. package/evals/static/test_builder_skill_coherence.sh +247 -0
  61. package/evals/static/test_library_exports.sh +5 -2
  62. package/evals/static/test_workflow_skills.sh +13 -325
  63. package/kits/builder/flows/build.flow.json +22 -0
  64. package/kits/builder/flows/shape.flow.json +9 -9
  65. package/kits/builder/kit.json +70 -16
  66. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  67. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  68. package/kits/builder/skills/deliver/SKILL.md +96 -442
  69. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  70. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  71. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  72. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  73. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  74. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  75. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  76. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  77. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  78. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  79. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  80. package/kits/builder/skills/review-work/SKILL.md +89 -176
  81. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  82. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  83. package/package.json +2 -2
  84. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  85. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  86. package/schemas/workflow-state.schema.json +1 -1
  87. package/scripts/hooks/config-protection.js +8 -1
  88. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  89. package/scripts/hooks/lib/kit-catalog.js +1 -1
  90. package/scripts/hooks/stop-goal-fit.js +79 -10
  91. package/src/builder-flow-run-adapter.ts +156 -23
  92. package/src/builder-flow-runtime.ts +535 -53
  93. package/src/builder-lifecycle-authority.ts +218 -0
  94. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  95. package/src/cli/assignment-provider.ts +91 -22
  96. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  97. package/src/cli/builder-flow-runtime.test.mjs +597 -8
  98. package/src/cli/builder-run.ts +54 -9
  99. package/src/cli/kit-metadata-security.test.mjs +232 -6
  100. package/src/cli/public-api.test.mjs +15 -0
  101. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  102. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  103. package/src/cli/workflow-sidecar.ts +748 -99
  104. package/src/cli/workflow.ts +708 -0
  105. package/src/cli.ts +2 -0
  106. package/src/flow-kit/validate.ts +320 -2
  107. package/src/index.ts +20 -5
  108. package/src/lib/observed-command.ts +96 -0
  109. package/src/lib/package-version.ts +15 -0
  110. package/src/lib/pinned-cli-command.ts +23 -0
  111. package/src/tools/generate-context-map.ts +5 -3
@@ -3,17 +3,33 @@ import assert from "node:assert/strict";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { createHash, generateKeyPairSync, sign } from "node:crypto";
6
7
 
7
8
  import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
8
9
  import {
10
+ archiveBuilderFlowSession,
11
+ cancelBuilderFlowSession,
12
+ captureReviewWorkspaceSnapshot,
13
+ pauseBuilderFlowSession,
9
14
  recoverBuilderFlowSession,
15
+ releaseBuilderFlowAssignment,
16
+ resumeBuilderFlowSession,
10
17
  startBuilderFlowSession,
11
18
  syncBuilderFlowSession,
12
19
  } from "../../build/src/builder-flow-runtime.js";
20
+ import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization, recordAuthorizationConsumed } from "../../build/src/builder-lifecycle-authority.js";
21
+ import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
22
+ import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
13
23
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
24
+ import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
14
25
 
15
26
  const SUBJECT = "local:work-item/runtime-projection";
16
27
  const NOW = "2026-07-09T20:00:00.000Z";
28
+ const PACKAGE_VERSION = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
29
+ const ACTOR = { runtime: "codex", session_id: "runtime-projection", host: "test-host", human: null };
30
+ const ACTOR_KEY = "codex:runtime-projection:test-host";
31
+ const AUTHORITY_KEY_ID = "runtime-test";
32
+ const AUTHORITY_KEYS = generateKeyPairSync("ed25519");
17
33
 
18
34
  function makeSession(slug = "runtime-projection") {
19
35
  const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-builder-runtime-"));
@@ -33,6 +49,13 @@ function makeSession(slug = "runtime-projection") {
33
49
  artifact_dir: `.kontourai/flow-agents/${slug}`,
34
50
  updated_at: NOW,
35
51
  });
52
+ writeJson(path.join(projectRoot, ".flow-agents", "lifecycle-authority-keys.json"), {
53
+ schema_version: "1.0",
54
+ keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
55
+ });
56
+ fs.mkdirSync(path.join(projectRoot, "review-target"), { recursive: true });
57
+ fs.writeFileSync(path.join(projectRoot, "review-target", "implementation.txt"), "reviewed implementation\n");
58
+ fs.writeFileSync(path.join(projectRoot, "review-target", "delivery.md"), "reviewed delivery artifact\n");
36
59
  return { projectRoot, artifactRoot, sessionDir, slug };
37
60
  }
38
61
 
@@ -41,10 +64,117 @@ function writeJson(file, value) {
41
64
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
42
65
  }
43
66
 
67
+ test("shell output cannot spoof an executed-test count", () => {
68
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-test-count-"));
69
+ fs.mkdirSync(path.join(root, "checks"), { recursive: true });
70
+ fs.writeFileSync(path.join(root, "checks", "fake-test.sh"), "#!/bin/sh\nset -e\nprintf '1 passed\\n'\n");
71
+ fs.writeFileSync(path.join(root, "checks", "real-test.sh"), "#!/bin/sh\nset -e\ntest -f checks/real-test.sh\n");
72
+ assert.equal(inferExecutedTestCount("sh checks/fake-test.sh", root, "1 passed\n"), 0);
73
+ assert.equal(inferExecutedTestCount("sh checks/real-test.sh", root, "1..1\nok 1 - file exists\n"), 1);
74
+ });
75
+
44
76
  function readJson(file) {
45
77
  return JSON.parse(fs.readFileSync(file, "utf8"));
46
78
  }
47
79
 
80
+ function consumedAuthorizationRecords(session) {
81
+ const directory = path.join(session.artifactRoot, "lifecycle-authority", "consumed");
82
+ if (!fs.existsSync(directory)) return [];
83
+ return fs.readdirSync(directory).sort().map((name) => readJson(path.join(directory, name)));
84
+ }
85
+
86
+ function claimSessionAssignment(session) {
87
+ performLocalClaim(session.artifactRoot, session.slug, ACTOR, {
88
+ ttlSeconds: 1800,
89
+ actorKey: ACTOR_KEY,
90
+ branch: `agent/${session.slug}`,
91
+ artifactDir: session.sessionDir,
92
+ workItemRef: SUBJECT,
93
+ reason: "test",
94
+ });
95
+ }
96
+
97
+ function claimAmbientSessionAssignment(session) {
98
+ const ambient = resolveCurrentAssignmentActor();
99
+ performLocalClaim(session.artifactRoot, session.slug, ambient.actor, {
100
+ ttlSeconds: 1800,
101
+ actorKey: ambient.actorKey,
102
+ branch: `agent/${session.slug}`,
103
+ artifactDir: session.sessionDir,
104
+ workItemRef: SUBJECT,
105
+ reason: "test",
106
+ });
107
+ return ambient;
108
+ }
109
+
110
+ function lifecycleAuthorization(session, operation, name, overrides = {}) {
111
+ const file = path.join(session.projectRoot, `${name}.authorization.json`);
112
+ const unsigned = {
113
+ schema_version: "1.0",
114
+ operation,
115
+ run_id: session.slug,
116
+ subject: SUBJECT,
117
+ assignment_actor_key: ACTOR_KEY,
118
+ assignment_actor: ACTOR,
119
+ nonce: `${session.slug}:${name}`,
120
+ expires_at: "2026-07-09T21:00:00.000Z",
121
+ request: {
122
+ reason: `${name} requested by fixture`,
123
+ authority: {
124
+ kind: "user_request",
125
+ actor: "fixture-user",
126
+ request_ref: `fixture://request/${name}`,
127
+ requested_at: NOW,
128
+ },
129
+ },
130
+ ...overrides,
131
+ };
132
+ const value = {
133
+ ...unsigned,
134
+ signature: {
135
+ algorithm: "ed25519",
136
+ key_id: AUTHORITY_KEY_ID,
137
+ value: sign(null, Buffer.from(builderLifecycleAuthorizationPayload(unsigned)), AUTHORITY_KEYS.privateKey).toString("base64"),
138
+ },
139
+ };
140
+ writeJson(file, value);
141
+ return file;
142
+ }
143
+
144
+ function liveLifecycleAuthorization(session, operation, name, overrides = {}) {
145
+ const requestedAt = new Date();
146
+ return lifecycleAuthorization(session, operation, name, {
147
+ expires_at: new Date(requestedAt.getTime() + 60 * 60_000).toISOString(),
148
+ request: {
149
+ reason: `${name} requested by fixture`,
150
+ authority: {
151
+ kind: "user_request",
152
+ actor: "fixture-user",
153
+ request_ref: `fixture://request/${name}`,
154
+ requested_at: requestedAt.toISOString(),
155
+ },
156
+ },
157
+ ...overrides,
158
+ });
159
+ }
160
+
161
+ function expiredLifecycleAuthorization(session, operation, name, overrides = {}) {
162
+ const requestedAt = new Date(Date.now() - 120_000);
163
+ return lifecycleAuthorization(session, operation, name, {
164
+ expires_at: new Date(Date.now() - 60_000).toISOString(),
165
+ request: {
166
+ reason: `${name} requested by fixture`,
167
+ authority: {
168
+ kind: "user_request",
169
+ actor: "fixture-user",
170
+ request_ref: `fixture://request/${name}`,
171
+ requested_at: requestedAt.toISOString(),
172
+ },
173
+ },
174
+ ...overrides,
175
+ });
176
+ }
177
+
48
178
  function snapshotFile(file) {
49
179
  return fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null;
50
180
  }
@@ -84,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
84
214
  assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
85
215
  }
86
216
 
87
- function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT }) {
217
+ function bundleClaim({ expectation, claimType, subjectType, status = "pass", routeReason, subject = SUBJECT, testCount = 1, timestamp = NOW }) {
88
218
  const claimId = `claim.${expectation}`;
89
219
  return {
90
220
  claim: {
@@ -96,6 +226,11 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
96
226
  value: status,
97
227
  metadata: {
98
228
  workflow_subject_ref: subject,
229
+ ...(expectation === "tests-evidence" ? {
230
+ recorded_by: "implementation-actor",
231
+ artifact_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }],
232
+ observed_commands: [{ command: "node --test src/cli/builder-flow-runtime.test.mjs", exit_code: 0, test_count: testCount, output_sha256: "0".repeat(64) }],
233
+ } : {}),
99
234
  gate_claim: {
100
235
  expectation_id: expectation,
101
236
  claim_type: claimType,
@@ -103,8 +238,8 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
103
238
  ...(routeReason ? { route_reason: routeReason } : {}),
104
239
  },
105
240
  },
106
- createdAt: NOW,
107
- updatedAt: NOW,
241
+ createdAt: timestamp,
242
+ updatedAt: timestamp,
108
243
  },
109
244
  evidence: {
110
245
  id: `evidence.${expectation}`,
@@ -113,7 +248,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
113
248
  method: "attestation",
114
249
  sourceRef: "src/cli/builder-flow-runtime.test.mjs",
115
250
  excerptOrSummary: `${expectation} fixture`,
116
- observedAt: NOW,
251
+ observedAt: timestamp,
117
252
  collectedBy: "flow-agents-test",
118
253
  },
119
254
  event: {
@@ -123,10 +258,47 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
123
258
  actor: "flow-agents-test",
124
259
  method: "attestation",
125
260
  evidenceIds: [`evidence.${expectation}`],
126
- createdAt: NOW,
127
- verifiedAt: NOW,
261
+ createdAt: timestamp,
262
+ verifiedAt: timestamp,
263
+ },
264
+ };
265
+ }
266
+
267
+ function verifiedTestsPrerequisites(session, timestamp = NOW) {
268
+ const reviewArtifact = path.join(session.projectRoot, "review-target", "delivery.md");
269
+ const implementation = path.join(session.projectRoot, "review-target", "implementation.txt");
270
+ const implementationFile = path.relative(session.projectRoot, implementation);
271
+ const implementationBytes = fs.readFileSync(implementation);
272
+ const implementationSha256 = createHash("sha256").update(implementationBytes).digest("hex");
273
+ const workspaceDigest = createHash("sha256")
274
+ .update("flow-agents:reviewed-files:v1\0")
275
+ .update(implementationFile).update("\0").update(implementationBytes).update("\0")
276
+ .digest("hex");
277
+ const critique = bundleClaim({ expectation: "clean-critique", claimType: "workflow.critique.review", subjectType: "workflow-critique", timestamp });
278
+ critique.claim.metadata = {
279
+ workflow_subject_ref: SUBJECT,
280
+ origin: "critique",
281
+ reviewer: "reviewer-actor",
282
+ findings: [],
283
+ lanes: [{ id: "code", status: "pass" }],
284
+ review_target: {
285
+ artifacts: [{ file: path.relative(session.projectRoot, reviewArtifact), sha256: createHash("sha256").update(fs.readFileSync(reviewArtifact)).digest("hex") }],
286
+ workspace_snapshot: {
287
+ version: 1,
288
+ kind: "reviewed-files",
289
+ algorithm: "sha256",
290
+ digest: workspaceDigest,
291
+ files: [{ file: implementationFile, sha256: implementationSha256 }],
292
+ },
128
293
  },
129
294
  };
295
+ const criterion = bundleClaim({ expectation: "verified-criterion", claimType: "workflow.acceptance.criterion", subjectType: "flow-step", timestamp });
296
+ criterion.claim.metadata = {
297
+ workflow_subject_ref: SUBJECT,
298
+ origin: "acceptance",
299
+ criterion: { id: "ac-runtime", status: "pass", evidence_refs: [{ kind: "command", excerpt: "node --test src/cli/builder-flow-runtime.test.mjs", summary: "Runtime fixture assertion." }] },
300
+ };
301
+ return [critique, criterion];
130
302
  }
131
303
 
132
304
  function writeBundle(sessionDir, entries) {
@@ -152,7 +324,10 @@ test("small-model client can start and advance from projected actions without ch
152
324
  assert.equal(started.run.state.current_step, "pull-work");
153
325
  assert.deepEqual(started.projection.next_action.skills, ["pull-work"]);
154
326
  assert.deepEqual(started.projection.next_action.operations, []);
155
- assert.equal(started.projection.next_action.command, `flow-agents builder-run sync --session-dir .kontourai/flow-agents/${session.slug}`);
327
+ assert.match(started.projection.next_action.command, /^sh -c /);
328
+ assert.match(started.projection.next_action.command, /--prefix "\$root"/);
329
+ assert.ok(started.projection.next_action.command.includes(`'@kontourai/flow-agents@${PACKAGE_VERSION}'`));
330
+ assert.ok(started.projection.next_action.command.includes(`'workflow' 'status' '--session-dir' '.kontourai/flow-agents/${session.slug}' '--json'`));
156
331
  assert.ok(fs.existsSync(runDir(session.slug, session.projectRoot)));
157
332
  assert.ok(!fs.existsSync(path.join(session.projectRoot, ".flow", "runs")), "retired runtime path must not be created");
158
333
 
@@ -172,6 +347,331 @@ test("small-model client can start and advance from projected actions without ch
172
347
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
173
348
  });
174
349
 
350
+ test("sync attaches the staged trust.bundle bytes when the session bundle is replaced", async () => {
351
+ const session = makeSession("snapshot-attachment");
352
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
353
+ const originalEntries = [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })];
354
+ writeBundle(session.sessionDir, originalEntries);
355
+ const bundleFile = path.join(session.sessionDir, "trust.bundle");
356
+ const originalDigest = createHash("sha256").update(fs.readFileSync(bundleFile)).digest("hex");
357
+ let replaceBundle;
358
+ const replaced = new Promise((resolve) => { replaceBundle = resolve; });
359
+ const watcher = fs.watch(session.sessionDir, (_event, filename) => {
360
+ if (String(filename) !== ".trust-bundle-snapshots") return;
361
+ fs.writeFileSync(bundleFile, "{\"claims\":[]}");
362
+ replaceBundle();
363
+ });
364
+ try {
365
+ const syncing = syncBuilderFlowSession({ sessionDir: session.sessionDir });
366
+ await Promise.race([
367
+ replaced,
368
+ new Promise((_, reject) => setTimeout(() => reject(new Error("trust.bundle snapshot was not staged")), 2_000)),
369
+ ]);
370
+ const synced = await syncing;
371
+ assert.equal(synced.attached, true);
372
+ } finally {
373
+ watcher.close();
374
+ }
375
+ const manifest = readJson(path.join(runDir(session.slug, session.projectRoot), FLOW_RUN_EVIDENCE_MANIFEST_PATH));
376
+ assert.equal(manifest.evidence.at(-1).sha256, originalDigest);
377
+ assert.equal(fs.existsSync(path.join(session.sessionDir, ".trust-bundle-snapshots")), false);
378
+ });
379
+
380
+ test("start rejects a requested Builder flow that differs from the existing run before projection mutation", async (t) => {
381
+ for (const [existingFlowId, requestedFlowId] of [["builder.shape", "builder.build"], ["builder.build", "builder.shape"]]) {
382
+ await t.test(`${existingFlowId} cannot resume as ${requestedFlowId}`, async () => {
383
+ const session = makeSession(`flow-mismatch-${existingFlowId.replace('.', '-')}`);
384
+ await startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: existingFlowId });
385
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
386
+ const beforeProjection = snapshotProjectionTargets(session);
387
+ await assert.rejects(
388
+ () => startBuilderFlowSession({ sessionDir: session.sessionDir, flowId: requestedFlowId }),
389
+ new RegExp(`requested ${requestedFlowId} does not match the existing ${existingFlowId} run`),
390
+ );
391
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
392
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
393
+ });
394
+ }
395
+ });
396
+
397
+ test("pause and resume preserve the current Flow step and active assignment", async () => {
398
+ const session = makeSession("lifecycle-pause-resume");
399
+ claimAmbientSessionAssignment(session);
400
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
401
+ const before = readJson(path.join(runDir(session.slug, session.projectRoot), "state.json"));
402
+
403
+ const paused = await pauseBuilderFlowSession({
404
+ sessionDir: session.sessionDir,
405
+ reason: "fixture pause",
406
+ });
407
+ assert.equal(paused.run.state.status, "paused");
408
+ assert.equal(paused.run.state.current_step, before.current_step);
409
+ assert.deepEqual(paused.run.state.transitions, before.transitions);
410
+ assert.equal(paused.projection.status, "blocked");
411
+ assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
412
+
413
+ const resumed = await resumeBuilderFlowSession({
414
+ sessionDir: session.sessionDir,
415
+ reason: "fixture resume",
416
+ });
417
+ assert.equal(resumed.run.state.status, "active");
418
+ assert.equal(resumed.run.state.current_step, before.current_step);
419
+ assert.deepEqual(resumed.run.state.transitions, before.transitions);
420
+ assert.equal(readLocalAssignmentStatus(session.artifactRoot, session.slug).record.status, "claimed");
421
+ });
422
+
423
+ test("authorized cancellation is canonical, terminal, and releases the assignment exactly once", async () => {
424
+ const session = makeSession("lifecycle-cancel");
425
+ claimSessionAssignment(session);
426
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
427
+ const authorizationFile = liveLifecycleAuthorization(session, "cancel", "cancel");
428
+
429
+ const canceled = await cancelBuilderFlowSession({
430
+ sessionDir: session.sessionDir,
431
+ authorizationFile,
432
+ });
433
+ assert.equal(canceled.run.state.status, "canceled");
434
+ assert.equal(canceled.projection.status, "canceled");
435
+ assert.equal(canceled.assignmentReleased, true);
436
+ assert.equal(canceled.idempotent, false);
437
+ const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
438
+ assert.equal(readJson(assignmentFile).status, "released");
439
+ const firstAudit = readJson(assignmentFile).audit_trail;
440
+
441
+ await assert.rejects(() => cancelBuilderFlowSession({
442
+ sessionDir: session.sessionDir,
443
+ authorizationFile,
444
+ }), /nonce has already been consumed/);
445
+ assert.deepEqual(readJson(assignmentFile).audit_trail, firstAudit);
446
+ });
447
+
448
+ test("assignment release is independent of Flow lifecycle", async () => {
449
+ const session = makeSession("lifecycle-release-assignment");
450
+ claimAmbientSessionAssignment(session);
451
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
452
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
453
+
454
+ const released = await releaseBuilderFlowAssignment({
455
+ sessionDir: session.sessionDir,
456
+ reason: "fixture assignment release",
457
+ });
458
+
459
+ assert.equal(released.assignmentReleased, true);
460
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
461
+ assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "released");
462
+ });
463
+
464
+ test("archive rejects active runs and retains canceled Flow and session artifacts", async () => {
465
+ const session = makeSession("lifecycle-archive");
466
+ claimSessionAssignment(session);
467
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
468
+ const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-cancel");
469
+ const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive");
470
+ const beforeReject = snapshotTree(session.sessionDir);
471
+ await assert.rejects(() => archiveBuilderFlowSession({
472
+ sessionDir: session.sessionDir,
473
+ authorizationFile,
474
+ }), /must be completed or canceled/);
475
+ assert.deepEqual(snapshotTree(session.sessionDir), beforeReject);
476
+
477
+ await cancelBuilderFlowSession({
478
+ sessionDir: session.sessionDir,
479
+ authorizationFile: cancelAuthorization,
480
+ });
481
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
482
+ const beforeSession = snapshotTree(session.sessionDir);
483
+ const archived = await archiveBuilderFlowSession({
484
+ sessionDir: session.sessionDir,
485
+ authorizationFile,
486
+ });
487
+
488
+ assert.equal(archived.archiveDir, path.join(session.artifactRoot, "archive", session.slug));
489
+ assert.equal(fs.existsSync(session.sessionDir), false);
490
+ assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
491
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
492
+ const archivedFiles = snapshotTree(archived.archiveDir).map(([name]) => name);
493
+ for (const [name] of beforeSession) assert.ok(archivedFiles.includes(name), `archive retained ${name}`);
494
+ assert.deepEqual(consumedAuthorizationRecords(session).map((record) => record.operation).sort(), ["archive", "cancel"]);
495
+ });
496
+
497
+ test("archive rejects a symlinked archive root without moving the session", async () => {
498
+ const session = makeSession("lifecycle-archive-symlink");
499
+ claimSessionAssignment(session);
500
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
501
+ const cancelAuthorization = liveLifecycleAuthorization(session, "cancel", "archive-symlink-cancel");
502
+ const authorizationFile = liveLifecycleAuthorization(session, "archive", "archive-symlink");
503
+ await cancelBuilderFlowSession({
504
+ sessionDir: session.sessionDir,
505
+ authorizationFile: cancelAuthorization,
506
+ });
507
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-archive-outside-"));
508
+ fs.symlinkSync(outside, path.join(session.artifactRoot, "archive"), "dir");
509
+ const beforeSession = snapshotTree(session.sessionDir);
510
+
511
+ await assert.rejects(() => archiveBuilderFlowSession({
512
+ sessionDir: session.sessionDir,
513
+ authorizationFile,
514
+ }), /archive root.*symbolic link/);
515
+
516
+ assert.deepEqual(snapshotTree(session.sessionDir), beforeSession);
517
+ assert.deepEqual(fs.readdirSync(outside), []);
518
+ });
519
+
520
+ test("archive retries the exact consumed authorization after an interrupted prepared move", async () => {
521
+ const session = makeSession("lifecycle-archive-recovery");
522
+ claimSessionAssignment(session);
523
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
524
+ await cancelBuilderFlowSession({
525
+ sessionDir: session.sessionDir,
526
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "archive-recovery-cancel"),
527
+ });
528
+ const authorizationFile = expiredLifecycleAuthorization(session, "archive", "archive-recovery");
529
+ const rawAuthorization = readJson(authorizationFile);
530
+ const authorization = loadBuilderLifecycleAuthorization(authorizationFile, {
531
+ projectRoot: session.projectRoot,
532
+ operation: "archive",
533
+ runId: session.slug,
534
+ subject: SUBJECT,
535
+ actorKey: ACTOR_KEY,
536
+ now: new Date(Date.parse(rawAuthorization.request.authority.requested_at) + 30_000).toISOString(),
537
+ });
538
+ const preparedState = readJson(path.join(session.sessionDir, "state.json"));
539
+ preparedState.status = "archived";
540
+ preparedState.phase = "done";
541
+ preparedState.next_action = { status: "done", summary: "Builder session archived; canonical Flow artifacts remain retained." };
542
+ writeJson(path.join(session.sessionDir, "state.json"), preparedState);
543
+ recordAuthorizationConsumed(session.artifactRoot, authorization);
544
+
545
+ const conflictingAuthorization = liveLifecycleAuthorization(session, "archive", "archive-recovery-conflict", {
546
+ nonce: authorization.nonce,
547
+ });
548
+ await assert.rejects(
549
+ () => archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile: conflictingAuthorization }),
550
+ /does not match its integrity key/,
551
+ );
552
+ assert.equal(fs.existsSync(session.sessionDir), true);
553
+
554
+ const recovered = await archiveBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
555
+ assert.equal(fs.existsSync(session.sessionDir), false);
556
+ assert.equal(readJson(path.join(recovered.archiveDir, "state.json")).status, "archived");
557
+ assert.equal(consumedAuthorizationRecords(session).length, 2);
558
+ });
559
+
560
+ test("mismatched and expired cancellation authority fail before Flow or sidecar mutation", async (t) => {
561
+ for (const [name, overrides, pattern] of [
562
+ ["wrong-run", { run_id: "another-run" }, /run_id does not match/],
563
+ ["wrong-subject", { subject: "local:other" }, /subject does not match/],
564
+ ["wrong-actor", { assignment_actor_key: "another-actor" }, /assignment_actor_key does not match/],
565
+ ["wrong-actor-struct", { assignment_actor: { ...ACTOR, session_id: "another-session" } }, /assignment_actor.*active assignment holder/],
566
+ ["wrong-operation", { operation: "archive" }, /operation does not match/],
567
+ ["expired", {}, /expired/],
568
+ ]) {
569
+ await t.test(name, async () => {
570
+ const session = makeSession(`lifecycle-reject-${name}`);
571
+ claimSessionAssignment(session);
572
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
573
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
574
+ const beforeProjection = snapshotProjectionTargets(session);
575
+ const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
576
+ await assert.rejects(() => cancelBuilderFlowSession({
577
+ sessionDir: session.sessionDir,
578
+ authorizationFile: name === "expired"
579
+ ? expiredLifecycleAuthorization(session, "cancel", name, overrides)
580
+ : liveLifecycleAuthorization(session, "cancel", name, overrides),
581
+ }), pattern);
582
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
583
+ assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
584
+ assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
585
+ });
586
+ }
587
+ });
588
+
589
+ test("conflicting cancellation request replay is rejected without a second transition or release", async () => {
590
+ const session = makeSession("lifecycle-conflicting-replay");
591
+ claimSessionAssignment(session);
592
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
593
+ await cancelBuilderFlowSession({
594
+ sessionDir: session.sessionDir,
595
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-original"),
596
+ });
597
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
598
+ const assignmentFile = path.join(session.artifactRoot, "assignment", `${session.slug}.json`);
599
+ const beforeAssignment = snapshotFile(assignmentFile);
600
+ await assert.rejects(() => cancelBuilderFlowSession({
601
+ sessionDir: session.sessionDir,
602
+ authorizationFile: liveLifecycleAuthorization(session, "cancel", "cancel-conflict"),
603
+ }), /does not match the canonical cancellation/);
604
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
605
+ assert.equal(snapshotFile(assignmentFile), beforeAssignment);
606
+ });
607
+
608
+ test("tampered or unsigned cancellation authority fails before mutation", async () => {
609
+ const session = makeSession("lifecycle-signature-reject");
610
+ claimSessionAssignment(session);
611
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
612
+ const authorizationFile = liveLifecycleAuthorization(session, "cancel", "signature-reject");
613
+ const tampered = readJson(authorizationFile);
614
+ tampered.request.reason = "agent-authored replacement";
615
+ writeJson(authorizationFile, tampered);
616
+ const beforeFlow = snapshotTree(runDir(session.slug, session.projectRoot));
617
+ const beforeAssignment = snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`));
618
+
619
+ await assert.rejects(() => cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile }), /signature is invalid/);
620
+ assert.deepEqual(snapshotTree(runDir(session.slug, session.projectRoot)), beforeFlow);
621
+ assert.equal(snapshotFile(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)), beforeAssignment);
622
+ });
623
+
624
+ test("expired authority can finish side effects for its matching canonical cancellation", async () => {
625
+ const session = makeSession("lifecycle-cancel-recovery");
626
+ claimSessionAssignment(session);
627
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
628
+ const authorizationFile = expiredLifecycleAuthorization(session, "cancel", "cancel-recovery");
629
+ const authorization = readJson(authorizationFile);
630
+ await cancelBuilderBuildRun({
631
+ cwd: session.projectRoot,
632
+ runId: session.slug,
633
+ request: authorization.request,
634
+ at: new Date(Date.parse(authorization.expires_at) - 1_000).toISOString(),
635
+ });
636
+ assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
637
+ assert.equal(readJson(path.join(session.artifactRoot, "assignment", `${session.slug}.json`)).status, "claimed");
638
+ assert.deepEqual(consumedAuthorizationRecords(session), []);
639
+
640
+ const recovered = await cancelBuilderFlowSession({ sessionDir: session.sessionDir, authorizationFile });
641
+ assert.equal(recovered.idempotent, true);
642
+ assert.equal(recovered.assignmentReleased, true);
643
+ assert.equal(recovered.projection.status, "canceled");
644
+ assert.equal(consumedAuthorizationRecords(session).length, 1);
645
+ });
646
+
647
+ test("builder-run exposes lifecycle actions without caller-selected Flow identity", async () => {
648
+ const session = makeSession("lifecycle-cli");
649
+ const ambient = resolveCurrentAssignmentActor();
650
+ performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
651
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
652
+ assert.equal(await builderRunMain(["start", "--session-dir", session.sessionDir]), 64);
653
+ assert.equal(await builderRunMain(["sync", "--session-dir", session.sessionDir]), 64);
654
+ assert.equal(await builderRunMain([
655
+ "pause", "--session-dir", session.sessionDir,
656
+ "--reason", "cli pause",
657
+ ]), 0);
658
+ assert.equal(await builderRunMain([
659
+ "resume", "--session-dir", session.sessionDir,
660
+ "--reason", "cli resume",
661
+ ]), 0);
662
+ const cliCancel = liveLifecycleAuthorization(session, "cancel", "cli-cancel", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor });
663
+ assert.equal(await builderRunMain([
664
+ "cancel", "--session-dir", session.sessionDir,
665
+ "--authorization-file", cliCancel,
666
+ ]), 0);
667
+ assert.equal(readJson(path.join(runDir(session.slug, session.projectRoot), "state.json")).status, "canceled");
668
+ assert.equal(await builderRunMain([
669
+ "cancel", "--session-dir", session.sessionDir,
670
+ "--authorization-file", liveLifecycleAuthorization(session, "cancel", "cli-clock-override", { assignment_actor_key: ambient.actorKey, assignment_actor: ambient.actor }),
671
+ "--now", "2020-01-01T00:00:00.000Z",
672
+ ]), 64);
673
+ });
674
+
175
675
  test("automatic start refuses a slug-bound run for another Work Item without mutation", async () => {
176
676
  const session = makeSession("start-subject-mismatch");
177
677
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
@@ -249,10 +749,92 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
249
749
  assert.equal(routed.projection.flow_run.route_back_max_attempts, 3);
250
750
  assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
251
751
  assert.deepEqual(routed.projection.next_action.skills, ["execute-plan"]);
752
+
753
+ const reentered = await writeAndSync(session, [bundleClaim({
754
+ expectation: "implementation-scope",
755
+ claimType: "builder.execute.scope",
756
+ subjectType: "change",
757
+ timestamp: new Date().toISOString(),
758
+ })]);
759
+ assert.equal(reentered.run.state.current_step, "verify");
760
+ const correctedAt = new Date(Date.parse(reentered.run.state.transitions.at(-1).at) + 1).toISOString();
761
+ const corrected = await writeAndSync(session, [
762
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", timestamp: correctedAt }),
763
+ ...verifiedTestsPrerequisites(session, correctedAt),
764
+ ]);
765
+ assert.equal(corrected.run.state.current_step, "merge-ready");
766
+ const verifyEvidence = corrected.run.manifest.evidence.filter((entry) => entry.gate_id === "verify-gate");
767
+ assert.equal(verifyEvidence.length, 2);
768
+ assert.equal(verifyEvidence[0].superseded_by, verifyEvidence[1].id);
769
+ });
770
+
771
+ test("passing tests-evidence rejects a critique whose reviewed artifact changed", async () => {
772
+ const session = makeSession("stale-critique-artifact");
773
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
774
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
775
+ await writeAndSync(session, [
776
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
777
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
778
+ ]);
779
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
780
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
781
+ const prerequisites = verifiedTestsPrerequisites(session);
782
+ fs.writeFileSync(path.join(session.projectRoot, "review-target", "delivery.md"), "changed after review\n");
783
+ await assert.rejects(
784
+ () => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
785
+ /review_target\.artifacts\.sha256.*does not match/,
786
+ );
787
+ });
788
+
789
+ test("passing tests-evidence rejects a successful command that executed zero tests", async () => {
790
+ const session = makeSession("zero-test-evidence");
791
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
792
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
793
+ await writeAndSync(session, [
794
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
795
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
796
+ ]);
797
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
798
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
799
+ await assert.rejects(
800
+ () => writeAndSync(session, [
801
+ bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step", testCount: 0 }),
802
+ ...verifiedTestsPrerequisites(session),
803
+ ]),
804
+ /positive executed-test count/,
805
+ );
806
+ });
807
+
808
+ test("passing tests-evidence rejects a critique after implementation source changed", async () => {
809
+ const session = makeSession("stale-critique-workspace");
810
+ await startBuilderFlowSession({ sessionDir: session.sessionDir });
811
+ await writeAndSync(session, [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })]);
812
+ await writeAndSync(session, [
813
+ bundleClaim({ expectation: "pickup-probe-readiness", claimType: "builder.design-probe.pickup-readiness", subjectType: "work-item" }),
814
+ bundleClaim({ expectation: "probe-decisions-or-accepted-gaps", claimType: "builder.design-probe.decisions", subjectType: "decision" }),
815
+ ]);
816
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })]);
817
+ await writeAndSync(session, [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })]);
818
+ const prerequisites = verifiedTestsPrerequisites(session);
819
+ fs.writeFileSync(path.join(session.projectRoot, "review-target", "implementation.txt"), "source changed after review\n");
820
+ await assert.rejects(
821
+ () => writeAndSync(session, [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...prerequisites]),
822
+ /review_target\.workspace_snapshot\.digest.*does not match/,
823
+ );
824
+ });
825
+
826
+ test("workspace review fails closed when a Git worktree marker cannot be inspected", () => {
827
+ const session = makeSession("git-inspection-unavailable");
828
+ fs.mkdirSync(path.join(session.projectRoot, ".git"));
829
+ assert.throws(
830
+ () => captureReviewWorkspaceSnapshot(session.projectRoot, [{ file: "review-target/delivery.md", sha256: createHash("sha256").update("reviewed delivery\n").digest("hex") }]),
831
+ /could not inspect the Git worktree/,
832
+ );
252
833
  });
253
834
 
254
835
  test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
255
836
  const session = makeSession("composed-completion");
837
+ claimSessionAssignment(session);
256
838
  await startBuilderFlowSession({ sessionDir: session.sessionDir });
257
839
  const steps = [
258
840
  [bundleClaim({ expectation: "selected-work", claimType: "builder.pull-work.selected", subjectType: "work-item" })],
@@ -262,7 +844,7 @@ test("verified sidecar claims drive the composed publish and learning prefix to
262
844
  ],
263
845
  [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
264
846
  [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
265
- [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" })],
847
+ [bundleClaim({ expectation: "tests-evidence", claimType: "builder.verify.tests", subjectType: "flow-step" }), ...verifiedTestsPrerequisites(session)],
266
848
  [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
267
849
  ];
268
850
  for (const entries of steps) await writeAndSync(session, entries);
@@ -297,6 +879,13 @@ test("verified sidecar claims drive the composed publish and learning prefix to
297
879
  assert.equal(recovered.projection.phase, "done");
298
880
  assert.deepEqual(recovered.projection.next_action, { status: "done", summary: "Canonical Flow run is complete." });
299
881
  assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
882
+
883
+ const archived = await archiveBuilderFlowSession({
884
+ sessionDir: session.sessionDir,
885
+ authorizationFile: liveLifecycleAuthorization(session, "archive", "completed-archive"),
886
+ });
887
+ assert.equal(readJson(path.join(archived.archiveDir, "state.json")).status, "archived");
888
+ assert.deepEqual(snapshotTree(flowDirectory), beforeFlow);
300
889
  });
301
890
 
302
891
  test("recovery loads the slug-bound run, restores every matching projection, and preserves every Flow byte", async () => {