@kontourai/flow-agents 3.6.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 (86) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +308 -47
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +67 -13
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +801 -97
  11. package/build/src/cli/workflow.js +290 -42
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/generate-context-map.js +5 -3
  18. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  19. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  20. package/docs/context-map.md +22 -20
  21. package/docs/developer-architecture.md +1 -1
  22. package/docs/public-workflow-cli.md +76 -7
  23. package/docs/skills-map.md +51 -27
  24. package/docs/spec/builder-flow-runtime.md +41 -40
  25. package/docs/workflow-usage-guide.md +109 -42
  26. package/evals/fixtures/hook-influence/cases.json +2 -2
  27. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  28. package/evals/integration/test_builder_step_producers.sh +297 -6
  29. package/evals/integration/test_bundle_install.sh +212 -63
  30. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  31. package/evals/integration/test_current_json_per_actor.sh +12 -0
  32. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  33. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  34. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  35. package/evals/integration/test_gate_lockdown.sh +3 -5
  36. package/evals/integration/test_goal_fit_hook.sh +60 -2
  37. package/evals/integration/test_liveness_verdict.sh +14 -17
  38. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  39. package/evals/integration/test_public_workflow_cli.sh +325 -11
  40. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  41. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  42. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  43. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  44. package/evals/run.sh +2 -0
  45. package/evals/static/test_builder_skill_coherence.sh +247 -0
  46. package/evals/static/test_library_exports.sh +5 -2
  47. package/evals/static/test_workflow_skills.sh +13 -325
  48. package/kits/builder/flows/build.flow.json +22 -0
  49. package/kits/builder/flows/shape.flow.json +9 -9
  50. package/kits/builder/kit.json +70 -16
  51. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  52. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  53. package/kits/builder/skills/deliver/SKILL.md +96 -442
  54. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  55. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  56. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  57. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  58. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  59. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  60. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  61. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  62. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  63. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  64. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  65. package/kits/builder/skills/review-work/SKILL.md +89 -176
  66. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  67. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  68. package/package.json +2 -2
  69. package/scripts/hooks/lib/kit-catalog.js +1 -1
  70. package/scripts/hooks/stop-goal-fit.js +78 -9
  71. package/src/builder-flow-run-adapter.ts +118 -32
  72. package/src/builder-flow-runtime.ts +331 -61
  73. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  74. package/src/cli/assignment-provider.ts +62 -13
  75. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  76. package/src/cli/builder-flow-runtime.test.mjs +194 -8
  77. package/src/cli/builder-run.ts +3 -9
  78. package/src/cli/kit-metadata-security.test.mjs +232 -6
  79. package/src/cli/public-api.test.mjs +15 -0
  80. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  81. package/src/cli/workflow-sidecar.ts +724 -97
  82. package/src/cli/workflow.ts +277 -40
  83. package/src/flow-kit/validate.ts +320 -2
  84. package/src/index.ts +6 -5
  85. package/src/lib/observed-command.ts +96 -0
  86. package/src/tools/generate-context-map.ts +5 -3
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { createRequire } from "node:module";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
@@ -309,18 +310,28 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
309
310
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
310
311
  }
311
312
 
313
+ // Lock age is adjudicated by the current contender, never by metadata written
314
+ // by the lock owner. The environment is only an operator tuning input; clamp it
315
+ // so a caller cannot turn a transient owner-file write into immediate takeover.
316
+ const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
317
+ const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
318
+ const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
319
+
320
+ function trustedSubjectLockStaleMs(): number {
321
+ const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
322
+ if (!Number.isFinite(configured)) return SUBJECT_LOCK_STALE_DEFAULT_MS;
323
+ return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
324
+ }
325
+
312
326
  /**
313
327
  * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
314
328
  * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
315
329
  * could both read "no conflicting claim" before either wrote, and the second write would silently
316
330
  * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
317
- * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
318
- * workflow-sidecar.ts:908 for the same class of shared-state mutation atomic `fs.mkdirSync`
319
- * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
320
- * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
321
- * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
322
- * as a small LOCAL helper (not a cross-import of that private function, which would pull the
323
- * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
331
+ * races against the built CLI). Atomic directory creation establishes ownership before metadata
332
+ * is written; contenders treat even an ownerless directory as held. Live contention waits with a
333
+ * bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
334
+ * filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
324
335
  * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
325
336
  * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
326
337
  * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
@@ -331,22 +342,31 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
331
342
  */
