@kontourai/flow-agents 3.5.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/src/builder-flow-run-adapter.d.ts +32 -3
  4. package/build/src/builder-flow-run-adapter.js +113 -20
  5. package/build/src/builder-flow-runtime.d.ts +26 -3
  6. package/build/src/builder-flow-runtime.js +502 -49
  7. package/build/src/builder-lifecycle-authority.d.ts +35 -0
  8. package/build/src/builder-lifecycle-authority.js +219 -0
  9. package/build/src/cli/assignment-provider.d.ts +14 -7
  10. package/build/src/cli/assignment-provider.js +128 -65
  11. package/build/src/cli/builder-run.js +46 -9
  12. package/build/src/cli/workflow-artifact-cleanup-audit.js +3 -0
  13. package/build/src/cli/workflow-sidecar.d.ts +30 -3
  14. package/build/src/cli/workflow-sidecar.js +825 -99
  15. package/build/src/cli/workflow.d.ts +2 -0
  16. package/build/src/cli/workflow.js +769 -0
  17. package/build/src/cli.js +2 -0
  18. package/build/src/flow-kit/validate.d.ts +17 -0
  19. package/build/src/flow-kit/validate.js +340 -2
  20. package/build/src/index.d.ts +4 -0
  21. package/build/src/index.js +7 -5
  22. package/build/src/lib/observed-command.d.ts +7 -0
  23. package/build/src/lib/observed-command.js +100 -0
  24. package/build/src/lib/package-version.d.ts +2 -0
  25. package/build/src/lib/package-version.js +13 -0
  26. package/build/src/lib/pinned-cli-command.d.ts +6 -0
  27. package/build/src/lib/pinned-cli-command.js +21 -0
  28. package/build/src/tools/generate-context-map.js +5 -3
  29. package/context/contracts/artifact-contract.md +1 -1
  30. package/context/scripts/hooks/config-protection.js +8 -1
  31. package/context/scripts/hooks/lib/config-protection-remedies.js +3 -0
  32. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  33. package/context/scripts/hooks/stop-goal-fit.js +79 -10
  34. package/docs/context-map.md +24 -20
  35. package/docs/developer-architecture.md +1 -1
  36. package/docs/public-workflow-cli.md +132 -0
  37. package/docs/skills-map.md +51 -27
  38. package/docs/spec/builder-flow-runtime.md +78 -40
  39. package/docs/workflow-usage-guide.md +110 -38
  40. package/evals/ci/run-baseline.sh +2 -0
  41. package/evals/fixtures/hook-influence/cases.json +2 -2
  42. package/evals/integration/test_builder_entry_enforcement.sh +57 -45
  43. package/evals/integration/test_builder_step_producers.sh +297 -6
  44. package/evals/integration/test_bundle_install.sh +258 -55
  45. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  46. package/evals/integration/test_current_json_per_actor.sh +12 -0
  47. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  48. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  49. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  50. package/evals/integration/test_gate_lockdown.sh +3 -5
  51. package/evals/integration/test_goal_fit_hook.sh +60 -2
  52. package/evals/integration/test_liveness_verdict.sh +14 -17
  53. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  54. package/evals/integration/test_public_workflow_cli.sh +573 -0
  55. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  56. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  57. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  58. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  59. package/evals/run.sh +2 -0
  60. package/evals/static/test_builder_skill_coherence.sh +247 -0
  61. package/evals/static/test_library_exports.sh +5 -2
  62. package/evals/static/test_workflow_skills.sh +13 -325
  63. package/kits/builder/flows/build.flow.json +22 -0
  64. package/kits/builder/flows/shape.flow.json +9 -9
  65. package/kits/builder/kit.json +70 -16
  66. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  67. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  68. package/kits/builder/skills/deliver/SKILL.md +96 -442
  69. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  70. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  71. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  72. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  73. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  74. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  75. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  76. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  77. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  78. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  79. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  80. package/kits/builder/skills/review-work/SKILL.md +89 -176
  81. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  82. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  83. package/package.json +2 -2
  84. package/schemas/builder-lifecycle-authorization.schema.json +57 -0
  85. package/schemas/lifecycle-authority-keys.schema.json +25 -0
  86. package/schemas/workflow-state.schema.json +1 -1
  87. package/scripts/hooks/config-protection.js +8 -1
  88. package/scripts/hooks/lib/config-protection-remedies.js +3 -0
  89. package/scripts/hooks/lib/kit-catalog.js +1 -1
  90. package/scripts/hooks/stop-goal-fit.js +79 -10
  91. package/src/builder-flow-run-adapter.ts +156 -23
  92. package/src/builder-flow-runtime.ts +535 -53
  93. package/src/builder-lifecycle-authority.ts +218 -0
  94. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  95. package/src/cli/assignment-provider.ts +91 -22
  96. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  97. package/src/cli/builder-flow-runtime.test.mjs +597 -8
  98. package/src/cli/builder-run.ts +54 -9
  99. package/src/cli/kit-metadata-security.test.mjs +232 -6
  100. package/src/cli/public-api.test.mjs +15 -0
  101. package/src/cli/workflow-artifact-cleanup-audit.ts +3 -0
  102. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  103. package/src/cli/workflow-sidecar.ts +748 -99
  104. package/src/cli/workflow.ts +708 -0
  105. package/src/cli.ts +2 -0
  106. package/src/flow-kit/validate.ts +320 -2
  107. package/src/index.ts +20 -5
  108. package/src/lib/observed-command.ts +96 -0
  109. package/src/lib/package-version.ts +15 -0
  110. package/src/lib/pinned-cli-command.ts +23 -0
  111. package/src/tools/generate-context-map.ts +5 -3
@@ -7,10 +7,13 @@ import { createHash } from "node:crypto";
7
7
  import { createRequire } from "node:module";
8
8
  import { fileURLToPath } from "node:url";
9
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
10
- import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
+ import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
12
  import { ensureSafeDirectory } from "../lib/fs.js";
13
- import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
13
+ import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
14
+ import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
15
+ import { runObservedCommand } from "../lib/observed-command.js";
16
+ import { captureReviewWorkspaceSnapshot, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
14
17
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
15
18
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
16
19
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
@@ -19,14 +22,26 @@ import { assignmentFilePath, computeEffectiveState, performLocalClaim, performLo
19
22
 
20
23
  type AnyObj = Record<string, any>;
21
24
 
22
- export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
25
+ export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "canceled", "accepted", "archived"]);
23
26
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
24
27
  export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
25
28
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
26
29
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
30
+ export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
31
+ const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
27
32
 
