@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,31 +1,38 @@
1
1
  import * as fs from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash, randomBytes } from "node:crypto";
2
4
  import * as path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { isDeepStrictEqual } from "node:util";
5
7
  import { flowAgentsPackageVersion } from "./lib/package-version.js";
6
8
  import { pinnedFlowAgentsCommand } from "./lib/pinned-cli-command.js";
7
- import { expectationsForGate, lifecycleRequestMatches, openGates, readJson, runDir, sha256File, } from "@kontourai/flow";
9
+ import { expectationsForGate, lifecycleRequestMatches, openGates, } from "@kontourai/flow";
8
10
  import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAuthorizationConsumption, recordAuthorizationConsumed } from "./builder-lifecycle-authority.js";
9
11
  import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock } from "./cli/assignment-provider.js";
10
- import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderBuildRun, evaluateBuilderBuildRun, loadBuilderBuildRun, pauseBuilderBuildRun, resumeBuilderBuildRun, startBuilderBuildRun, } from "./builder-flow-run-adapter.js";
12
+ import { BUILDER_BUILD_FLOW_ID, BuilderBuildRunInputError, cancelBuilderFlowRun, evaluateBuilderFlowRun, loadBuilderFlowRun, pauseBuilderFlowRun, resumeBuilderFlowRun, startBuilderFlowRun, } from "./builder-flow-run-adapter.js";
11
13
  export async function startBuilderFlowSession(input) {
12
14
  const context = resolveSessionContext(input.sessionDir);
13
15
  const sidecarSnapshot = readSidecarSnapshot(context);
14
16
  const subject = workflowSubject(sidecarSnapshot.state);
17
+ const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
15
18
  let run;
16
19
  try {
17
- run = await loadBuilderBuildRun({
20
+ run = await loadBuilderFlowRun({
18
21
  cwd: context.projectRoot,
19
22
  runId: context.slug,
20
23
  });
24
+ if (run.definitionId !== requestedFlowId) {
25
+ throw new BuilderBuildRunInputError("flowId", `requested ${requestedFlowId} does not match the existing ${run.definitionId} run; start builder.build from a provider Work Item instead of retrying a local shape session`);
26
+ }
21
27
  }
22
28
  catch (error) {
23
29
  if (!isRunNotFound(error))
24
30
  throw error;
25
- run = await startBuilderBuildRun({
31
+ run = await startBuilderFlowRun({
26
32
  cwd: context.projectRoot,
27
33
  runId: context.slug,
28
34
  subject,
35
+ flowId: requestedFlowId,
29
36
  params: {
30
37
  subject,
31
38
  },
@@ -38,7 +45,7 @@ export async function syncBuilderFlowSession(input) {
38
45
  const context = resolveSessionContext(input.sessionDir);
39
46
  const sidecarSnapshot = readSidecarSnapshot(context);
40
47
  const subject = workflowSubject(sidecarSnapshot.state);
41
- const run = await loadBuilderBuildRun({
48
+ const run = await loadBuilderFlowRun({
42
49
  cwd: context.projectRoot,
43
50
  runId: context.slug,
44
51
  });
@@ -49,7 +56,7 @@ export async function recoverBuilderFlowSession(input) {
49
56
  const context = resolveSessionContext(input.sessionDir);
50
57
  const sidecarSnapshot = readSidecarSnapshot(context);
51
58
  const subject = workflowSubject(sidecarSnapshot.state);
52
- const run = await loadBuilderBuildRun({
59
+ const run = await loadBuilderFlowRun({
53
60
  cwd: context.projectRoot,
54
61
  runId: context.slug,
55
62
  });
@@ -64,6 +71,20 @@ export async function recoverBuilderFlowSession(input) {
64
71
  attached: false,
65
72
  };
66
73
  }
74
+ export async function inspectBuilderFlowSession(input) {
75
+ const context = resolveSessionContext(input.sessionDir);
76
+ const sidecarSnapshot = readSidecarSnapshot(context);
77
+ const subject = workflowSubject(sidecarSnapshot.state);
78
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
79
+ assertRunSubjectBinding(run, subject);
80
+ return {
81
+ sessionDir: context.sessionDir,
82
+ projectRoot: context.projectRoot,
83
+ run,
84
+ projection: projectFlowRun(context, run, sidecarSnapshot.state),
85
+ attached: false,
86
+ };
87
+ }
67
88
  export async function pauseBuilderFlowSession(input) {
68
89
  return changeBuilderFlowSessionLifecycle(input, "pause");
69
90
  }
@@ -75,7 +96,7 @@ export async function cancelBuilderFlowSession(input) {
75
96
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
76
97
  const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
77
98
  assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
78
- const changed = await cancelBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
99
+ const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
79
100
  const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
80
101
  actorKey: prepared.authorization.assignment_actor_key,
81
102
  reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
@@ -91,7 +112,7 @@ export async function releaseBuilderFlowAssignment(input) {
91
112
  const context = resolveSessionContext(input.sessionDir);
92
113
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
93
114
  const prepared = prepareAgentLifecycleChange(input, context);
94
- const run = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
115
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
95
116
  const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
96
117
  return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
97
118
  });
@@ -104,7 +125,7 @@ export async function archiveBuilderFlowSession(input) {
104
125
  const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
105
126
  if (priorConsumption && !recoveringPreparedArchive)
106
127
  throw new Error("lifecycle authorization nonce has already been consumed");
107
- const run = await loadBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
128
+ const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
108
129
  if (run.state.status !== "completed" && run.state.status !== "canceled") {
109
130
  throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
110
131
  }
@@ -145,7 +166,7 @@ async function changeBuilderFlowSessionLifecycle(input, operation) {
145
166
  const context = resolveSessionContext(input.sessionDir);
146
167
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
147
168
  const prepared = prepareAgentLifecycleChange(input, context);
148
- const change = operation === "pause" ? pauseBuilderBuildRun : resumeBuilderBuildRun;
169
+ const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
149
170
  const at = new Date().toISOString();
150
171
  const run = await change({
151
172
  cwd: context.projectRoot,
@@ -171,7 +192,7 @@ async function prepareAuthorizedLifecycleChange(input, operation, context) {
171
192
  const persistedAssignment = pathExistsNoFollow(assignmentFile)
172
193
  ? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")))
173
194
  : null;
174
- const canonicalRun = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
195
+ const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
175
196
  const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
176
197
  const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
177
198
  if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
@@ -245,20 +266,6 @@ function clearCurrentPointers(artifactRoot, slug) {
245
266
  fs.unlinkSync(file);
246
267
  }
247
268
  }
248
- export async function syncBuilderFlowSessionIfPresent(sessionDir) {
249
- let context;
250
- try {
251
- context = resolveSessionContext(sessionDir);
252
- }
253
- catch (error) {
254
- if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir")
255
- return null;
256
- throw error;
257
- }
258
- if (!fs.existsSync(runDir(context.slug, context.projectRoot)))
259
- return null;
260
- return syncBuilderFlowSession({ sessionDir });
261
- }
262
269
  async function syncAndProject(context, initial, sidecarSnapshot) {
263
270
  let run = initial;
264
271
  let attached = false;
@@ -267,25 +274,35 @@ async function syncAndProject(context, initial, sidecarSnapshot) {
267
274
  throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
268
275
  }
269
276
  if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
270
- const rawBundle = await readJson(context.bundleFile);
271
- const gateEvidence = bundleGateEvidence(rawBundle, gates[0], run.state.subject);
272
- if (gateEvidence) {
273
- const digest = await sha256File(context.bundleFile);
274
- const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === digest);
275
- if (!alreadyAttached) {
276
- run = await evaluateBuilderBuildRun({
277
- cwd: context.projectRoot,
278
- runId: context.slug,
279
- evidence: {
280
- gate: gates[0].id,
281
- file: path.relative(context.projectRoot, context.bundleFile),
282
- ...(gateEvidence.failed ? { status: "failed" } : {}),
283
- ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
284
- },
285
- });
286
- attached = true;
277
+ const snapshot = stageTrustBundleSnapshot(context);
278
+ try {
279
+ const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
280
+ const gateEvidence = await bundleGateEvidence(rawBundle, gates[0], run.state.subject, context.projectRoot);
281
+ if (gateEvidence) {
282
+ const alreadyAttached = manifestEvidence(run.manifest).some((entry) => entry.gate_id === gates[0].id && entry.sha256 === snapshot.sha256);
283
+ if (!alreadyAttached) {
284
+ const supersede = manifestEvidence(run.manifest)
285
+ .filter((entry) => entry.gate_id === gates[0].id && typeof entry.superseded_by !== "string")
286
+ .map((entry) => String(entry.id));
287
+ run = await evaluateBuilderFlowRun({
288
+ cwd: context.projectRoot,
289
+ runId: context.slug,
290
+ evidence: {
291
+ gate: gates[0].id,
292
+ file: path.relative(context.projectRoot, snapshot.file),
293
+ expectedSha256: snapshot.sha256,
294
+ ...(supersede.length > 0 ? { supersede } : {}),
295
+ ...(gateEvidence.failed ? { status: "failed" } : {}),
296
+ ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
297
+ },
298
+ });
299
+ attached = true;
300
+ }
287
301
  }
288
302
  }
303
+ finally {
304
+ removeTrustBundleSnapshot(snapshot);
305
+ }
289
306
  }
290
307
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
291
308
  writeProjection(context, projection, sidecarSnapshot.raw, "projection");
@@ -352,10 +369,15 @@ function workflowSubject(state) {
352
369
  }
353
370
  return refs[0];
354
371
  }
372
+ function persistedFlowId(state) {
373
+ const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
374
+ const flowId = flowRun?.definition_id;
375
+ return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
376
+ }
355
377
  function openGatesForResult(run) {
356
378
  return openGates(JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")), run.state);
357
379
  }
358
- function bundleGateEvidence(bundle, gate, subject) {
380
+ async function bundleGateEvidence(bundle, gate, subject, projectRoot) {
359
381
  if (!isRecord(bundle) || !Array.isArray(bundle.claims))
360
382
  return null;
361
383
  const selectors = expectationsForGate(gate).map((expectation) => expectation.bundle_claim);
@@ -387,8 +409,247 @@ function bundleGateEvidence(bundle, gate, subject) {
387
409
  if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
388
410
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
389
411
  }
412
+ if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
413
+ await assertVerifiedTestsTrust(bundle.claims, projectRoot);
414
+ }
390
415
  return { failed, routeReason };
391
416
  }
417
+ async function assertVerifiedTestsTrust(claims, projectRoot) {
418
+ const testClaims = claims.filter((claim) => isRecord(claim)
419
+ && claim.claimType === "builder.verify.tests"
420
+ && claim.value === "pass"
421
+ && isRecord(claim.metadata)
422
+ && isRecord(claim.metadata.gate_claim)
423
+ && claim.metadata.gate_claim.expectation_id === "tests-evidence");
424
+ if (testClaims.length === 0)
425
+ throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
426
+ const liveCritiques = claims.filter((claim) => isRecord(claim)
427
+ && isRecord(claim.metadata)
428
+ && claim.metadata.origin === "critique"
429
+ && typeof claim.metadata.superseded_by !== "string");
430
+ if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
431
+ throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
432
+ }
433
+ const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor) => typeof actor === "string" && actor.length > 0));
434
+ if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
435
+ throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
436
+ }
437
+ await Promise.all(liveCritiques.flatMap(async (claim) => {
438
+ const artifacts = reviewedArtifacts(claim);
439
+ await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
440
+ assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
441
+ }));
442
+ const criteria = claims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
443
+ if (criteria.length === 0 || criteria.some((claim) => {
444
+ const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
445
+ return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
446
+ })) {
447
+ throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
448
+ }
449
+ for (const testClaim of testClaims)
450
+ assertObservedTestsEvidence(testClaim, criteria);
451
+ }
452
+ function assertObservedTestsEvidence(testClaim, criteria) {
453
+ const observed = testClaim.metadata.observed_commands;
454
+ if (!Array.isArray(observed) || observed.length === 0) {
455
+ throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
456
+ }
457
+ const commands = new Set();
458
+ for (const entry of observed) {
459
+ if (!isRecord(entry) || typeof entry.command !== "string" || entry.exit_code !== 0 || !Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256) || commands.has(entry.command)) {
460
+ throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain unique commands with exit_code 0, a positive executed-test count, and SHA-256 output digests");
461
+ }
462
+ commands.add(entry.command);
463
+ }
464
+ const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
465
+ const topCommands = new Set(topRefs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
466
+ if ([...commands].some((command) => !topCommands.has(command))) {
467
+ throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
468
+ }
469
+ const criterionCommands = new Set();
470
+ for (const claim of criteria) {
471
+ const criterion = claim.metadata.criterion;
472
+ const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
473
+ const matched = refs.flatMap((ref) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
474
+ if (matched.length === 0)
475
+ throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
476
+ matched.forEach((command) => criterionCommands.add(command));
477
+ }
478
+ if ([...commands].some((command) => !criterionCommands.has(command))) {
479
+ throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
480
+ }
481
+ }
482
+ function isSubstantivePassingCritique(claim) {
483
+ if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding) => isRecord(finding) && finding.status === "open")))
484
+ return false;
485
+ const lanes = claim.metadata.lanes;
486
+ return Array.isArray(lanes)
487
+ && lanes.length > 0
488
+ && lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
489
+ && reviewedArtifacts(claim).length > 0;
490
+ }
491
+ function reviewedArtifacts(claim) {
492
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
493
+ const artifacts = reviewTarget?.artifacts;
494
+ if (!Array.isArray(artifacts) || artifacts.length === 0)
495
+ return [];
496
+ if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256)))
497
+ return [];
498
+ return artifacts;
499
+ }
500
+ function assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot) {
501
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
502
+ const expected = reviewTarget?.workspace_snapshot;
503
+ if (!isRecord(expected)) {
504
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
505
+ }
506
+ const current = captureReviewWorkspaceSnapshot(projectRoot, expected.kind === "reviewed-files"
507
+ ? reviewedWorkspaceFiles(expected)
508
+ : artifacts.map((artifact) => ({ file: artifact.file, sha256: artifact.sha256 })));
509
+ if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
510
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
511
+ }
512
+ if (expected.digest !== current.digest) {
513
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
514
+ }
515
+ if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
516
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
517
+ }
518
+ if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
519
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
520
+ }
521
+ }
522
+ function gitWorktreeSnapshot(projectRoot) {
523
+ const root = fs.realpathSync(projectRoot);
524
+ const hasGitMarker = fs.existsSync(path.join(root, ".git"));
525
+ try {
526
+ const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
527
+ if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
528
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
529
+ }
530
+ const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
531
+ const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
532
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
533
+ .toString("utf8").split("\0").filter(Boolean).sort();
534
+ const hash = createHash("sha256");
535
+ hash.update("flow-agents:git-worktree:v1\0");
536
+ hash.update(headSha).update("\0");
537
+ hash.update(trackedDiff).update("\0");
538
+ for (const file of untracked) {
539
+ const absolute = path.resolve(root, file);
540
+ if (!pathIsWithin(absolute, root))
541
+ throw new Error("untracked file escapes repository root");
542
+ const stat = fs.lstatSync(absolute);
543
+ if (!stat.isFile() || stat.isSymbolicLink())
544
+ throw new Error("untracked entry is not a regular file");
545
+ hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
546
+ }
547
+ return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
548
+ }
549
+ catch (error) {
550
+ if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
551
+ if (error instanceof BuilderBuildRunInputError)
552
+ throw error;
553
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
554
+ }
555
+ return null;
556
+ }
557
+ }
558
+ export function captureReviewWorkspaceSnapshot(projectRoot, reviewedFiles) {
559
+ return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
560
+ }
561
+ function reviewedWorkspaceFiles(snapshot) {
562
+ if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
563
+ || !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
564
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
565
+ }
566
+ const files = snapshot.files.map((file) => ({ file: file.file, sha256: file.sha256 })).sort((left, right) => left.file.localeCompare(right.file));
567
+ if (new Set(files.map((file) => file.file)).size !== files.length) {
568
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
569
+ }
570
+ return files;
571
+ }
572
+ function reviewedFilesSnapshot(projectRoot, reviewedFiles) {
573
+ const files = reviewedFiles.map((file) => ({ ...file }));
574
+ const hash = createHash("sha256");
575
+ hash.update("flow-agents:reviewed-files:v1\0");
576
+ for (const artifact of files) {
577
+ const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
578
+ hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
579
+ }
580
+ return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
581
+ }
582
+ async function assertReviewedArtifactDigest(artifact, projectRoot) {
583
+ const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
584
+ if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
585
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
586
+ }
587
+ }
588
+ function safeReviewedArtifactPath(projectRoot, file) {
589
+ const canonicalRoot = fs.realpathSync(projectRoot);
590
+ const candidate = path.resolve(canonicalRoot, file);
591
+ const relative = path.relative(canonicalRoot, candidate);
592
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
593
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
594
+ }
595
+ let canonicalArtifact;
596
+ try {
597
+ canonicalArtifact = fs.realpathSync(candidate);
598
+ }
599
+ catch {
600
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
601
+ }
602
+ const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
603
+ if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
604
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
605
+ }
606
+ return canonicalArtifact;
607
+ }
608
+ function stageTrustBundleSnapshot(context) {
609
+ assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
610
+ const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
611
+ let raw;
612
+ try {
613
+ const stat = fs.fstatSync(source);
614
+ if (!stat.isFile())
615
+ throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
616
+ raw = fs.readFileSync(source);
617
+ }
618
+ finally {
619
+ fs.closeSync(source);
620
+ }
621
+ const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
622
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
623
+ assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
624
+ fs.chmodSync(directory, 0o700);
625
+ const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
626
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
627
+ try {
628
+ fs.writeFileSync(descriptor, raw);
629
+ fs.fsyncSync(descriptor);
630
+ }
631
+ finally {
632
+ fs.closeSync(descriptor);
633
+ }
634
+ fs.chmodSync(file, 0o400);
635
+ return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
636
+ }
637
+ function removeTrustBundleSnapshot(snapshot) {
638
+ try {
639
+ fs.unlinkSync(snapshot.file);
640
+ }
641
+ catch (error) {
642
+ if (!isRecord(error) || error.code !== "ENOENT")
643
+ throw error;
644
+ }
645
+ try {
646
+ fs.rmdirSync(path.dirname(snapshot.file));
647
+ }
648
+ catch (error) {
649
+ if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY"))
650
+ throw error;
651
+ }
652
+ }
392
653
  function workflowSubjectRef(claim) {
393
654
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
394
655
  return metadata && typeof metadata.workflow_subject_ref === "string"
@@ -404,7 +665,7 @@ function projectFlowRun(context, run, sidecar) {
404
665
  const complete = run.state.status === "completed";
405
666
  const paused = run.state.status === "paused";
406
667
  const canceled = run.state.status === "canceled";
407
- const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
668
+ const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
408
669
  if (!action) {
409
670
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
410
671
  }
@@ -505,7 +766,7 @@ function prepareProjectionWrites(context, projection, expectedStateRaw, operatio
505
766
  continue;
506
767
  const output = {
507
768
  ...pointer,
508
- active_flow_id: BUILDER_BUILD_FLOW_ID,
769
+ active_flow_id: projection.flow_run.definition_id,
509
770
  active_step_id: projection.flow_run.current_step,
510
771
  updated_at: projection.updated_at,
511
772
  };
@@ -602,11 +863,11 @@ function writeExistingFileNoFollow(file, content) {
602
863
  fs.closeSync(descriptor);
603
864
  }
604
865
  }
605
- function stepAction(stepId) {
866
+ function stepAction(flowId, stepId) {
606
867
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
607
868
  const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
608
869
  const action = actions.find((candidate) => isRecord(candidate)
609
- && candidate.flow_id === BUILDER_BUILD_FLOW_ID
870
+ && candidate.flow_id === flowId
610
871
  && candidate.step_id === stepId);
611
872
  if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill) => typeof skill === "string")) {
612
873
  return null;
@@ -66,13 +66,10 @@ export declare function writeLocalRecord(artifactRoot: string, subjectId: string
66
66
  * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
67
67
  * could both read "no conflicting claim" before either wrote, and the second write would silently
68
68
  * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
69
- * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
70
- * workflow-sidecar.ts:908 for the same class of shared-state mutation atomic `fs.mkdirSync`
71
- * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
72
- * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
73
- * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
74
- * as a small LOCAL helper (not a cross-import of that private function, which would pull the
75
- * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
69
+ * races against the built CLI). Atomic directory creation establishes ownership before metadata
70
+ * is written; contenders treat even an ownerless directory as held. Live contention waits with a
71
+ * bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
72
+ * filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
76
73
  * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
77
74
  * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
78
75
  * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
@@ -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 } from "../lib/args.js";
@@ -186,18 +187,27 @@ function subjectLockDir(artifactRoot, subjectId) {
186
187
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
187
188
  return path.join(assignmentDir, `.${sanitized}.lockdir`);
188
189
  }
190
+ // Lock age is adjudicated by the current contender, never by metadata written
191
+ // by the lock owner. The environment is only an operator tuning input; clamp it
192
+ // so a caller cannot turn a transient owner-file write into immediate takeover.
193
+ const SUBJECT_LOCK_STALE_MIN_MS = 1_000;
194
+ const SUBJECT_LOCK_STALE_MAX_MS = 30 * 60 * 1_000;
195
+ const SUBJECT_LOCK_STALE_DEFAULT_MS = 5 * 60 * 1_000;
196
+ function trustedSubjectLockStaleMs() {
197
+ const configured = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS);
198
+ if (!Number.isFinite(configured))
199
+ return SUBJECT_LOCK_STALE_DEFAULT_MS;
200
+ return Math.min(SUBJECT_LOCK_STALE_MAX_MS, Math.max(SUBJECT_LOCK_STALE_MIN_MS, Math.floor(configured)));
201
+ }
189
202
  /**
190
203
  * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
191
204
  * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
192
205
  * could both read "no conflicting claim" before either wrote, and the second write would silently
193
206
  * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
194
- * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
195
- * workflow-sidecar.ts:908 for the same class of shared-state mutation atomic `fs.mkdirSync`
196
- * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
197
- * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
198
- * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
199
- * as a small LOCAL helper (not a cross-import of that private function, which would pull the
200
- * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
207
+ * races against the built CLI). Atomic directory creation establishes ownership before metadata
208
+ * is written; contenders treat even an ownerless directory as held. Live contention waits with a
209
+ * bounded deadline; stale or malformed residue fails closed for explicit operator cleanup because portable Node
210
+ * filesystem APIs cannot compare-and-swap a directory identity safely. Deliberately synchronous (sleepSync's
201
211
  * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
202
212
  * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
203
213
  * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
@@ -208,23 +218,33 @@ function subjectLockDir(artifactRoot, subjectId) {
208
218
  */
209
219
  export function withSubjectLock(artifactRoot, subjectId, body) {
210
220
  const lockDir = subjectLockDir(artifactRoot, subjectId);
211
- const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
221
+ const staleMs = trustedSubjectLockStaleMs();
222
+ const token = randomBytes(16).toString("hex");
223
+ const ownerFile = path.join(lockDir, "owner.json");
212
224
  const deadline = Date.now() + 30000;
213
225
  while (true) {
226
+ let createdLockDir = false;
214
227
  try {
215
228
  fs.mkdirSync(lockDir);
229
+ createdLockDir = true;
230
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token, pid: process.pid, acquired_at: isoNow() })}\n`, { flag: "wx", mode: 0o600 });
216
231
  break;
217
232
  }
218
233
  catch (error) {
219
234
  const lockError = error;
235
+ if (createdLockDir)
236
+ fs.rmSync(lockDir, { recursive: true, force: true });
220
237
  if (lockError.code !== "EEXIST") {
221
238
  throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
222
239
  }
223
240
  try {
224
- const stat = fs.statSync(lockDir);
225
- if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
226
- fs.rmSync(lockDir, { recursive: true, force: true });
227
- continue;
241
+ const owner = readSubjectLockOwner(ownerFile);
242
+ const stat = fs.lstatSync(owner?.token ? ownerFile : lockDir);
243
+ if (stat.isSymbolicLink() || !(owner?.token ? stat.isFile() : stat.isDirectory())) {
244
+ throw new Error(`assignment lock has an unsafe ${owner?.token ? "owner file" : "directory"}: ${lockDir}`);
245
+ }
246
+ if (Date.now() - stat.mtimeMs > staleMs) {
247
+ throw new Error(`assignment lock is stale or malformed and requires explicit operator cleanup after confirming no owner is active: ${lockDir}`);
228
248
  }
229
249
  }
230
250
  catch (statError) {
@@ -238,7 +258,14 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
238
258
  sleepSync(20);
239
259
  }
240
260
  }
241
- const release = () => fs.rmSync(lockDir, { recursive: true, force: true });
261
+ let heartbeat;
262
+ const ownsLock = () => readSubjectLockOwner(ownerFile)?.token === token;
263
+ const release = () => {
264
+ if (heartbeat)
265
+ clearInterval(heartbeat);
266
+ if (ownsLock())
267
+ fs.rmSync(lockDir, { recursive: true, force: true });
268
+ };
242
269
  let result;
243
270
  try {
244
271
  result = body();
@@ -248,11 +275,38 @@ export function withSubjectLock(artifactRoot, subjectId, body) {
248
275
  throw error;
249
276
  }
250
277
  if (result && typeof result.then === "function") {
278
+ // An async owner can legitimately hold the lock longer than the stale-lock
279
+ // threshold while an authority-bound command is running. Keep its mtime fresh
280
+ // so lifecycle operations and takeovers continue to observe the live lock.
281
+ const heartbeatMs = Math.max(10, Math.min(1_000, Math.floor(staleMs > 0 ? staleMs / 3 : 1_000)));
282
+ heartbeat = setInterval(() => {
283
+ try {
284
+ if (!ownsLock())
285
+ return;
286
+ const timestamp = new Date();
287
+ fs.utimesSync(ownerFile, timestamp, timestamp);
288
+ fs.utimesSync(lockDir, timestamp, timestamp);
289
+ }
290
+ catch { /* release, reclamation, or process teardown owns cleanup */ }
291
+ }, heartbeatMs);
251
292
  return Promise.resolve(result).finally(release);
252
293
  }
253
294
  release();
254
295
  return result;
255
296
  }
297
+ function readSubjectLockOwner(file) {
298
+ try {
299
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
300
+ return value && typeof value === "object" && !Array.isArray(value)
301
+ ? value
302
+ : null;
303
+ }
304
+ catch (error) {
305
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
306
+ return null;
307
+ throw error;
308
+ }
309
+ }
256
310
  /**
257
311
  * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
258
312
  * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five