@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,4 +1,6 @@
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";
@@ -8,9 +10,6 @@ import {
8
10
  expectationsForGate,
9
11
  lifecycleRequestMatches,
10
12
  openGates,
11
- readJson,
12
- runDir,
13
- sha256File,
14
13
  type FlowGate,
15
14
  type FlowExpectation,
16
15
  type FlowRunState,
@@ -20,20 +19,22 @@ import { assertAuthorizationUnused, loadBuilderLifecycleAuthorization, readAutho
20
19
  import { assignmentFilePath, performLocalReleaseUnderLock, readLocalAssignmentStatus, resolveCurrentAssignmentActor, withSubjectLock, type ActorStruct } from "./cli/assignment-provider.js";
21
20
  import {
22
21
  BUILDER_BUILD_FLOW_ID,
22
+ type BuilderFlowId,
23
23
  BuilderBuildRunInputError,
24
- cancelBuilderBuildRun,
25
- evaluateBuilderBuildRun,
26
- loadBuilderBuildRun,
27
- pauseBuilderBuildRun,
28
- resumeBuilderBuildRun,
29
- startBuilderBuildRun,
30
- type BuilderBuildRunResult,
24
+ cancelBuilderFlowRun,
25
+ evaluateBuilderFlowRun,
26
+ loadBuilderFlowRun,
27
+ pauseBuilderFlowRun,
28
+ resumeBuilderFlowRun,
29
+ startBuilderFlowRun,
30
+ type BuilderFlowRunResult,
31
31
  } from "./builder-flow-run-adapter.js";
32
32
 
33
33
  type AnyRecord = Record<string, any>;
34
34
 
35
35
  export interface BuilderFlowSessionInput {
36
36
  sessionDir: string;
37
+ flowId?: BuilderFlowId;
37
38
  }
38
39
 
39
40
  export interface BuilderFlowAuthorizedLifecycleInput extends BuilderFlowSessionInput {
@@ -47,7 +48,7 @@ export interface BuilderFlowAgentLifecycleInput extends BuilderFlowSessionInput
47
48
  export interface BuilderFlowSessionResult {
48
49
  sessionDir: string;
49
50
  projectRoot: string;
50
- run: BuilderBuildRunResult;
51
+ run: BuilderFlowRunResult;
51
52
  projection: AnyRecord;
52
53
  attached: boolean;
53
54
  }
@@ -79,22 +80,33 @@ type PreparedProjectionWrites = {
79
80
  writes: Array<{ file: string; content: string }>;
80
81
  };
81
82
 
83
+ type TrustBundleSnapshot = {
84
+ file: string;
85
+ raw: Buffer;
86
+ sha256: string;
87
+ };
88
+
82
89
  export async function startBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
83
90
  const context = resolveSessionContext(input.sessionDir);
84
91
  const sidecarSnapshot = readSidecarSnapshot(context);
85
92
  const subject = workflowSubject(sidecarSnapshot.state);
86
- let run: BuilderBuildRunResult;
93
+ const requestedFlowId = input.flowId ?? persistedFlowId(sidecarSnapshot.state) ?? BUILDER_BUILD_FLOW_ID;
94
+ let run: BuilderFlowRunResult;
87
95
  try {
88
- run = await loadBuilderBuildRun({
96
+ run = await loadBuilderFlowRun({
89
97
  cwd: context.projectRoot,
90
98
  runId: context.slug,
91
99
  });
100
+ if (run.definitionId !== requestedFlowId) {
101
+ 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`);
102
+ }
92
103
  } catch (error) {
93
104
  if (!isRunNotFound(error)) throw error;
94
- run = await startBuilderBuildRun({
105
+ run = await startBuilderFlowRun({
95
106
  cwd: context.projectRoot,
96
107
  runId: context.slug,
97
108
  subject,
109
+ flowId: requestedFlowId,
98
110
  params: {
99
111
  subject,
100
112
  },
@@ -108,7 +120,7 @@ export async function syncBuilderFlowSession(input: BuilderFlowSessionInput): Pr
108
120
  const context = resolveSessionContext(input.sessionDir);
109
121
  const sidecarSnapshot = readSidecarSnapshot(context);
110
122
  const subject = workflowSubject(sidecarSnapshot.state);
111
- const run = await loadBuilderBuildRun({
123
+ const run = await loadBuilderFlowRun({
112
124
  cwd: context.projectRoot,
113
125
  runId: context.slug,
114
126
  });
@@ -120,7 +132,7 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
120
132
  const context = resolveSessionContext(input.sessionDir);
121
133
  const sidecarSnapshot = readSidecarSnapshot(context);
122
134
  const subject = workflowSubject(sidecarSnapshot.state);
123
- const run = await loadBuilderBuildRun({
135
+ const run = await loadBuilderFlowRun({
124
136
  cwd: context.projectRoot,
125
137
  runId: context.slug,
126
138
  });
@@ -136,6 +148,21 @@ export async function recoverBuilderFlowSession(input: BuilderFlowSessionInput):
136
148
  };
137
149
  }
138
150
 
151
+ export async function inspectBuilderFlowSession(input: BuilderFlowSessionInput): Promise<BuilderFlowSessionResult> {
152
+ const context = resolveSessionContext(input.sessionDir);
153
+ const sidecarSnapshot = readSidecarSnapshot(context);
154
+ const subject = workflowSubject(sidecarSnapshot.state);
155
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
156
+ assertRunSubjectBinding(run, subject);
157
+ return {
158
+ sessionDir: context.sessionDir,
159
+ projectRoot: context.projectRoot,
160
+ run,
161
+ projection: projectFlowRun(context, run, sidecarSnapshot.state),
162
+ attached: false,
163
+ };
164
+ }
165
+
139
166
  export async function pauseBuilderFlowSession(input: BuilderFlowAgentLifecycleInput): Promise<BuilderFlowSessionResult> {
140
167
  return changeBuilderFlowSessionLifecycle(input, "pause");
141
168
  }
@@ -149,7 +176,7 @@ export async function cancelBuilderFlowSession(input: BuilderFlowAuthorizedLifec
149
176
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
150
177
  const prepared = await prepareAuthorizedLifecycleChange(input, "cancel", context);
151
178
  assertAuthorizationUnused(prepared.context.artifactRoot, prepared.authorization);
152
- const changed = await cancelBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
179
+ const changed = await cancelBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug, request: prepared.authorization.request });
153
180
  const released = performLocalReleaseUnderLock(prepared.context.artifactRoot, prepared.context.slug, prepared.authorization.assignment_actor, {
154
181
  actorKey: prepared.authorization.assignment_actor_key,
155
182
  reason: `canonical Flow run canceled by ${prepared.authorization.request.authority.request_ref}`,
@@ -166,7 +193,7 @@ export async function releaseBuilderFlowAssignment(input: BuilderFlowAgentLifecy
166
193
  const context = resolveSessionContext(input.sessionDir);
167
194
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
168
195
  const prepared = prepareAgentLifecycleChange(input, context);
169
- const run = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
196
+ const run = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
170
197
  const released = performLocalReleaseUnderLock(context.artifactRoot, context.slug, prepared.actor, { actorKey: prepared.actorKey, reason: input.reason });
171
198
  return { sessionDir: context.sessionDir, projectRoot: context.projectRoot, run, projection: prepared.sidecarSnapshot.state, attached: false, assignmentReleased: released !== null };
172
199
  });
@@ -179,7 +206,7 @@ export async function archiveBuilderFlowSession(input: BuilderFlowAuthorizedLife
179
206
  const priorConsumption = readAuthorizationConsumption(prepared.context.artifactRoot, prepared.authorization);
180
207
  const recoveringPreparedArchive = priorConsumption !== null && prepared.sidecarSnapshot.state.status === "archived";
181
208
  if (priorConsumption && !recoveringPreparedArchive) throw new Error("lifecycle authorization nonce has already been consumed");
182
- const run = await loadBuilderBuildRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
209
+ const run = await loadBuilderFlowRun({ cwd: prepared.context.projectRoot, runId: prepared.context.slug });
183
210
  if (run.state.status !== "completed" && run.state.status !== "canceled") {
184
211
  throw new BuilderBuildRunInputError("flow_run.status", "must be completed or canceled before archival");
185
212
  }
@@ -223,7 +250,7 @@ async function changeBuilderFlowSessionLifecycle(
223
250
  const context = resolveSessionContext(input.sessionDir);
224
251
  return await withSubjectLock(context.artifactRoot, context.slug, async () => {
225
252
  const prepared = prepareAgentLifecycleChange(input, context);
226
- const change = operation === "pause" ? pauseBuilderBuildRun : resumeBuilderBuildRun;
253
+ const change = operation === "pause" ? pauseBuilderFlowRun : resumeBuilderFlowRun;
227
254
  const at = new Date().toISOString();
228
255
  const run = await change({
229
256
  cwd: context.projectRoot,
@@ -254,7 +281,7 @@ async function prepareAuthorizedLifecycleChange(input: BuilderFlowAuthorizedLife
254
281
  const persistedAssignment = pathExistsNoFollow(assignmentFile)
255
282
  ? (assertSafeFile(assignmentFile, context.artifactRoot, "assignment record"), JSON.parse(fs.readFileSync(assignmentFile, "utf8")) as AnyRecord)
256
283
  : null;
257
- const canonicalRun = await loadBuilderBuildRun({ cwd: context.projectRoot, runId: context.slug });
284
+ const canonicalRun = await loadBuilderFlowRun({ cwd: context.projectRoot, runId: context.slug });
258
285
  const acceptsReleasedAssignment = (operation === "cancel" && canonicalRun.state.status === "canceled") || operation === "archive";
259
286
  const assignment = activeAssignment ?? (acceptsReleasedAssignment && persistedAssignment?.status === "released" ? persistedAssignment : null);
260
287
  if (!assignment || (assignment.status !== "claimed" && !acceptsReleasedAssignment) || !assignment.actor_key) {
@@ -325,21 +352,9 @@ function clearCurrentPointers(artifactRoot: string, slug: string): void {
325
352
  }
326
353
  }
327
354
 
328
- export async function syncBuilderFlowSessionIfPresent(sessionDir: string): Promise<BuilderFlowSessionResult | null> {
329
- let context: SessionContext;
330
- try {
331
- context = resolveSessionContext(sessionDir);
332
- } catch (error) {
333
- if (error instanceof BuilderBuildRunInputError && error.field === "sessionDir") return null;
334
- throw error;
335
- }
336
- if (!fs.existsSync(runDir(context.slug, context.projectRoot))) return null;
337
- return syncBuilderFlowSession({ sessionDir });
338
- }
339
-
340
355
  async function syncAndProject(
341
356
  context: SessionContext,
342
- initial: BuilderBuildRunResult,
357
+ initial: BuilderFlowRunResult,
343
358
  sidecarSnapshot: SidecarSnapshot,
344
359
  ): Promise<BuilderFlowSessionResult> {
345
360
  let run = initial;
@@ -349,26 +364,35 @@ async function syncAndProject(
349
364
  throw new BuilderBuildRunInputError("flow_run.open_gates", `expected exactly one gate for active step ${run.state.current_step}, found ${gates.length}`);
350
365
  }
351
366
  if (gates.length === 1 && fs.existsSync(context.bundleFile)) {
352
- const rawBundle = await readJson(context.bundleFile);
353
- const gateEvidence = bundleGateEvidence(rawBundle, gates[0]!, run.state.subject);
354
- if (gateEvidence) {
355
- const digest = await sha256File(context.bundleFile);
356
- const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
357
- entry.gate_id === gates[0]!.id && entry.sha256 === digest
358
- );
359
- if (!alreadyAttached) {
360
- run = await evaluateBuilderBuildRun({
361
- cwd: context.projectRoot,
362
- runId: context.slug,
363
- evidence: {
364
- gate: gates[0]!.id,
365
- file: path.relative(context.projectRoot, context.bundleFile),
366
- ...(gateEvidence.failed ? { status: "failed" } : {}),
367
- ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
368
- },
369
- });
370
- attached = true;
367
+ const snapshot = stageTrustBundleSnapshot(context);
368
+ try {
369
+ const rawBundle = JSON.parse(snapshot.raw.toString("utf8"));
370
+ const gateEvidence = await bundleGateEvidence(rawBundle, gates[0]!, run.state.subject, context.projectRoot);
371
+ if (gateEvidence) {
372
+ const alreadyAttached = manifestEvidence(run.manifest).some((entry) =>
373
+ entry.gate_id === gates[0]!.id && entry.sha256 === snapshot.sha256
374
+ );
375
+ if (!alreadyAttached) {
376
+ const supersede = manifestEvidence(run.manifest)
377
+ .filter((entry) => entry.gate_id === gates[0]!.id && typeof entry.superseded_by !== "string")
378
+ .map((entry) => String(entry.id));
379
+ run = await evaluateBuilderFlowRun({
380
+ cwd: context.projectRoot,
381
+ runId: context.slug,
382
+ evidence: {
383
+ gate: gates[0]!.id,
384
+ file: path.relative(context.projectRoot, snapshot.file),
385
+ expectedSha256: snapshot.sha256,
386
+ ...(supersede.length > 0 ? { supersede } : {}),
387
+ ...(gateEvidence.failed ? { status: "failed" } : {}),
388
+ ...(gateEvidence.routeReason ? { routeReason: gateEvidence.routeReason } : {}),
389
+ },
390
+ });
391
+ attached = true;
392
+ }
371
393
  }
394
+ } finally {
395
+ removeTrustBundleSnapshot(snapshot);
372
396
  }
373
397
  }
374
398
  const projection = projectFlowRun(context, run, sidecarSnapshot.state);
@@ -382,7 +406,7 @@ async function syncAndProject(
382
406
  };
383
407
  }
384
408
 
385
- function assertRunSubjectBinding(run: BuilderBuildRunResult, subject: string): void {
409
+ function assertRunSubjectBinding(run: BuilderFlowRunResult, subject: string): void {
386
410
  if (run.state.subject !== subject) {
387
411
  throw new BuilderBuildRunInputError("flow_run.state.subject", "must match the selected Work Item");
388
412
  }
@@ -441,14 +465,20 @@ function workflowSubject(state: AnyRecord): string {
441
465
  return refs[0]!;
442
466
  }
443
467
 
444
- function openGatesForResult(run: BuilderBuildRunResult): Array<FlowGate & { id: string }> {
468
+ function persistedFlowId(state: AnyRecord): BuilderFlowId | null {
469
+ const flowRun = isRecord(state.flow_run) ? state.flow_run : null;
470
+ const flowId = flowRun?.definition_id;
471
+ return flowId === "builder.build" || flowId === "builder.shape" ? flowId : null;
472
+ }
473
+
474
+ function openGatesForResult(run: BuilderFlowRunResult): Array<FlowGate & { id: string }> {
445
475
  return openGates(
446
476
  JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8")),
447
477
  run.state,
448
478
  ) as Array<FlowGate & { id: string }>;
449
479
  }
450
480
 
451
- function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): { failed: boolean; routeReason: string | null } | null {
481
+ async function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string, projectRoot: string): Promise<{ failed: boolean; routeReason: string | null } | null> {
452
482
  if (!isRecord(bundle) || !Array.isArray(bundle.claims)) return null;
453
483
  const selectors = (expectationsForGate(gate) as FlowExpectation[]).map((expectation) => expectation.bundle_claim);
454
484
  const relevant = bundle.claims.filter((claim: unknown): claim is AnyRecord => {
@@ -479,9 +509,249 @@ function bundleGateEvidence(bundle: unknown, gate: FlowGate, subject: string): {
479
509
  if (routeReason && (!routeMap || typeof routeMap[routeReason] !== "string")) {
480
510
  throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String((gate as AnyRecord).id ?? "<unknown>")}`);
481
511
  }
512
+ if (String((gate as AnyRecord).id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
513
+ await assertVerifiedTestsTrust(bundle.claims, projectRoot);
514
+ }
482
515
  return { failed, routeReason };
483
516
  }
484
517
 
518
+ async function assertVerifiedTestsTrust(claims: unknown[], projectRoot: string): Promise<void> {
519
+ const testClaims = claims.filter((claim): claim is AnyRecord => isRecord(claim)
520
+ && claim.claimType === "builder.verify.tests"
521
+ && claim.value === "pass"
522
+ && isRecord(claim.metadata)
523
+ && isRecord(claim.metadata.gate_claim)
524
+ && claim.metadata.gate_claim.expectation_id === "tests-evidence");
525
+ if (testClaims.length === 0) throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
526
+ const liveCritiques = claims.filter((claim): claim is AnyRecord => isRecord(claim)
527
+ && isRecord(claim.metadata)
528
+ && claim.metadata.origin === "critique"
529
+ && typeof claim.metadata.superseded_by !== "string");
530
+ if (liveCritiques.length === 0 || liveCritiques.some((claim) => !isSubstantivePassingCritique(claim))) {
531
+ throw new BuilderBuildRunInputError("evidence.critique", "a passing tests-evidence claim requires a current clean critique");
532
+ }
533
+ const implementationActors = new Set(testClaims.map((claim) => claim.metadata.recorded_by).filter((actor): actor is string => typeof actor === "string" && actor.length > 0));
534
+ if (implementationActors.size !== 1 || liveCritiques.some((claim) => typeof claim.metadata.reviewer !== "string" || implementationActors.has(claim.metadata.reviewer))) {
535
+ throw new BuilderBuildRunInputError("evidence.critique.reviewer", "must identify a reviewer distinct from the tests-evidence implementation actor");
536
+ }
537
+ await Promise.all(liveCritiques.flatMap(async (claim) => {
538
+ const artifacts = reviewedArtifacts(claim);
539
+ await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
540
+ assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
541
+ }));
542
+ const criteria = claims.filter((claim): claim is AnyRecord => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
543
+ if (criteria.length === 0 || criteria.some((claim) => {
544
+ const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
545
+ return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
546
+ })) {
547
+ throw new BuilderBuildRunInputError("evidence.acceptance", "a passing tests-evidence claim requires complete verified acceptance criteria");
548
+ }
549
+ for (const testClaim of testClaims) assertObservedTestsEvidence(testClaim, criteria);
550
+ }
551
+
552
+ function assertObservedTestsEvidence(testClaim: AnyRecord, criteria: AnyRecord[]): void {
553
+ const observed = testClaim.metadata.observed_commands;
554
+ if (!Array.isArray(observed) || observed.length === 0) {
555
+ throw new BuilderBuildRunInputError("evidence.tests.observed_commands", "must contain successful command observations");
556
+ }
557
+ const commands = new Set<string>();
558
+ for (const entry of observed) {
559
+ 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)) {
560
+ 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");
561
+ }
562
+ commands.add(entry.command);
563
+ }
564
+ const topRefs = Array.isArray(testClaim.metadata.artifact_refs) ? testClaim.metadata.artifact_refs : [];
565
+ const topCommands = new Set(topRefs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" ? [ref.excerpt.trim()] : []));
566
+ if ([...commands].some((command) => !topCommands.has(command))) {
567
+ throw new BuilderBuildRunInputError("evidence.tests.artifact_refs", "must reference every successful observed command exactly");
568
+ }
569
+ const criterionCommands = new Set<string>();
570
+ for (const claim of criteria) {
571
+ const criterion = claim.metadata.criterion as AnyRecord;
572
+ const refs = Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [];
573
+ const matched = refs.flatMap((ref: unknown) => isRecord(ref) && ref.kind === "command" && typeof ref.excerpt === "string" && commands.has(ref.excerpt.trim()) ? [ref.excerpt.trim()] : []);
574
+ if (matched.length === 0) throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", `criterion ${String(criterion.id ?? claim.id)} must reference a successful observed command`);
575
+ matched.forEach((command) => criterionCommands.add(command));
576
+ }
577
+ if ([...commands].some((command) => !criterionCommands.has(command))) {
578
+ throw new BuilderBuildRunInputError("evidence.acceptance.evidence_refs", "must bind every successful observed command to at least one criterion");
579
+ }
580
+ }
581
+
582
+ function isSubstantivePassingCritique(claim: AnyRecord): boolean {
583
+ if (claim.value !== "pass" || (Array.isArray(claim.metadata.findings) && claim.metadata.findings.some((finding: unknown) => isRecord(finding) && finding.status === "open"))) return false;
584
+ const lanes = claim.metadata.lanes;
585
+ return Array.isArray(lanes)
586
+ && lanes.length > 0
587
+ && lanes.every((lane) => isRecord(lane) && (lane.status === "pass" || lane.verdict === "pass"))
588
+ && reviewedArtifacts(claim).length > 0;
589
+ }
590
+
591
+ function reviewedArtifacts(claim: AnyRecord): AnyRecord[] {
592
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
593
+ const artifacts = reviewTarget?.artifacts;
594
+ if (!Array.isArray(artifacts) || artifacts.length === 0) return [];
595
+ if (!artifacts.every((artifact) => isRecord(artifact) && typeof artifact.file === "string" && typeof artifact.sha256 === "string" && /^[a-f0-9]{64}$/i.test(artifact.sha256))) return [];
596
+ return artifacts as AnyRecord[];
597
+ }
598
+
599
+ function assertReviewedWorkspaceSnapshot(claim: AnyRecord, artifacts: AnyRecord[], projectRoot: string): void {
600
+ const reviewTarget = isRecord(claim.metadata.review_target) ? claim.metadata.review_target : null;
601
+ const expected = reviewTarget?.workspace_snapshot;
602
+ if (!isRecord(expected)) {
603
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "is required for a passing critique");
604
+ }
605
+ const current = captureReviewWorkspaceSnapshot(
606
+ projectRoot,
607
+ expected.kind === "reviewed-files"
608
+ ? reviewedWorkspaceFiles(expected)
609
+ : artifacts.map((artifact) => ({ file: artifact.file as string, sha256: artifact.sha256 as string })),
610
+ );
611
+ if (expected.version !== current.version || expected.kind !== current.kind || expected.algorithm !== current.algorithm) {
612
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "does not match the current workspace snapshot strategy");
613
+ }
614
+ if (expected.digest !== current.digest) {
615
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.digest", "does not match the current implementation workspace");
616
+ }
617
+ if (current.kind === "git-worktree" && expected.head_sha !== current.head_sha) {
618
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.head_sha", "does not match the current Git HEAD");
619
+ }
620
+ if (current.kind === "reviewed-files" && !isDeepStrictEqual(expected.files, current.files)) {
621
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "does not match the explicitly reviewed files");
622
+ }
623
+ }
624
+
625
+ function gitWorktreeSnapshot(projectRoot: string): AnyRecord | null {
626
+ const root = fs.realpathSync(projectRoot);
627
+ const hasGitMarker = fs.existsSync(path.join(root, ".git"));
628
+ try {
629
+ const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
630
+ if (!gitRoot || fs.realpathSync(gitRoot) !== root) {
631
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", "requires the canonical project root to match the Git worktree root");
632
+ }
633
+ const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
634
+ const trackedDiff = execFileSync("git", ["diff", "--binary", "--no-ext-diff", "HEAD", "--"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] });
635
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", stdio: ["ignore", "pipe", "ignore"] })
636
+ .toString("utf8").split("\0").filter(Boolean).sort();
637
+ const hash = createHash("sha256");
638
+ hash.update("flow-agents:git-worktree:v1\0");
639
+ hash.update(headSha).update("\0");
640
+ hash.update(trackedDiff).update("\0");
641
+ for (const file of untracked) {
642
+ const absolute = path.resolve(root, file);
643
+ if (!pathIsWithin(absolute, root)) throw new Error("untracked file escapes repository root");
644
+ const stat = fs.lstatSync(absolute);
645
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("untracked entry is not a regular file");
646
+ hash.update(file).update("\0").update(fs.readFileSync(absolute)).update("\0");
647
+ }
648
+ return { version: 1, kind: "git-worktree", algorithm: "sha256", digest: hash.digest("hex"), head_sha: headSha };
649
+ } catch (error) {
650
+ if (hasGitMarker || error instanceof BuilderBuildRunInputError) {
651
+ if (error instanceof BuilderBuildRunInputError) throw error;
652
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot", `could not inspect the Git worktree: ${error instanceof Error ? error.message : String(error)}`);
653
+ }
654
+ return null;
655
+ }
656
+ }
657
+
658
+ export function captureReviewWorkspaceSnapshot(
659
+ projectRoot: string,
660
+ reviewedFiles: Array<{ file: string; sha256: string }>,
661
+ ): AnyRecord {
662
+ return gitWorktreeSnapshot(projectRoot) ?? reviewedFilesSnapshot(projectRoot, reviewedFiles);
663
+ }
664
+
665
+ function reviewedWorkspaceFiles(snapshot: AnyRecord): Array<{ file: string; sha256: string }> {
666
+ if (!Array.isArray(snapshot.files) || snapshot.files.length === 0
667
+ || !snapshot.files.every((file) => isRecord(file) && typeof file.file === "string" && /^[a-f0-9]{64}$/i.test(String(file.sha256)))) {
668
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must list explicitly reviewed files with SHA-256 digests");
669
+ }
670
+ const files = snapshot.files.map((file) => ({ file: file.file as string, sha256: file.sha256 as string })).sort((left, right) => left.file.localeCompare(right.file));
671
+ if (new Set(files.map((file) => file.file)).size !== files.length) {
672
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.workspace_snapshot.files", "must not contain duplicate files");
673
+ }
674
+ return files;
675
+ }
676
+
677
+ function reviewedFilesSnapshot(projectRoot: string, reviewedFiles: Array<{ file: string; sha256: string }>): AnyRecord {
678
+ const files = reviewedFiles.map((file) => ({ ...file }));
679
+ const hash = createHash("sha256");
680
+ hash.update("flow-agents:reviewed-files:v1\0");
681
+ for (const artifact of files) {
682
+ const absolute = safeReviewedArtifactPath(projectRoot, artifact.file);
683
+ hash.update(artifact.file).update("\0").update(fs.readFileSync(absolute)).update("\0");
684
+ }
685
+ return { version: 1, kind: "reviewed-files", algorithm: "sha256", digest: hash.digest("hex"), files };
686
+ }
687
+
688
+ async function assertReviewedArtifactDigest(artifact: AnyRecord, projectRoot: string): Promise<void> {
689
+ const canonicalArtifact = safeReviewedArtifactPath(projectRoot, artifact.file);
690
+ if (createHash("sha256").update(fs.readFileSync(canonicalArtifact)).digest("hex") !== artifact.sha256) {
691
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.sha256", `does not match ${artifact.file}`);
692
+ }
693
+ }
694
+
695
+ function safeReviewedArtifactPath(projectRoot: string, file: string): string {
696
+ const canonicalRoot = fs.realpathSync(projectRoot);
697
+ const candidate = path.resolve(canonicalRoot, file);
698
+ const relative = path.relative(canonicalRoot, candidate);
699
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
700
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must resolve within the canonical project root");
701
+ }
702
+ let canonicalArtifact: string;
703
+ try {
704
+ canonicalArtifact = fs.realpathSync(candidate);
705
+ } catch {
706
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", `is missing: ${file}`);
707
+ }
708
+ const canonicalRelative = path.relative(canonicalRoot, canonicalArtifact);
709
+ if (canonicalRelative === ".." || canonicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(canonicalRelative) || !fs.statSync(canonicalArtifact).isFile()) {
710
+ throw new BuilderBuildRunInputError("evidence.critique.review_target.artifacts.file", "must be a regular file within the canonical project root");
711
+ }
712
+ return canonicalArtifact;
713
+ }
714
+
715
+ function stageTrustBundleSnapshot(context: SessionContext): TrustBundleSnapshot {
716
+ assertSafeFile(context.bundleFile, context.sessionDir, "trust.bundle");
717
+ const source = fs.openSync(context.bundleFile, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
718
+ let raw: Buffer;
719
+ try {
720
+ const stat = fs.fstatSync(source);
721
+ if (!stat.isFile()) throw new BuilderBuildRunInputError("trust.bundle", "must be a regular file");
722
+ raw = fs.readFileSync(source);
723
+ } finally {
724
+ fs.closeSync(source);
725
+ }
726
+ const directory = path.join(context.sessionDir, ".trust-bundle-snapshots");
727
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
728
+ assertSafeDirectory(directory, context.sessionDir, "trust.bundle snapshot directory");
729
+ fs.chmodSync(directory, 0o700);
730
+ const file = path.join(directory, `${randomBytes(16).toString("hex")}.json`);
731
+ const descriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
732
+ try {
733
+ fs.writeFileSync(descriptor, raw);
734
+ fs.fsyncSync(descriptor);
735
+ } finally {
736
+ fs.closeSync(descriptor);
737
+ }
738
+ fs.chmodSync(file, 0o400);
739
+ return { file, raw, sha256: createHash("sha256").update(raw).digest("hex") };
740
+ }
741
+
742
+ function removeTrustBundleSnapshot(snapshot: TrustBundleSnapshot): void {
743
+ try {
744
+ fs.unlinkSync(snapshot.file);
745
+ } catch (error) {
746
+ if (!isRecord(error) || error.code !== "ENOENT") throw error;
747
+ }
748
+ try {
749
+ fs.rmdirSync(path.dirname(snapshot.file));
750
+ } catch (error) {
751
+ if (!isRecord(error) || (error.code !== "ENOENT" && error.code !== "ENOTEMPTY")) throw error;
752
+ }
753
+ }
754
+
485
755
  function workflowSubjectRef(claim: AnyRecord): string | null {
486
756
  const metadata = isRecord(claim.metadata) ? claim.metadata : null;
487
757
  return metadata && typeof metadata.workflow_subject_ref === "string"
@@ -493,13 +763,13 @@ function manifestEvidence(manifest: JsonObject): AnyRecord[] {
493
763
  return Array.isArray(manifest.evidence) ? manifest.evidence.filter(isRecord) : [];
494
764
  }
495
765
 
496
- function projectFlowRun(context: SessionContext, run: BuilderBuildRunResult, sidecar: AnyRecord): AnyRecord {
766
+ function projectFlowRun(context: SessionContext, run: BuilderFlowRunResult, sidecar: AnyRecord): AnyRecord {
497
767
  const definition = JSON.parse(fs.readFileSync(path.join(run.dir, "definition.json"), "utf8"));
498
768
  const gates = openGates(definition, run.state) as Array<FlowGate & { id: string }>;
499
769
  const complete = run.state.status === "completed";
500
770
  const paused = run.state.status === "paused";
501
771
  const canceled = run.state.status === "canceled";
502
- const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.state.current_step);
772
+ const action = complete || paused || canceled ? { skills: [], operations: [] } : stepAction(run.definitionId, run.state.current_step);
503
773
  if (!action) {
504
774
  throw new BuilderBuildRunInputError("kit.flow_step_actions", `does not declare Builder step ${run.state.current_step}`);
505
775
  }
@@ -606,7 +876,7 @@ function prepareProjectionWrites(
606
876
  if (!isRecord(pointer) || pointer.active_slug !== context.slug) continue;
607
877
  const output = {
608
878
  ...pointer,
609
- active_flow_id: BUILDER_BUILD_FLOW_ID,
879
+ active_flow_id: projection.flow_run.definition_id,
610
880
  active_step_id: projection.flow_run.current_step,
611
881
  updated_at: projection.updated_at,
612
882
  };
@@ -713,12 +983,12 @@ function writeExistingFileNoFollow(file: string, content: string): void {
713
983
  }
714
984
  }
715
985
 
716
- function stepAction(stepId: string): { skills: string[]; operations: string[] } | null {
986
+ function stepAction(flowId: BuilderFlowId, stepId: string): { skills: string[]; operations: string[] } | null {
717
987
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), "kits", "builder", "kit.json"), "utf8"));
718
988
  const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
719
989
  const action = actions.find((candidate: unknown) =>
720
990
  isRecord(candidate)
721
- && candidate.flow_id === BUILDER_BUILD_FLOW_ID
991
+ && candidate.flow_id === flowId
722
992
  && candidate.step_id === stepId
723
993
  );
724
994
  if (!isRecord(action) || !Array.isArray(action.skills) || !action.skills.every((skill: unknown) => typeof skill === "string")) {
@@ -0,0 +1,83 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { syncBuiltinESMExports } from "node:module";
7
+
8
+ import { withSubjectLock } from "../../build/src/cli/assignment-provider.js";
9
+
10
+ test("a displaced lock owner cannot heartbeat or release a replacement lock", async () => {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-aba-"));
12
+ const subject = "lock-aba";
13
+ const lockDir = path.join(root, "assignment", ".lock-aba.lockdir");
14
+ let finish;
15
+ const held = withSubjectLock(root, subject, () => new Promise((resolve) => { finish = resolve; }));
16
+ const original = JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
17
+
18
+ const quarantine = `${lockDir}.stale-test`;
19
+ fs.renameSync(lockDir, quarantine);
20
+ fs.mkdirSync(lockDir);
21
+ const replacement = { token: "replacement-owner", stale_after_ms: 300000 };
22
+ fs.writeFileSync(path.join(lockDir, "owner.json"), `${JSON.stringify(replacement)}\n`);
23
+ fs.rmSync(quarantine, { recursive: true, force: true });
24
+
25
+ finish();
26
+ await held;
27
+ assert.equal(fs.existsSync(lockDir), true);
28
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8")), replacement);
29
+ assert.notEqual(original.token, replacement.token);
30
+ });
31
+
32
+ test("stale locks fail closed instead of risking concurrent reclamation", () => {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-stale-"));
34
+ const lockDir = path.join(root, "assignment", ".lock-stale.lockdir");
35
+ fs.mkdirSync(lockDir, { recursive: true });
36
+ const ownerFile = path.join(lockDir, "owner.json");
37
+ fs.writeFileSync(ownerFile, `${JSON.stringify({ token: "old-owner", stale_after_ms: 1 })}\n`);
38
+ // The owner-provided one-millisecond threshold is ignored. Reclamation uses
39
+ // the contender's bounded policy, so this must be older than its default.
40
+ const old = new Date(Date.now() - 10 * 60 * 1000);
41
+ fs.utimesSync(ownerFile, old, old);
42
+
43
+ assert.throws(() => withSubjectLock(root, "lock-stale", () => "unreachable"), /requires explicit operator cleanup/);
44
+ assert.equal(fs.existsSync(lockDir), true);
45
+ });
46
+
47
+ test("old ownerless and malformed locks fail closed without a busy-spin", () => {
48
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-malformed-"));
49
+ const assignmentDir = path.join(root, "assignment");
50
+ for (const [subject, owner] of [["ownerless", null], ["malformed", "not-json\n"]]) {
51
+ const lockDir = path.join(assignmentDir, `.${subject}.lockdir`);
52
+ fs.mkdirSync(lockDir, { recursive: true });
53
+ if (owner !== null) fs.writeFileSync(path.join(lockDir, "owner.json"), owner);
54
+ const old = new Date(Date.now() - 10 * 60 * 1000);
55
+ fs.utimesSync(lockDir, old, old);
56
+ if (owner !== null) fs.utimesSync(path.join(lockDir, "owner.json"), old, old);
57
+ const started = Date.now();
58
+ assert.throws(() => withSubjectLock(root, subject, () => "unreachable"), /requires explicit operator cleanup/);
59
+ assert.ok(Date.now() - started < 1_000, `${subject} stale lock should fail without spinning until deadline`);
60
+ assert.equal(fs.existsSync(lockDir), true);
61
+ }
62
+ });
63
+
64
+ test("failed owner metadata creation removes the ownerless lock directory", () => {
65
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "flow-agents-lock-owner-write-"));
66
+ const originalWriteFileSync = fs.writeFileSync;
67
+ fs.writeFileSync = function injectedOwnerWriteFailure(file, ...args) {
68
+ if (path.basename(String(file)) === "owner.json") {
69
+ const error = new Error("injected owner write failure");
70
+ error.code = "EACCES";
71
+ throw error;
72
+ }
73
+ return originalWriteFileSync.call(this, file, ...args);
74
+ };
75
+ syncBuiltinESMExports();
76
+ try {
77
+ assert.throws(() => withSubjectLock(root, "owner-write", () => "unreachable"), /failed to acquire assignment lock/);
78
+ } finally {
79
+ fs.writeFileSync = originalWriteFileSync;
80
+ syncBuiltinESMExports();
81
+ }
82
+ assert.equal(fs.existsSync(path.join(root, "assignment", ".owner-write.lockdir")), false);
83
+ });