28
33
  function now(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
29
34
  function read(file: string): string { return fs.readFileSync(file, "utf8"); }
35
+ function readRegularFileNoFollow(file: string, label: string): Buffer {
36
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
37
+ try {
38
+ const stat = fs.fstatSync(fd);
39
+ if (!stat.isFile()) die(`${label} must be a regular file`);
40
+ return fs.readFileSync(fd);
41
+ } finally {
42
+ fs.closeSync(fd);
43
+ }
44
+ }
30
45
  export function writeJson(file: string, payload: AnyObj): void { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
31
46
  // Single-line but readable "key": "value" form. Built by collapsing the
32
47
  // structural whitespace from an indented stringify — corruption-proof, unlike a
@@ -48,7 +63,13 @@ function slugify(value: string, fallback: string): string { return value.toLower
48
63
  * Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
49
64
  function workItemSlug(ref: string): string {
50
65
  const hashIdx = ref.indexOf("#");
51
- if (hashIdx < 0 || hashIdx === ref.length - 1) die("--work-item must be in owner/repo#id format");
66
+ if (hashIdx < 0) {
67
+ if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
68
+ die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
69
+ }
70
+ return slugify(ref, "work-item");
71
+ }
72
+ if (hashIdx === ref.length - 1) die("--work-item must be in owner/repo#numeric-id format");
52
73
  const repoPath = ref.slice(0, hashIdx);
53
74
  const id = ref.slice(hashIdx + 1);
54
75
  if (!/^\d+$/.test(id)) die("--work-item id must be a numeric issue number");
@@ -60,8 +81,7 @@ function workItemSlug(ref: string): string {
60
81
 
61
82
  function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
62
83
  if (ref === `local:${slug}`) return true;
63
- if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref)) return false;
64
- return workItemSlug(ref) === slug;
84
+ try { return workItemSlug(ref) === slug; } catch { return false; }
65
85
  }
66
86
 
67
87
  type SessionWorkItem = {
@@ -629,10 +649,10 @@ export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map
629
649
  * @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
630
650
  * @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
631
651
  * @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
632
- * @param critiques Critique objects (from critique.json .critiques array)
652
+ * @param critiques Critique objects reconstructed from trust.bundle claims
633
653
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
634
654
  */
635
- export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
655
+ export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<AnyObj | null> {
636
656
  const surface = await tryLoadSurface();
637
657
  if (!surface) return null;
638
658
  const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
@@ -645,7 +665,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
645
665
  // #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
646
666
  // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
647
667
  // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
648
- const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
668
+ const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
669
+ const activeStep: ActiveFlowStep | null = exactFlowContext && exactRepoRoot
670
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
671
+ : flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
649
672
 
650
673
  // #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
651
674
  // CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
@@ -655,6 +678,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
655
678
  // the gate claim was recorded at — the validation must be against the FULL flow definition
656
679
  // (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
657
680
  const sessionFlowId: string | null = (() => {
681
+ if (exactFlowContext) return exactFlowContext.flowId;
658
682
  if (!flowAgentsDir) return null;
659
683
  try {
660
684
  const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
@@ -942,6 +966,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
942
966
  const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
943
967
  ? { algorithm: "sha256", hex: check._output_sha256 }
944
968
  : null;
969
+ const observedCommandsMeta = Array.isArray(check._observed_commands)
970
+ ? check._observed_commands
971
+ .filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
972
+ .map((entry: AnyObj) => ({ command: entry.command, exit_code: entry.exit_code, output_sha256: entry.output_sha256, ...(Number.isSafeInteger(entry.test_count) ? { test_count: entry.test_count } : {}), ...(entry.execution_proof && typeof entry.execution_proof === "object" ? { execution_proof: entry.execution_proof } : {}) }))
973
+ : null;
945
974
  // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
946
975
  // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
947
976
  // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
@@ -971,11 +1000,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
971
1000
  origin: "check",
972
1001
  check_kind: String(check.kind ?? "external"),
973
1002
  ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1003
+ ...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
1004
+ ...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
1005
+ ...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
974
1006
  ...(waiver ? { waiver } : {}),
975
1007
  ...(promotionMeta ? { promotion: promotionMeta } : {}),
976
1008
  ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
977
1009
  ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
978
1010
  ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
1011
+ ...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
979
1012
  };
980
1013
 
981
1014
  const claimEvents: AnyObj[] = [];
@@ -1072,12 +1105,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1072
1105
  if (declared) {
1073
1106
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1074
1107
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
1075
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1108
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1076
1109
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
1077
1110
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1078
1111
  } else {
1079
1112
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
1080
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
1113
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] } } };
1081
1114
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1082
1115
  claims.push({ ...claimObj, status: derivedStatus });
1083
1116
  }
@@ -1095,7 +1128,18 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1095
1128
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1096
1129
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1097
1130
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
1098
- const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
1131
+ const critMeta: AnyObj = {
1132
+ origin: "critique",
1133
+ reviewer: critiqueReviewer,
1134
+ reviewed_at: critiqueReviewedAt,
1135
+ findings: Array.isArray(c.findings) ? c.findings : [],
1136
+ lanes: Array.isArray(c.lanes) ? c.lanes : [],
1137
+ review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
1138
+ // Keep the legacy field for consumers that still render a flat artifact list.
1139
+ artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
1140
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1141
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
1142
+ };
1099
1143
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1100
1144
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1101
1145
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
@@ -1112,17 +1156,14 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1112
1156
  claimEvents.push(evt);
1113
1157
  }
1114
1158
 
1115
- // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
1116
- const declared = matchExpectsEntry("critique");
1117
- const claimType = declared ? declared.claimType : legacyClaimType;
1118
- const subjectType = declared ? declared.subjectType : "workflow-critique";
1119
- const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
1120
- const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
1159
+ // Critique is intentionally report-only. It must never inherit a Flow gate expectation,
1160
+ // even while a run is positioned at a gate-bearing step.
1161
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id, metadata: critMeta };
1121
1162
  if (supersededBy) {
1122
1163
  // History: status is "superseded" directly (no verification event); excluded from evaluation.
1123
1164
  claims.push({ ...claimObj, status: "superseded" });
1124
1165
  } else {
1125
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
1166
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1126
1167
  claims.push({ ...claimObj, status: derivedStatus });
1127
1168
  }
1128
1169
  }
@@ -1150,7 +1191,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1150
1191
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
1151
1192
  * @param critiques Critique objects (same as buildTrustBundle)
1152
1193
  */
1153
- export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
1194
+ export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<{ written: boolean; errors: string[] }> {
1154
1195
  try {
1155
1196
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
1156
1197
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -1170,14 +1211,20 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1170
1211
  const _flowAgentsDir = path.dirname(dir);
1171
1212
  const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
1172
1213
  let _scopedFlowAgentsDir: string | undefined = undefined;
1173
- try {
1174
- const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1175
- const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
1176
- if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1177
- _scopedFlowAgentsDir = _flowAgentsDir;
1178
- }
1179
- } catch { /* current.json absent or unreadable — no scoping */ }
1180
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
1214
+ if (exactFlowContext) {
1215
+ // The context was read from this session's persisted flow_run. Navigation pointers may
1216
+ // lag or point at another run and must not override an explicit session mutation.
1217
+ _scopedFlowAgentsDir = _flowAgentsDir;
1218
+ } else {
1219
+ try {
1220
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1221
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
1222
+ if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1223
+ _scopedFlowAgentsDir = _flowAgentsDir;
1224
+ }
1225
+ } catch { /* current.json absent or unreadable — no scoping */ }
1226
+ }
1227
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
1181
1228
  if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
1182
1229
  const result = await validateTrustBundle(bundle);
1183
1230
  if (result.available && !result.valid) {
@@ -1185,7 +1232,6 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1185
1232
  return { written: false, errors: result.errors };
1186
1233
  }
1187
1234
  writeJson(path.join(dir, "trust.bundle"), bundle);
1188
- await syncBuilderFlowSessionIfPresent(dir);
1189
1235
  return { written: true, errors: [] };
1190
1236
  } catch (err) {
1191
1237
  const message = err instanceof Error ? err.message : String(err);
@@ -1678,6 +1724,11 @@ function currentDir(root: string, actorKey?: string): string | null {
1678
1724
  }
1679
1725
  return dir;
1680
1726
  }