332
343
  export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
333
344
  const lockDir = subjectLockDir(artifactRoot, subjectId);
334
- const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
345
+ const staleMs = trustedSubjectLockStaleMs();
346
+ const token = randomBytes(16).toString("hex");
347
+ const ownerFile = path.join(lockDir, "owner.json");
335
348
  const deadline = Date.now() + 30000;
336
349
  while (true) {
350
+ let createdLockDir = false;
337
351
  try {
338
352
  fs.mkdirSync(lockDir);
353
+ createdLockDir = true;
354
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
339
355
  break;
340
356
  } catch (error) {
341
357
  const lockError = error as NodeJS.ErrnoException;
358
+ if (createdLockDir) fs.rmSync(lockDir, { recursive: true, force: true });
342
359
  if (lockError.code !== "EEXIST") {
343
360
  throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
344
361
  }
345
362
  try {
346
- const stat = fs.statSync(lockDir);
347
- if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
348
- fs.rmSync(lockDir, { recursive: true, force: true });
349
- continue;
363
+ const owner = readSubjectLockOwner(ownerFile);
364
+ const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
365
+ if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
366
+ throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
367
+ }
368
+ if (Date.now() - stat.mtimeMs > staleMs) {
369
+ throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
350
370
  }
351
371
  } catch (statError) {
352
372
  if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
@@ -358,7 +378,12 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
358
378
  sleepSync(20);
359
379
  }
360
380
  }
361
- const release = (): void => fs.rmSync(lockDir, { recursive: true, force: true });
381
+ let heartbeat: NodeJS.Timeout | undefined;
382
+ const ownsLock = (): boolean => readSubjectLockOwner(ownerFile)?.token === token;
383
+ const release = (): void => {
384
+ if (heartbeat) clearInterval(heartbeat);
385
+ if (ownsLock()) fs.rmSync(lockDir, { recursive: true, force: true });
386
+ };
362
387
  let result: T;
363
388
  try {
364
389
  result = body();
@@ -367,12 +392,36 @@ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body
367
392
  throw error;
368
393
  }
369
394
  if (result && typeof (result as { then?: unknown }).then === "function") {
395
+ // An async owner can legitimately hold the lock longer than the stale-lock
396
+ // threshold while an authority-bound command is running. Keep its mtime fresh
397
+ // so lifecycle operations and takeovers continue to observe the live lock.
398
+ const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
399
+ heartbeat = setInterval(() => {
400
+ try {
401
+ if (!ownsLock()) return;
402
+ const timestamp = new Date();
403
+ fs.utimesSync(ownerFile, timestamp, timestamp);
404
+ fs.utimesSync(lockDir, timestamp, timestamp);
405
+ } catch { /* release, reclamation, or process teardown owns cleanup */ }
406
+ }, heartbeatMs);
370
407
  return Promise.resolve(result).finally(release) as T;
371
408
  }
372
409
  release();
373
410
  return result;
374
411
  }
375
412
 
413
+ function readSubjectLockOwner(file: string): { token?: string } | null {
414
+ try {
415
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
416
+ return value && typeof value === "object" && !Array.isArray(value)
417
+ ? value as { token?: string }
418
+ : null;
419
+ } catch (error) {
420
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return null;
421
+ throw error;
422
+ }
423
+ }
424
+
376
425
  /**
377
426
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
378
427
  * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
@@ -17,7 +17,7 @@ import {
17
17
  BUILDER_BUILD_FLOW_ID,
18
18
  evaluateBuilderBuildRun,
19
19
  startBuilderBuildRun,
20
- } from "../../build/src/index.js";
20
+ } from "../../build/src/builder-flow-run-adapter.js";
21
21
 
22
22
  const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
23
23
  const BUILDER_BUILD_DEFINITION = path.join(REPO_ROOT, "kits/builder/flows/build.flow.json");
@@ -245,6 +245,8 @@ test("all verified parent selectors advance only their persisted gates through p
245
245
  subjectType: "flow-step",
246
246
  name: "verify-all-selectors",
247
247
  bundle: trustBundle({ claims: [
248
+ claim("workflow.critique.review", "workflow-critique"),
249
+ claim("workflow.acceptance.criterion", "flow-step"),
248
250
  claim("builder.verify.tests", "flow-step"),
249
251
  claim("builder.verify.policy-compliance", "artifact"),
250
252
  ] }),
@@ -271,7 +273,7 @@ test("all verified parent selectors advance only their persisted gates through p
271
273
  { gate_id: "design-probe-gate", status: "pass", matched_expectations: ["pickup-probe-readiness", "probe-decisions-or-accepted-gaps"] },
272
274
  { gate_id: "plan-gate", status: "pass", matched_expectations: ["implementation-plan"] },
273
275
  { gate_id: "execute-gate", status: "pass", matched_expectations: ["implementation-scope"] },
274
- { gate_id: "verify-gate", status: "pass", matched_expectations: ["tests-evidence", "policy-compliance"] },
276
+ { gate_id: "verify-gate", status: "pass", matched_expectations: ["clean-critique", "acceptance-criteria", "tests-evidence", "policy-compliance"] },
275
277
  { gate_id: "merge-ready-gate", status: "pass", matched_expectations: ["merge-readiness"] },
276
278
  ]);
277
279
  });
@@ -3,12 +3,13 @@ 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 { generateKeyPairSync, sign } from "node:crypto";
6
+ import { createHash, generateKeyPairSync, sign } from "node:crypto";
7
7
 
8
8
  import { FLOW_RUN_EVIDENCE_MANIFEST_PATH, runDir } from "@kontourai/flow";
9
9
  import {
10
10
  archiveBuilderFlowSession,
11
11
  cancelBuilderFlowSession,
12
+ captureReviewWorkspaceSnapshot,
12
13
  pauseBuilderFlowSession,
13
14
  recoverBuilderFlowSession,
14
15
  releaseBuilderFlowAssignment,
@@ -20,6 +21,7 @@ import { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization
20
21
  import { cancelBuilderBuildRun } from "../../build/src/builder-flow-run-adapter.js";
21
22
  import { performLocalClaim, readLocalAssignmentStatus, resolveCurrentAssignmentActor } from "../../build/src/cli/assignment-provider.js";
22
23
  import { main as builderRunMain } from "../../build/src/cli/builder-run.js";
24
+ import { inferExecutedTestCount } from "../../build/src/cli/workflow-sidecar.js";
23
25
 
24
26
  const SUBJECT = "local:work-item/runtime-projection";
25
27
  const NOW = "2026-07-09T20:00:00.000Z";
@@ -51,6 +53,9 @@ function makeSession(slug = "runtime-projection") {
51
53
  schema_version: "1.0",
52
54
  keys: [{ id: AUTHORITY_KEY_ID, algorithm: "ed25519", public_key_pem: AUTHORITY_KEYS.publicKey.export({ type: "spki", format: "pem" }) }],
53
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");
54
59
  return { projectRoot, artifactRoot, sessionDir, slug };
55
60
  }
56
61
 
@@ -59,6 +64,15 @@ function writeJson(file, value) {
59
64
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
60
65
  }
61
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
+
62
76
  function readJson(file) {
63
77
  return JSON.parse(fs.readFileSync(file, "utf8"));
64
78
  }
@@ -200,7 +214,7 @@ async function assertRecoveryRejectsWithoutWrites(session, pattern) {
200
214
  assert.deepEqual(snapshotProjectionTargets(session), beforeProjection);
201
215
  }
202
216
 
203
- 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 }) {
204
218
  const claimId = `claim.${expectation}`;
205
219
  return {
206
220
  claim: {
@@ -212,6 +226,11 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
212
226
  value: status,
213
227
  metadata: {
214
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
+ } : {}),
215
234
  gate_claim: {
216
235
  expectation_id: expectation,
217
236
  claim_type: claimType,
@@ -219,8 +238,8 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
219
238
  ...(routeReason ? { route_reason: routeReason } : {}),
220
239
  },
221
240
  },
222
- createdAt: NOW,
223
- updatedAt: NOW,
241
+ createdAt: timestamp,
242
+ updatedAt: timestamp,
224
243
  },
225
244
  evidence: {
226
245
  id: `evidence.${expectation}`,
@@ -229,7 +248,7 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
229
248
  method: "attestation",
230
249
  sourceRef: "src/cli/builder-flow-runtime.test.mjs",
231
250
  excerptOrSummary: `${expectation} fixture`,
232
- observedAt: NOW,
251
+ observedAt: timestamp,
233
252
  collectedBy: "flow-agents-test",
234
253
  },
235
254
  event: {
@@ -239,10 +258,47 @@ function bundleClaim({ expectation, claimType, subjectType, status = "pass", rou
239
258
  actor: "flow-agents-test",
240
259
  method: "attestation",
241
260
  evidenceIds: [`evidence.${expectation}`],
242
- createdAt: NOW,
243
- 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
+ },
244
293
  },
245
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];
246
302
  }
247
303
 
248
304
  function writeBundle(sessionDir, entries) {
@@ -291,6 +347,53 @@ test("small-model client can start and advance from projected actions without ch
291
347
  assert.equal(duplicate.run.manifest.evidence.length, advanced.run.manifest.evidence.length);
292
348
  });
293
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
+
294
397
  test("pause and resume preserve the current Flow step and active assignment", async () => {
295
398
  const session = makeSession("lifecycle-pause-resume");
296
399
  claimAmbientSessionAssignment(session);
@@ -546,6 +649,8 @@ test("builder-run exposes lifecycle actions without caller-selected Flow identit
546
649
  const ambient = resolveCurrentAssignmentActor();
547
650
  performLocalClaim(session.artifactRoot, session.slug, ambient.actor, { actorKey: ambient.actorKey, ttlSeconds: 1800, branch: `agent/${session.slug}`, artifactDir: session.sessionDir, workItemRef: SUBJECT, reason: "test" });
548
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);
549
654
  assert.equal(await builderRunMain([
550
655
  "pause", "--session-dir", session.sessionDir,
551
656
  "--reason", "cli pause",
@@ -644,6 +749,87 @@ test("failed verification projects Flow-owned route-back attempt and budget", as
644
749
  assert.equal(routed.projection.flow_run.route_back_max_attempts, 3);
645
750
  assert.match(routed.projection.next_action.summary, /Route-back history: attempt 1\/3 returned to `execute` for `implementation_defect`/);
646
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
+ );
647
833
  });
648
834
 
649
835
  test("verified sidecar claims drive the composed publish and learning prefix to completion", async () => {
@@ -658,7 +844,7 @@ test("verified sidecar claims drive the composed publish and learning prefix to
658
844
  ],
659
845
  [bundleClaim({ expectation: "implementation-plan", claimType: "builder.plan.implementation", subjectType: "artifact" })],
660
846
  [bundleClaim({ expectation: "implementation-scope", claimType: "builder.execute.scope", subjectType: "change" })],
661
- [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)],
662
848
  [bundleClaim({ expectation: "merge-readiness", claimType: "builder.merge-ready.readiness", subjectType: "change" })],
663
849
  ];
664
850
  for (const entries of steps) await writeAndSync(session, entries);
@@ -6,11 +6,9 @@ import {
6
6
  recoverBuilderFlowSession,
7
7
  releaseBuilderFlowAssignment,
8
8
  resumeBuilderFlowSession,
9
- startBuilderFlowSession,
10
- syncBuilderFlowSession,
11
9
  } from "../builder-flow-runtime.js";
12
10
 
13
- const USAGE = "Usage: flow-agents builder-run <start|sync|recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
11
+ const USAGE = "Usage: flow-agents builder-run <recover|pause|resume|cancel|release-assignment|archive> --session-dir <path> [--reason <text> | --authorization-file <path>]";
14
12
 
15
13
  export async function main(argv: string[]): Promise<number> {
16
14
  const parsed = parseArgs(argv);
@@ -29,7 +27,7 @@ export async function main(argv: string[]): Promise<number> {
29
27
  console.error("builder-run requires --session-dir .kontourai/flow-agents/<slug>");
30
28
  return 64;
31
29
  }
32
- if (!action || !["start", "sync", "recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
30
+ if (!action || !["recover", "pause", "resume", "cancel", "release-assignment", "archive"].includes(action)) {
33
31
  console.error(USAGE);
34
32
  return 64;
35
33
  }
@@ -53,11 +51,7 @@ export async function main(argv: string[]): Promise<number> {
53
51
  console.error(USAGE);
54
52
  return 64;
55
53
  }
56
- const result = action === "start"
57
- ? await startBuilderFlowSession({ sessionDir })
58
- : action === "sync"
59
- ? await syncBuilderFlowSession({ sessionDir })
60
- : action === "recover"
54
+ const result = action === "recover"
61
55
  ? await recoverBuilderFlowSession({ sessionDir })
62
56
  : action === "pause"
63
57
  ? await pauseBuilderFlowSession({ sessionDir, reason: reason! })