1727
+
1728
+ export function currentWorkflowSessionDir(root: string): string | null {
1729
+ const resolved = loadActorIdentityHelper().resolveActor(process.env);
1730
+ return currentDir(path.resolve(root), loadActorIdentityHelper().isUnresolvedActor(resolved.actor) ? undefined : resolved.actor);
1731
+ }
1681
1732
  /**
1682
1733
  * #291 Wave 2 Task 2.1 (§6): updates BOTH the legacy current.json (when IT points at `dir` — the
1683
1734
  * exact, unchanged existing check/write) AND the resolved actor's own per-actor current.json
@@ -1758,6 +1809,9 @@ function initSidecars(
1758
1809
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1759
1810
  ...(branch ? { branch } : {}),
1760
1811
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1812
+ ...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
1813
+ ? { flow_run: existingState.flow_run }
1814
+ : {}),
1761
1815
  artifact_paths: relArtifacts(dir),
1762
1816
  next_action: nextActionPayload,
1763
1817
  });
@@ -1811,7 +1865,7 @@ function enforceEnsureSessionOwnership(
1811
1865
  dir: string,
1812
1866
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1813
1867
  workItemRef?: string,
1814
- ): { assignmentFile: string; actorKey: string } | null {
1868
+ ): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null {
1815
1869
  if (p.flags.has("skip-ownership-guard")) {
1816
1870
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1817
1871
  return null;
@@ -1846,15 +1900,41 @@ function enforceEnsureSessionOwnership(
1846
1900
 
1847
1901
  type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
1848
1902
  let effective: EffectiveResult;
1903
+ let providerEvidenceBytes: Buffer | undefined;
1849
1904
 
1850
1905
  const effectiveStateJsonFlag = opt(p, "effective-state-json");
1851
1906
  if (effectiveStateJsonFlag) {
1852
- const parsed = loadJsonInputFile(effectiveStateJsonFlag) as AnyObj;
1907
+ providerEvidenceBytes = effectiveStateJsonFlag === "-"
1908
+ ? fs.readFileSync(0)
1909
+ : readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
1910
+ const parsed = JSON.parse(providerEvidenceBytes.toString("utf8")) as AnyObj;
1853
1911
  const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
1912
+ const assignment = parsed && typeof parsed === "object" ? (parsed.assignment as AnyObj | undefined) : undefined;
1913
+ const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record as AnyObj : undefined;
1854
1914
  const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1855
1915
  if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1856
1916
  die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
1857
1917
  }
1918
+ if (workItemRef && (parsed.role !== "AssignmentStatus"
1919
+ || parsed.provider !== assignmentProviderKind
1920
+ || assignment?.provider !== assignmentProviderKind
1921
+ || assignment?.subject_id !== slug
1922
+ || record?.role !== "AssignmentClaimRecord"
1923
+ || record?.status !== "claimed"
1924
+ || record?.subject_id !== slug
1925
+ || record?.work_item_ref !== workItemRef
1926
+ || record?.actor_key !== resolution.branchActorKey
1927
+ || assignment?.assignee !== resolution.branchActorKey
1928
+ || !record.actor || typeof record.actor !== "object"
1929
+ || record.actor.runtime !== resolution.actorStruct.runtime
1930
+ || record.actor.session_id !== resolution.actorStruct.session_id
1931
+ || record.actor.host !== resolution.actorStruct.host
1932
+ || (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
1933
+ || candidate.effective_state !== "held"
1934
+ || candidate.reason !== "self_is_holder"
1935
+ || candidate.holder?.actor !== resolution.branchActorKey)) {
1936
+ die("ensure-session --effective-state-json must be the configured provider's AssignmentStatus for this Work Item, with a claimed record and self_is_holder actor matching the current runtime");
1937
+ }
1858
1938
  effective = candidate as EffectiveResult;
1859
1939
  } else if (assignmentProviderKind === "local-file") {
1860
1940
  // Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
@@ -1890,10 +1970,8 @@ function enforceEnsureSessionOwnership(
1890
1970
  return null;
1891
1971
  }
1892
1972
 
1893
- const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
1894
- // Only a claim read from the local provider is durable evidence. Precomputed provider state
1895
- // can authorize entry, but it cannot prove that this process acquired the Work Item.
1896
- if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
1973
+ const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null => {
1974
+ if (!workItemRef) return null;
1897
1975
  const assignment = readLocalAssignmentStatus(root, slug);
1898
1976
  const record = assignment.record;
1899
1977
  if (!record
@@ -1906,6 +1984,8 @@ function enforceEnsureSessionOwnership(
1906
1984
  return {
1907
1985
  assignmentFile: assignmentFilePath(root, slug),
1908
1986
  actorKey: resolution.branchActorKey,
1987
+ ...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
1988
+ ...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
1909
1989
  };
1910
1990
  };
1911
1991
 
@@ -1921,7 +2001,22 @@ function enforceEnsureSessionOwnership(
1921
2001
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1922
2002
  // effective_state computation instead of silently using a different identity.
1923
2003
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1924
- if (isSelf) return selectedWorkEvidence();
2004
+ if (isSelf) {
2005
+ if (assignmentProviderKind !== "local-file") {
2006
+ const local = readLocalAssignmentStatus(root, slug).record;
2007
+ if (!local || local.status !== "claimed") {
2008
+ performLocalClaim(root, slug, resolution.actorStruct, {
2009
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
2010
+ branch: resolveBranchForClaim(),
2011
+ artifactDir: path.relative(root, dir) || ".",
2012
+ reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
2013
+ actorKey: resolution.branchActorKey,
2014
+ ...(workItemRef ? { workItemRef } : {}),
2015
+ });
2016
+ }
2017
+ }
2018
+ return selectedWorkEvidence();
2019
+ }
1925
2020
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1926
2021
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1927
2022
  die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
@@ -1972,6 +2067,9 @@ function enforceEnsureSessionOwnership(
1972
2067
  return selectedWorkEvidence();
1973
2068
  }
1974
2069
  case "free": {
2070
+ if (assignmentProviderKind !== "local-file") {
2071
+ die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
2072
+ }
1975
2073
  performLocalClaim(root, slug, resolution.actorStruct, {
1976
2074
  ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1977
2075
  branch: resolveBranchForClaim(),
@@ -2024,11 +2122,11 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
2024
2122
  return { flowId, stepId: explicitStep || firstStep, firstStep };
2025
2123
  }
2026
2124
 
2027
- function assertCanonicalBuilderArtifactRoot(root: string): void {
2125
+ function assertCanonicalBuilderArtifactRoot(root: string, flowId: "builder.build" | "builder.shape"): void {
2028
2126
  const kontouraiRoot = path.dirname(root);
2029
2127
  const projectRoot = path.dirname(kontouraiRoot);
2030
2128
  if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
2031
- die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
2129
+ die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
2032
2130
  }
2033
2131
 
2034
2132
  const statIfPresent = (candidate: string): fs.Stats | null => {
@@ -2041,7 +2139,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2041
2139
  };
2042
2140
  const projectStat = statIfPresent(projectRoot);
2043
2141
  if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
2044
- die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
2142
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
2045
2143
  }
2046
2144
  const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
2047
2145
  for (const [candidate, expected, label] of [
@@ -2051,7 +2149,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2051
2149
  const stat = statIfPresent(candidate);
2052
2150
  if (!stat) continue;
2053
2151
  if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2054
- die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2152
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
2055
2153
  }
2056
2154
  }
2057
2155
  }
@@ -2062,17 +2160,17 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
2062
2160
  const dir = sessionDirFor(root, slug);
2063
2161
  assertSafeSessionDirectory(root, dir);
2064
2162
  const entry = resolveEnsureSessionEntry(p, dir);
2065
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2163
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2066
2164
  sessionWorkItem(p, slug, dir);
2067
2165
  }
2068
2166
 
2069
- async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2167
+ async function ensureSession(p: ReturnType<typeof parseArgs>, allowCanonicalFlowMutation: boolean): Promise<number> {
2070
2168
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2071
2169
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
2072
2170
  const dir = sessionDirFor(root, slug);
2073
2171
  assertSafeSessionDirectory(root, dir);
2074
2172
  const entry = resolveEnsureSessionEntry(p, dir);
2075
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2173
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2076
2174
  const workItem = sessionWorkItem(p, slug, dir);
2077
2175
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
2078
2176
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -2082,8 +2180,18 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2082
2180
  const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2083
2181
  ? workItem.ref
2084
2182
  : undefined;
2085
- const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2183
+ let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2086
2184
  ensureSafeDirectory(root, dir);
2185
+ if (selectedWorkEvidence?.providerEvidenceBytes) {
2186
+ const staged = path.join(dir, "assignment-provider-state.json");
2187
+ if (fs.existsSync(staged)) {
2188
+ if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes)) die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
2189
+ } else {
2190
+ fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
2191
+ }
2192
+ fs.chmodSync(staged, 0o400);
2193
+ selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
2194
+ }
2087
2195
  const timestamp = opt(p, "timestamp", now());
2088
2196
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2089
2197
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2096,13 +2204,26 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2096
2204
  // takeover continuity true by construction (see Design Decision 3 in the plan).
2097
2205
  const branch = resolveSessionBranch(p, slug);
2098
2206
  const initialMarkdownStatus = entry.flowId ? "new" : "planning";
2099
- md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: ${initialMarkdownStatus}\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
2207
+ const acceptanceCriteria = opts(p, "criterion");
2208
+ if (acceptanceCriteria.length === 0) acceptanceCriteria.push(`Complete the requested outcome: ${opt(p, "summary", "Workflow session is durable.")}`);
2209
+ md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: ${initialMarkdownStatus}\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${acceptanceCriteria.map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
2100
2210
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
2101
2211
  }
2102
2212
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
2103
2213
  const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
2104
2214
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2105
- const startCommand = `flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}`;
2215
+ const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
2216
+ "workflow", "start",
2217
+ "--flow", entry.flowId,
2218
+ ...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
2219
+ ...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
2220
+ "--assignment-provider", opt(p, "assignment-provider", "local-file"),
2221
+ ...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
2222
+ "--artifact-root", root,
2223
+ "--title", opt(p, "title", slug),
2224
+ "--summary", opt(p, "summary", "Workflow session is durable."),
2225
+ ...opts(p, "criterion").flatMap((criterion) => ["--criterion", criterion]),
2226
+ ]);
2106
2227
  const nextAction: string | AnyObj = entry.flowId
2107
2228
  ? entry.flowId === "builder.build"
2108
2229
  ? {
@@ -2140,9 +2261,9 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2140
2261
  ? persistedCurrent.active_step_id
2141
2262
  : entry.stepId;
2142
2263
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2143
- if (entry.flowId === "builder.build") {
2264
+ if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
2144
2265
  try {
2145
- const started = await startBuilderFlowSession({ sessionDir: dir });
2266
+ const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
2146
2267
  if (started.run.state.current_step === "pull-work"
2147
2268
  && selectedWorkEvidence
2148
2269
  && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
@@ -2157,8 +2278,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2157
2278
  }
2158
2279
  const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2159
2280
  const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2281
+ const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
2282
+ ? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
2283
+ : null;
2284
+ if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
2285
+ die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
2286
+ }
2287
+ const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
2160
2288
  const projectRoot = path.dirname(path.dirname(root));
2161
2289
  const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2290
+ const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
2291
+ let reportIsValid = false;
2292
+ try {
2293
+ const reportStat = fs.lstatSync(pullWorkReport);
2294
+ reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
2295
+ } catch { /* handled by the stable contract error below */ }
2296
+ if (!reportIsValid) {
2297
+ die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
2298
+ }
2162
2299
  await recordGateClaim(parseArgs([
2163
2300
  "record-gate-claim",
2164
2301
  dir,
@@ -2171,8 +2308,25 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2171
2308
  file: evidenceFile,
2172
2309
  summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2173
2310
  }),
2311
+ ...(selectedWorkEvidence.providerEvidenceFile ? [
2312
+ "--evidence-ref-json", JSON.stringify({
2313
+ kind: "artifact",
2314
+ file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
2315
+ summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
2316
+ }),
2317
+ ] : []),
2318
+ "--evidence-ref-json", JSON.stringify({
2319
+ kind: "artifact",
2320
+ file: path.relative(projectRoot, pullWorkReport),
2321
+ summary: `Concrete pull-work selection report for ${workItem.ref}.`,
2322
+ }),
2174
2323
  "--timestamp", timestamp,
2175
2324
  ]));
2325
+ if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
2326
+ && !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
2327
+ die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
2328
+ }
2329
+ await syncBuilderFlowSession({ sessionDir: dir });
2176
2330
  });
2177
2331
  }
2178
2332
  } catch (error) {
@@ -2284,6 +2438,373 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
2284
2438
  return validateEvidenceRef({ ...ref as AnyObj }, label);
2285
2439
  });
2286
2440
  }
2441
+
2442
+ function canonicalProjectRootForSession(dir: string): string {
2443
+ const artifactRoot = path.dirname(dir);
2444
+ const kontouraiRoot = path.dirname(artifactRoot);
2445
+ if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") die("gate evidence requires a canonical .kontourai/flow-agents session");
2446
+ const projectRoot = path.dirname(kontouraiRoot);
2447
+ const stat = fs.lstatSync(projectRoot);
2448
+ if (stat.isSymbolicLink() || !stat.isDirectory()) die("gate evidence project root must be a non-symlink directory");
2449
+ return projectRoot;
2450
+ }
2451
+
2452
+ function pathIsWithinRoot(candidate: string, root: string): boolean {
2453
+ const relative = path.relative(root, candidate);
2454
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
2455
+ }
2456
+
2457
+ function validateLocalEvidenceFile(projectRoot: string, raw: string, label: string): string {
2458
+ const candidate = path.resolve(projectRoot, raw);
2459
+ if (!pathIsWithinRoot(candidate, projectRoot)) die(`${label} must remain inside the canonical project root`);
2460
+ let stat: fs.Stats;
2461
+ try { stat = fs.lstatSync(candidate); } catch { die(`${label} does not exist`); }
2462
+ if (stat.isSymbolicLink() || !stat.isFile()) die(`${label} must be a non-symlink regular file`);
2463
+ if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot))) die(`${label} escapes the canonical project root`);
2464
+ return path.relative(projectRoot, candidate);
2465
+ }
2466
+
2467
+ function globMatches(pattern: string, relative: string): boolean {
2468
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
2469
+ return new RegExp(`^${escaped}$`).test(relative);
2470
+ }
2471
+
2472
+ type GateProducer = { id: string; artifactPatterns: string[]; selfProducedTrustSlices: string[] };
2473
+
2474
+ function expectedGateProducer(flowId: string, stepId: string, expectationId: string): GateProducer {
2475
+ const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
2476
+ const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions as AnyObj[] : [];
2477
+ const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
2478
+ if (!action) die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
2479
+ const skills = Array.isArray(action.skills) ? action.skills.filter((value: unknown): value is string => typeof value === "string") : [];
2480
+ const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles as AnyObj[] : [];
2481
+ const owners = roles.filter((role) => typeof role.skill_id === "string"
2482
+ && Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
2483
+ && Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
2484
+ && skills.includes(role.skill_id.replace(/^builder\./, "")));
2485
+ if (owners.length === 0) {
2486
+ const operations = Array.isArray(action.operations) ? action.operations.filter((value: unknown): value is string => typeof value === "string") : [];
2487
+ const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
2488
+ if (operations.length === 1 && operationExpectations.includes(expectationId)) {
2489
+ const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value: unknown): value is string => typeof value === "string") : [];
2490
+ return {
2491
+ id: `operation:${operations[0]}`,
2492
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2493
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2494
+ };
2495
+ }
2496
+ }
2497
+ if (owners.length !== 1) die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
2498
+ const owner = owners[0]!;
2499
+ const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value): value is string => typeof value === "string" && value !== "ephemeral decision record");
2500
+ return {
2501
+ id: owner.skill_id,
2502
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2503
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2504
+ };
2505
+ }
2506
+
2507
+ function validateReviewableGateEvidence(dir: string, slug: string, refs: AnyObj[], producer: GateProducer, label: string): void {
2508
+ if (refs.length === 0) die(`${label} requires at least one reviewable --evidence-ref-json`);
2509
+ const projectRoot = canonicalProjectRootForSession(dir);
2510
+ let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
2511
+ let localOrCommandEvidenceFound = false;
2512
+ for (const [index, ref] of refs.entries()) {
2513
+ if (ref.kind === "command" && hasNonEmptyString(ref.excerpt)) localOrCommandEvidenceFound = true;
2514
+ if (typeof ref.file !== "string") continue;
2515
+ const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
2516
+ ref.file = relative;
2517
+ localOrCommandEvidenceFound = true;
2518
+ if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
2519
+ const patterns = producer.artifactPatterns.map((pattern) => {
2520
+ const expanded = pattern.replaceAll("<slug>", slug);
2521
+ return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
2522
+ });
2523
+ if (patterns.some((pattern) => globMatches(pattern, relative))) declaredProducerArtifactFound = true;
2524
+ }
2525
+ }
2526
+ if (!declaredProducerArtifactFound) die(`${label} requires a declared durable artifact from producer ${producer.id}`);
2527
+ if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
2528
+ die(`${label} is produced as trust.bundle fragment(s) ${producer.selfProducedTrustSlices.join(", ")} and requires local artifact or command evidence; external-only references cannot prove a passing claim`);
2529
+ }
2530
+ }
2531
+
2532
+ function commandFromEvidenceRef(ref: AnyObj): string {
2533
+ return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
2534
+ }
2535
+
2536
+ function hasTestIntent(name: string): boolean {
2537
+ return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
2538
+ }
2539
+
2540
+ function resolvesExplicitTestTarget(projectRoot: string, token: string): boolean {
2541
+ if (!hasTestIntent(token)) return false;
2542
+ try {
2543
+ if (/[*?\[]/.test(token)) return fs.globSync(token, { cwd: projectRoot }).length > 0;
2544
+ const target = path.resolve(projectRoot, token);
2545
+ return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
2546
+ } catch { return false; }
2547
+ }
2548
+
2549
+ /** Validate test-evidence command shape without executing it. */
2550
+ type TestExecutionProof = {
2551
+ kind: "local-process-exit";
2552
+ runner: string;
2553
+ static_test_units: number;
2554
+ };
2555
+
2556
+ function staticTestUnits(file: string, executable: string): number {
2557
+ try {
2558
+ const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
2559
+ const hashComments = !["cargo", "go"].includes(executable);
2560
+ const active = content.split("\n")
2561
+ .map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
2562
+ .filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
2563
+ .join("\n");
2564
+ if (["bash", "sh", "zsh"].includes(executable)
2565
+ && !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active)) return 0;
2566
+ const assertions = active.match(/(?:\b(?:test|it)\s*\(|\b(?:assert|expect)\s*\(|^\s*(?:async\s+)?def\s+test_|^\s*(?:public\s+)?function\s+test|^\s*it\s+["']|^\s*func\s+(?:Test|Fuzz|Example)\w*\s*\(|#\[(?:\w+::)?test\]|^(?:if\s+! ?)?(?:test\s|\[\s|\[\[\s|(?:grep|rg|diff|cmp)\s))/gm) ?? [];
2567
+ return assertions.length;
2568
+ } catch {
2569
+ return 0;
2570
+ }
2571
+ }
2572
+
2573
+ /**
2574
+ * Produce evidence from the locally executed command and statically reviewable
2575
+ * test units. Runner stdout is deliberately excluded: any executable can print
2576
+ * a Vitest/Jest-looking success summary, but it cannot turn a non-test script
2577
+ * into a supported test workflow or supply this locally-created proof.
2578
+ */
2579
+ export function testExecutionProof(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): TestExecutionProof | null {
2580
+ const normalized = command.trim().replace(/\s+/g, " ");
2581
+ if (!normalized || /[`$()]/.test(normalized)) return null;
2582
+ if (/[;&|]/.test(normalized)) {
2583
+ if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized)) return null;
2584
+ const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
2585
+ return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
2586
+ }
2587
+ if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized)) return null;
2588
+ if (/^(?:echo|printf)(?:\s|$)/i.test(normalized)) return null;
2589
+ if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized)) return null;
2590
+ const tokens = normalized.split(" ").filter(Boolean);
2591
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? "")) tokens.shift();
2592
+ const executable = tokens[0] ?? "";
2593
+ const executableName = path.basename(executable);
2594
+ if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
2595
+ // `bun test` is Bun's test runner; package scripts use `bun run <script>`.
2596
+ if (executableName === "bun" && tokens[1] === "test") {
2597
+ const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
2598
+ const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
2599
+ return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
2600
+ }
2601
+ const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
2602
+ if (!script || !hasTestIntent(script) || seenScripts.has(script)) return null;
2603
+ try {
2604
+ const pkg = loadJson(path.join(projectRoot, "package.json"));
2605
+ const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
2606
+ if (typeof scriptCommand !== "string") return null;
2607
+ seenScripts.add(script);
2608
+ return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
2609
+ } catch { return null; }
2610
+ }
2611
+ if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
2612
+ && !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
2613
+ if (executable !== executableName) return null;
2614
+ const targets = tokens.slice(1)
2615
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2616
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2617
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2618
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2619
+ }
2620
+ if (executableName === "cargo" && tokens[1] === "test") {
2621
+ const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
2622
+ const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
2623
+ return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
2624
+ }
2625
+ if (executableName === "go" && tokens[1] === "test") {
2626
+ const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
2627
+ const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
2628
+ return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
2629
+ }
2630
+ if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
2631
+ const runner = path.basename(tokens[1]);
2632
+ const targets = tokens.slice(2)
2633
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2634
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2635
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
2636
+ return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
2637
+ }
2638
+ if (executableName === "node" && tokens.includes("--test")) {
2639
+ const testFlag = tokens.indexOf("--test");
2640
+ const targets = tokens.slice(testFlag + 1)
2641
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2642
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2643
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2644
+ return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
2645
+ }
2646
+ const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
2647
+ if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath)) return null;
2648
+ try {
2649
+ const resolved = path.resolve(projectRoot, scriptPath);
2650
+ const stat = fs.lstatSync(resolved);
2651
+ if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot))) return null;
2652
+ const units = staticTestUnits(resolved, executableName);
2653
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2654
+ } catch { return null; }
2655
+ }
2656
+
2657
+ /** Validate test-evidence command shape without executing it. */
2658
+ export function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): boolean {
2659
+ return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
2660
+ }
2661
+
2662
+ export function observedExecutedTestCount(output: string): number {
2663
+ const counts: number[] = [];
2664
+ for (const pattern of [
2665
+ /^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
2666
+ /^\s*1\.\.(\d+)(?:\s|$)/gim,
2667
+ /\b(\d+)\s+passed\b/gim,
2668
+ ]) {
2669
+ for (const match of output.matchAll(pattern)) counts.push(Number(match[1]));
2670
+ }
2671
+ const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
2672
+ if (explicitPasses > 0) counts.push(explicitPasses);
2673
+ const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
2674
+ if (goPackages > 0) counts.push(goPackages);
2675
+ return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
2676
+ }
2677
+
2678
+ export function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts = new Set<string>()): number {
2679
+ const proof = testExecutionProof(command, projectRoot, seenScripts);
2680
+ if (!proof) return 0;
2681
+ const observed = observedExecutedTestCount(output);
2682
+ return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
2683
+ }
2684
+
2685
+ type ObservedCommand = { command: string; exit_code: number; output_sha256: string; test_count?: number; execution_proof?: TestExecutionProof };
2686
+
2687
+ async function normalizeObservedCommands(commands: string[], projectRoot: string, requireTestIntent: boolean, expectedStatus: string): Promise<ObservedCommand[]> {
2688
+ if (commands.length === 0) die("record-gate-claim requires at least one --command for observed command evidence");
2689
+ if (new Set(commands).size !== commands.length) die("record-gate-claim --command values must be unique");
2690
+ for (const command of commands) {
2691
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2692
+ if (!isRunnableCommandText(command)) die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2693
+ if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot)) die("record-gate-claim tests-evidence command must resolve through a non-vacuous package script or a known test/check/verify/eval runner or project-local test path; shell wrappers, no-ops, version/help commands, and arbitrary node -e commands are not evidence");
2694
+ }
2695
+ // Passing test evidence is always executed exactly once by this canonical
2696
+ // writer. Caller-supplied observations remain available for non-test
2697
+ // attestations but can never stand in for locally observed test execution.
2698
+ const observed = await Promise.all(commands.map(async (command) => {
2699
+ const result = await runObservedCommand(command, projectRoot);
2700
+ const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
2701
+ return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
2702
+ }));
2703
+ if (observed.length !== commands.length) die("record-gate-claim requires exactly one --observed-command-json for every --command");
2704
+ const byCommand = new Map<string, ObservedCommand>();
2705
+ for (const entry of observed) {
2706
+ if (typeof entry.command !== "string" || typeof entry.exit_code !== "number" || !Number.isInteger(entry.exit_code) || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256)) die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
2707
+ if (!commands.includes(entry.command)) die("--observed-command-json command must exactly match one supplied --command");
2708
+ if (expectedStatus === "pass" && entry.exit_code !== 0) die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
2709
+ if (expectedStatus === "fail" && entry.exit_code === 0) die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
2710
+ if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit")) die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
2711
+ if (byCommand.has(entry.command)) die("--observed-command-json command values must be unique");
2712
+ byCommand.set(entry.command, entry as ObservedCommand);
2713
+ }
2714
+ if (commands.some((command) => !byCommand.has(command))) die("every --command requires a corresponding --observed-command-json");
2715
+ return commands.map((command) => byCommand.get(command)!);
2716
+ }
2717
+
2718
+ function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySet<string>, label: string, requireAll = false): void {
2719
+ const commandRefs = refs.filter((ref) => ref.kind === "command");
2720
+ if (commandRefs.length === 0) die(`${label} requires a command evidence ref matching a successful observed command`);
2721
+ for (const ref of commandRefs) {
2722
+ if (!observedCommands.has(commandFromEvidenceRef(ref))) die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
2723
+ }
2724
+ if (requireAll) {
2725
+ const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
2726
+ if ([...observedCommands].some((command) => !referenced.has(command))) die(`${label} requires a top-level command evidence ref for every successful observed command`);
2727
+ }
2728
+ }
2729
+
2730
+ function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>): AnyObj[] {
2731
+ if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2732
+ const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
2733
+ const expectedById = new Map<string, AnyObj>();
2734
+ for (const criterion of existing) {
2735
+ const id = String(criterion.id ?? "");
2736
+ if (id.length > 0) expectedById.set(id, criterion);
2737
+ }
2738
+ const expectedIds = [...expectedById.keys()];
2739
+ const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
2740
+ if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
2741
+ die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
2742
+ }
2743
+ return incoming.map((criterion, index) => {
2744
+ if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key))) die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
2745
+ if (criterion.status !== "pass") die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
2746
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
2747
+ if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2748
+ requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2749
+ return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs };
2750
+ });
2751
+ }
2752
+
2753
+ const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
2754
+ const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
2755
+
2756
+ function normalizeCritiqueLanes(raw: string[]): AnyObj[] {
2757
+ if (raw.length === 0) die("record-critique requires at least one --lane-json");
2758
+ const lanes = raw.map((value, index) => {
2759
+ const lane = parseJson(value, "--lane-json");
2760
+ const keys = Object.keys(lane);
2761
+ if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key))) die(`--lane-json ${index} contains unsupported fields`);
2762
+ if (!safeCritiqueId.test(String(lane.id ?? ""))) die(`--lane-json ${index} id must be a unique safe identifier`);
2763
+ if (!critiqueStatuses.has(String(lane.status ?? ""))) die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
2764
+ if (!hasNonEmptyString(lane.summary)) die(`--lane-json ${index} summary must be non-empty`);
2765
+ const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
2766
+ if (evidenceRefs.length === 0) die(`--lane-json ${index} requires structured reviewable evidence_refs`);
2767
+ return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
2768
+ });
2769
+ if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) die("--lane-json ids must be unique");
2770
+ return lanes;
2771
+ }
2772
+
2773
+ function reviewTargetArtifacts(dir: string, rawPaths: string[], label: string): AnyObj[] {
2774
+ const projectRoot = canonicalProjectRootForSession(dir);
2775
+ const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
2776
+ const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
2777
+ const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
2778
+ if (new Set(files).size !== files.length) die(`${label} artifact refs must be unique`);
2779
+ return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
2780
+ }
2781
+
2782
+ function reviewTargetArtifactsMatch(dir: string, reviewTarget: unknown): boolean {
2783
+ try {
2784
+ if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget)) return false;
2785
+ const artifacts = (reviewTarget as AnyObj).artifacts;
2786
+ if (!Array.isArray(artifacts) || artifacts.length === 0) return false;
2787
+ const projectRoot = canonicalProjectRootForSession(dir);
2788
+ const files = new Set<string>();
2789
+ return artifacts.every((artifact) => {
2790
+ if (!artifact || typeof artifact !== "object") return false;
2791
+ const file = (artifact as AnyObj).file;
2792
+ const sha256 = (artifact as AnyObj).sha256;
2793
+ if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file)) return false;
2794
+ files.add(file);
2795
+ const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
2796
+ const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
2797
+ return digest === sha256;
2798
+ });
2799
+ } catch { return false; }
2800
+ }
2801
+
2802
+ function critiqueIsCleanAndCurrent(dir: string, critique: AnyObj): boolean {
2803
+ if (critique.verdict !== "pass") return false;
2804
+ if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane: AnyObj) => lane.status !== "pass")) return false;
2805
+ if (Array.isArray(critique.findings) && critique.findings.some((finding: AnyObj) => finding.status === "open")) return false;
2806
+ return reviewTargetArtifactsMatch(dir, critique.review_target);
2807
+ }
2287
2808
  // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2288
2809
  // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2289
2810
  // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
@@ -2585,6 +3106,17 @@ function checksFromBundle(dir: string): AnyObj[] {
2585
3106
  const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
2586
3107
  return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2587
3108
  };
3109
+ const observedCommandsOf = (claim: AnyObj): ObservedCommand[] | undefined => {
3110
+ const md = claim.metadata as AnyObj;
3111
+ const observed = md && typeof md === "object" ? md.observed_commands : undefined;
3112
+ return Array.isArray(observed) ? observed.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
3113
+ };
3114
+ const applyProducerStamp = (check: AnyObj, claim: AnyObj): void => {
3115
+ const md = claim.metadata as AnyObj;
3116
+ if (md && typeof md.expected_producer === "string") check._producer = md.expected_producer;
3117
+ if (md && typeof md.recorded_by === "string") check._recorded_by = md.recorded_by;
3118
+ if (md && Array.isArray(md.self_produced_trust_slices)) check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
3119
+ };
2588
3120
  // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2589
3121
  // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2590
3122
  // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
@@ -2658,6 +3190,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2658
3190
  Object.assign(check, refsOf(claim));
2659
3191
  const outputSha256 = outputSha256Of(claim);
2660
3192
  if (outputSha256) check._output_sha256 = outputSha256;
3193
+ const observedCommands = observedCommandsOf(claim);
3194
+ if (observedCommands) check._observed_commands = observedCommands;
3195
+ applyProducerStamp(check, claim);
2661
3196
  applyGateClaimStamp(check, claim);
2662
3197
  applyGateClaimShapeUnstamped(check, claim);
2663
3198
  checks.push(check);
@@ -2675,6 +3210,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2675
3210
  Object.assign(check, refsOf(claim));
2676
3211
  const outputSha256 = outputSha256Of(claim);
2677
3212
  if (outputSha256) check._output_sha256 = outputSha256;
3213
+ const observedCommands = observedCommandsOf(claim);
3214
+ if (observedCommands) check._observed_commands = observedCommands;
3215
+ applyProducerStamp(check, claim);
2678
3216
  applyGateClaimStamp(check, claim);
2679
3217
  applyGateClaimShapeUnstamped(check, claim);
2680
3218
  checks.push(check);
@@ -2709,9 +3247,18 @@ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
2709
3247
  */
2710
3248
  function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
2711
3249
  const acceptance = loadJson(path.join(dir, "acceptance.json"));
3250
+ const bundledCriteria = criteriaFromBundle(dir);
3251
+ const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria as AnyObj[] : [];
3252
+ const contractSignature = (criteria: AnyObj[]): string => JSON.stringify(criteria.map((criterion) => ({
3253
+ id: criterion.id ?? null,
3254
+ description: criterion.description ?? null,
3255
+ })));
3256
+ const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
3257
+ ? acceptedCriteria
3258
+ : (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
2712
3259
  return {
2713
3260
  checks: checksFromBundle(dir),
2714
- criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
3261
+ criteria,
2715
3262
  critiques: critiquesFromBundle(dir),
2716
3263
  };
2717
3264
  }
@@ -2742,14 +3289,35 @@ function critiquesFromBundle(dir: string): AnyObj[] {
2742
3289
  id: String(c.subjectId || "").split("/").pop() || c.id,
2743
3290
  verdict: c.value ?? "not_verified",
2744
3291
  summary: c.fieldOrBehavior || "",
2745
- findings: [],
3292
+ findings: Array.isArray(md.findings) ? md.findings : [],
3293
+ lanes: Array.isArray(md.lanes) ? md.lanes : [],
3294
+ review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
2746
3295
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
2747
3296
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
2748
- artifact_refs: [],
3297
+ artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
2749
3298
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
2750
3299
  };
2751
3300
  });
2752
3301
  }
3302
+
3303
+ function criteriaFromBundle(dir: string): AnyObj[] {
3304
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
3305
+ if (!Array.isArray(bundle.claims)) return [];
3306
+ for (const claim of bundle.claims) requireStampedClaim(claim, dir);
3307
+ return bundle.claims
3308
+ .filter((claim: AnyObj) => claim && claimOrigin(claim) === "acceptance")
3309
+ .map((claim: AnyObj) => {
3310
+ const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata as AnyObj : {};
3311
+ const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion as AnyObj : {};
3312
+ return {
3313
+ id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
3314
+ description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3315
+ status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3316
+ evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3317
+ };
3318
+ })
3319
+ .filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
3320
+ }
2753
3321
  // ─────────────────────────────────────────────────────────────────────────────
2754
3322
  /**
2755
3323
  * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
@@ -2808,13 +3376,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
2808
3376
  // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2809
3377
  // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2810
3378
  // exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
2811
- const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
2812
- const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
2813
3379
  const _mergedChecks = mergeChecksById(_existingState.checks, checks);
2814
3380
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
2815
3381
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2816
3382
  // whenever it ran after a critique existed.
2817
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
3383
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2818
3384
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
2819
3385
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
2820
3386
  return 0;
@@ -3019,14 +3585,22 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3019
3585
  if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
3020
3586
  if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
3021
3587
 
3022
- // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
3023
- // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
3024
- // unconditional legacy-only read this is the fix for record-gate-claim's pre-existing (#291
3025
- // Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
3026
- // view, not a different actor's more-recently-written legacy pointer.
3588
+ // Prefer the exact session's canonical Flow projection. Actor/global current pointers are
3589
+ // ambient navigation state and may legitimately point at another run or lag this run.
3590
+ // Legacy sessions without flow_run retain the per-actor current-pointer fallback.
3027
3591
  const flowAgentsDir = path.dirname(dir);
3028
3592
  const gateClaimActorKey = resolveReadActorKey(p);
3029
- const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3593
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3594
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3595
+ ? sidecarState.flow_run as AnyObj
3596
+ : null;
3597
+ const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
3598
+ const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
3599
+ const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
3600
+ const activeStep = exactFlowContext
3601
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
3602
+ : resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3603
+ if (exactFlowContext && !activeStep) die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
3030
3604
  if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
3031
3605
  if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
3032
3606
  die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
@@ -3047,18 +3621,34 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3047
3621
  }
3048
3622
 
3049
3623
  const { claimType, subjectType } = targetExpectation.bundle_claim;
3624
+ const gateCommands = opts(p, "command");
3625
+ const gateCommand = gateCommands[0] ?? "";
3626
+ const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
3627
+ const observedCommandRaw = opts(p, "observed-command-json");
3628
+ if (mustRunTests && gateCommands.length === 0) die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
3629
+ const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
3630
+ const observedCommands = gateCommands.length > 0
3631
+ ? await normalizeObservedCommands(gateCommands, projectRoot!, mustRunTests, statusVal)
3632
+ : [];
3633
+ const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
3634
+ let outputSha256: string | null = null;
3635
+ if (!mustRunTests && gateCommands.length > 1) die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
3636
+ if (gateCommands.length === 0 && observedCommandRaw.length > 0) die("--observed-command-json requires --command");
3637
+ if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
3638
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
3639
+ if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3640
+ }
3641
+ if (observedCommands.length > 0) outputSha256 = observedCommands[0]!.output_sha256;
3050
3642
 
3051
- // Build a synthetic external check that will be matched by matchExpectsEntry to produce
3052
- // a correctly-typed claim. We use kind="external" so it routes through the non-policy,
3053
- // non-flow-step fallback path. The subjectType on the resulting claim comes from the
3054
- // expects[] entry via matchExpectsEntry.
3643
+ // Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
3644
+ // Command-backed checks retain their executable evidence classification.
3055
3645
  const checkId = expectationId || targetExpectation.id;
3056
3646
  // Build a minimal "external" check. Include _gate_claim_expectation_id so that
3057
3647
  // matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
3058
3648
  // normalizeCheck preserves extra underscore-prefixed fields without stripping them.
3059
3649
  const check: AnyObj = {
3060
3650
  id: `gate-claim-${checkId}`,
3061
- kind: "external",
3651
+ kind: gateCommands.length > 0 ? "command" : "external",
3062
3652
  status: statusVal,
3063
3653
  summary,
3064
3654
  _gate_claim_expectation_id: targetExpectation.id,
@@ -3067,10 +3657,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3067
3657
 
3068
3658
  // Include structured evidence refs if provided
3069
3659
  const evidenceRefs: AnyObj[] = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
3660
+ const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
3661
+ if (statusVal === "pass") validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
3662
+ if (mustRunTests) requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
3070
3663
 
3071
3664
  if (evidenceRefs.length > 0) {
3072
3665
  check.artifact_refs = evidenceRefs;
3073
3666
  }
3667
+ check._producer = producer.id;
3668
+ check._recorded_by = gateClaimActorKey;
3669
+ if (producer.selfProducedTrustSlices.length > 0) check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
3074
3670
 
3075
3671
  // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
3076
3672
  // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
@@ -3078,14 +3674,11 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3078
3674
  // prose itself), which it would otherwise execute as a shell command under
3079
3675
  // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
3080
3676
  // heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
3081
- const gateCommand = opt(p, "command");
3082
- if (gateCommand) {
3083
- const { isRunnableCommandText } = loadRunnableCommandHelper();
3084
- if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3085
- check.command = gateCommand;
3086
- }
3677
+ if (gateCommand) check.command = gateCommand;
3678
+ if (observedCommands.length > 0) check._observed_commands = observedCommands;
3087
3679
 
3088
3680
  const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
3681
+ if (outputSha256) checkNormalized._output_sha256 = outputSha256;
3089
3682
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
3090
3683
  const gateWaiver = parseWaiver(p, ts);
3091
3684
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
@@ -3097,8 +3690,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3097
3690
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3098
3691
  // gate claim against a different expectation is additive.
3099
3692
  const _existingState = readBundleState(dir);
3693
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
3694
+ if (mustRunTests) {
3695
+ const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3696
+ if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
3697
+ die("a passing tests-evidence claim requires a current clean critique first");
3698
+ }
3699
+ for (const criterion of criteria) validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
3700
+ }
3100
3701
  const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
3101
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
3702
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
3102
3703
  return 0;
3103
3704
  }
3104
3705
 
@@ -3280,12 +3881,46 @@ export function normalizeFinding(raw: AnyObj): AnyObj {
3280
3881
  async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number> {
3281
3882
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3282
3883
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3283
- // Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
3284
- // Fall back to critique.json for legacy sessions that still have it on disk.
3285
- const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
3286
- const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
3287
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
3288
- const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
3884
+ const verdict = opt(p, "verdict");
3885
+ if (!critiqueStatuses.has(verdict)) die("record-critique requires --verdict pass, fail, or not_verified");
3886
+ const summary = opt(p, "summary");
3887
+ if (!hasNonEmptyString(summary)) die("record-critique requires --summary");
3888
+ const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
3889
+ const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
3890
+ if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
3891
+ die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
3892
+ }
3893
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3894
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3895
+ ? sidecarState.flow_run as AnyObj
3896
+ : null;
3897
+ const exactFlowContext = projectedRun
3898
+ && typeof projectedRun.definition_id === "string"
3899
+ && typeof projectedRun.current_step === "string"
3900
+ ? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
3901
+ : undefined;
3902
+ // trust.bundle is the sole critique source. critique.json is unsupported historical residue.
3903
+ const _critiqueState = readBundleState(dir);
3904
+ const bundleCritiques = _critiqueState.critiques;
3905
+ const critiqueId = opt(p, "id", "review");
3906
+ if (!safeCritiqueId.test(critiqueId)) die("record-critique --id must be a safe identifier");
3907
+ const critique = {
3908
+ id: critiqueId,
3909
+ reviewer: opt(p, "reviewer", "tool-code-reviewer"),
3910
+ reviewed_at: opt(p, "timestamp", now()),
3911
+ verdict,
3912
+ summary,
3913
+ lanes,
3914
+ review_target: {
3915
+ artifacts: reviewArtifacts,
3916
+ workspace_snapshot: captureReviewWorkspaceSnapshot(
3917
+ canonicalProjectRootForSession(dir),
3918
+ reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) })),
3919
+ ),
3920
+ },
3921
+ artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
3922
+ findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
3923
+ };
3289
3924
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
3290
3925
  // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
3291
3926
  // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
@@ -3310,8 +3945,7 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3310
3945
  // checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
3311
3946
  // already exists to share; recordLearning uses it directly (see below) and recordCritique
3312
3947
  // previously duplicated it by hand for no reason.
3313
- const _critiqueState = readBundleState(dir);
3314
- assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
3948
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
3315
3949
  return 0;
3316
3950
  }
3317
3951
  function frontmatter(text: string, key: string): string {
@@ -3334,7 +3968,22 @@ async function importCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3334
3968
  const title = m.groups?.title ?? "finding";
3335
3969
  findings.push({ id: slugify(title, `finding-${findings.length + 1}`), severity: (m.groups?.severity ?? "info").toLowerCase(), status: opt(p, "finding-status", verdict === "pass" ? "fixed" : "open"), description: title, file_refs: [m.groups?.target ?? review] });
3336
3970
  }
3337
- const parsed = { ...p, positional: [dir], opts: { ...p.opts, id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")], reviewer: ["tool-code-reviewer"], verdict: [verdict], summary: [`Imported critique from ${path.basename(review)}`], "finding-json": findings.map((f) => JSON.stringify(f)) }, flags: p.flags };
3971
+ const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
3972
+ const parsed = {
3973
+ ...p,
3974
+ positional: [dir],
3975
+ opts: {
3976
+ ...p.opts,
3977
+ id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
3978
+ reviewer: ["tool-code-reviewer"],
3979
+ verdict: [verdict],
3980
+ summary: [`Imported critique from ${path.basename(review)}`],
3981
+ "artifact-ref": [importedArtifact],
3982
+ "lane-json": [JSON.stringify({ id: "imported-review", status: verdict, summary: `Imported review artifact ${path.basename(review)}.`, evidence_refs: [{ kind: "artifact", file: importedArtifact, summary: "Imported review artifact." }] })],
3983
+ "finding-json": findings.map((f) => JSON.stringify(f)),
3984
+ },
3985
+ flags: p.flags,
3986
+ };
3338
3987
  const result = await recordCritique(parsed);
3339
3988
  if (verdict !== "pass") die("required critique must pass");
3340
3989
  return result;
@@ -4755,10 +5404,7 @@ function evidenceClean(dir: string): boolean {
4755
5404
  });
4756
5405
  }
4757
5406
  function critiqueClean(dir: string): boolean {
4758
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
4759
- // legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
4760
- // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
4761
- // for an unstamped claim (#268/#344).
5407
+ // trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
4762
5408
  const bundle = loadJson(path.join(dir, "trust.bundle"));
4763
5409
  if (Array.isArray(bundle.claims)) {
4764
5410
  for (const c of bundle.claims) requireStampedClaim(c, dir);
@@ -4769,14 +5415,11 @@ function critiqueClean(dir: string): boolean {
4769
5415
  return claimOrigin(c) === "critique";
4770
5416
  });
4771
5417
  if (critiqueClaims.length === 0) return false; // no critique written yet
4772
- return critiqueClaims.every((c: AnyObj) => {
4773
- const v = String(c.value || "");
4774
- return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
4775
- });
5418
+ return critiquesFromBundle(dir)
5419
+ .filter((critique) => !critique.superseded_by)
5420
+ .every((critique) => critiqueIsCleanAndCurrent(dir, critique));
4776
5421
  }
4777
- // Legacy fallback: critique.json
4778
- const c = loadJson(path.join(dir, "critique.json"), {});
4779
- return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x: AnyObj) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f: AnyObj) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
5422
+ return false;
4780
5423
  }
4781
5424
  function assertExistingLearningValid(dir: string): void {
4782
5425
  const file = path.join(dir, "learning.json");
@@ -4821,8 +5464,7 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
4821
5464
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
4822
5465
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4823
5466
  if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
4824
- // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
4825
- if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
5467
+ if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle"))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4826
5468
  }
4827
5469
  }
4828
5470
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
@@ -5142,7 +5784,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
5142
5784
  // Locate trust.bundle — required per SKILL.md contract
5143
5785
  const bundlePath = path.join(dir, "trust.bundle");
5144
5786
  if (!fs.existsSync(bundlePath)) {
5145
- process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
5787
+ process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
5146
5788
  return 1;
5147
5789
  }
5148
5790
 
@@ -5421,7 +6063,7 @@ function loadLivenessReadHelper(): {
5421
6063
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
5422
6064
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
5423
6065
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
5424
- export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
6066
+ export const LIVENESS_TERMINAL = new Set(["delivered", "canceled", "accepted", "archived"]);
5425
6067
  /**
5426
6068
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
5427
6069
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
@@ -5889,8 +6531,12 @@ Available claim ids:
5889
6531
  // ─────────────────────────────────────────────────────────────────────────────
5890
6532
 
5891
6533
 
5892
- async function main(): Promise<number> {
5893
- const _rawArgv = process.argv.slice(2);
6534
+ export function mainFromPublicWorkflow(argv: string[]): Promise<number> {
6535
+ return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
6536
+ }
6537
+
6538
+ export async function main(argv: string[] = process.argv.slice(2), authority?: symbol): Promise<number> {
6539
+ const _rawArgv = argv;
5894
6540
  // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5895
6541
  // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
5896
6542
  // legitimately contains its OWN `--`, so only the record-check dispatcher's own separator, the
@@ -5929,7 +6575,7 @@ async function main(): Promise<number> {
5929
6575
  : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
5930
6576
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
5931
6577
  switch (p.command) {
5932
- case "ensure-session": return ensureSession(p);
6578
+ case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
5933
6579
  case "current": return current(p);
5934
6580
  case "record-agent-event": return recordAgentEvent(p);
5935
6581
  case "init-plan": return initPlan(p);
@@ -5965,5 +6611,8 @@ async function main(): Promise<number> {
5965
6611
  const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
5966
6612
  const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
5967
6613
  if (_selfRealPath === _argv1RealPath) {
6614
+ if (path.basename(process.argv[1] ?? "") === "flow-agents-workflow-sidecar") {
6615
+ process.stderr.write("flow-agents-workflow-sidecar is deprecated; use `flow-agents workflow` or an explicitly pinned `npx @kontourai/flow-agents@<version> workflow` command.\n");
6616
+ }
5968
6617
  main().then((code) => process.exit(code)).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });
5969
6618
  }