@kontourai/flow-agents 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +308 -47
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +67 -13
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +801 -97
  11. package/build/src/cli/workflow.js +290 -42
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/generate-context-map.js +5 -3
  18. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  19. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  20. package/docs/context-map.md +22 -20
  21. package/docs/developer-architecture.md +1 -1
  22. package/docs/public-workflow-cli.md +76 -7
  23. package/docs/skills-map.md +51 -27
  24. package/docs/spec/builder-flow-runtime.md +41 -40
  25. package/docs/workflow-usage-guide.md +109 -42
  26. package/evals/fixtures/hook-influence/cases.json +2 -2
  27. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  28. package/evals/integration/test_builder_step_producers.sh +297 -6
  29. package/evals/integration/test_bundle_install.sh +212 -63
  30. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  31. package/evals/integration/test_current_json_per_actor.sh +12 -0
  32. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  33. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  34. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  35. package/evals/integration/test_gate_lockdown.sh +3 -5
  36. package/evals/integration/test_goal_fit_hook.sh +60 -2
  37. package/evals/integration/test_liveness_verdict.sh +14 -17
  38. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  39. package/evals/integration/test_public_workflow_cli.sh +325 -11
  40. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  41. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  42. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  43. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  44. package/evals/run.sh +2 -0
  45. package/evals/static/test_builder_skill_coherence.sh +247 -0
  46. package/evals/static/test_library_exports.sh +5 -2
  47. package/evals/static/test_workflow_skills.sh +13 -325
  48. package/kits/builder/flows/build.flow.json +22 -0
  49. package/kits/builder/flows/shape.flow.json +9 -9
  50. package/kits/builder/kit.json +70 -16
  51. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  52. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  53. package/kits/builder/skills/deliver/SKILL.md +96 -442
  54. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  55. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  56. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  57. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  58. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  59. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  60. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  61. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  62. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  63. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  64. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  65. package/kits/builder/skills/review-work/SKILL.md +89 -176
  66. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  67. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  68. package/package.json +2 -2
  69. package/scripts/hooks/lib/kit-catalog.js +1 -1
  70. package/scripts/hooks/stop-goal-fit.js +78 -9
  71. package/src/builder-flow-run-adapter.ts +118 -32
  72. package/src/builder-flow-runtime.ts +331 -61
  73. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  74. package/src/cli/assignment-provider.ts +62 -13
  75. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  76. package/src/cli/builder-flow-runtime.test.mjs +194 -8
  77. package/src/cli/builder-run.ts +3 -9
  78. package/src/cli/kit-metadata-security.test.mjs +232 -6
  79. package/src/cli/public-api.test.mjs +15 -0
  80. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  81. package/src/cli/workflow-sidecar.ts +724 -97
  82. package/src/cli/workflow.ts +277 -40
  83. package/src/flow-kit/validate.ts +320 -2
  84. package/src/index.ts +6 -5
  85. package/src/lib/observed-command.ts +96 -0
  86. package/src/tools/generate-context-map.ts +5 -3
@@ -7,12 +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 } from "../lib/flow-resolver.js";
10
+ import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy } 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 { flowAgentsPackageVersion } from "../lib/package-version.js";
13
+ import { flowAgentsPackageRoot, flowAgentsPackageVersion } from "../lib/package-version.js";
14
14
  import { pinnedFlowAgentsCommand } from "../lib/pinned-cli-command.js";
15
- import { startBuilderFlowSession, syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
15
+ import { runObservedCommand } from "../lib/observed-command.js";
16
+ import { captureReviewWorkspaceSnapshot, startBuilderFlowSession, syncBuilderFlowSession } from "../builder-flow-runtime.js";
16
17
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
17
18
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
18
19
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
@@ -24,8 +25,21 @@ export const checkKinds = new Set(["build", "types", "lint", "test", "command",
24
25
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
25
26
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
26
27
  export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
28
+ const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
27
29
  function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
28
30
  function read(file) { return fs.readFileSync(file, "utf8"); }
31
+ function readRegularFileNoFollow(file, label) {
32
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
33
+ try {
34
+ const stat = fs.fstatSync(fd);
35
+ if (!stat.isFile())
36
+ die(`${label} must be a regular file`);
37
+ return fs.readFileSync(fd);
38
+ }
39
+ finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ }
29
43
  export function writeJson(file, payload) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
30
44
  // Single-line but readable "key": "value" form. Built by collapsing the
31
45
  // structural whitespace from an indented stringify — corruption-proof, unlike a
@@ -47,8 +61,14 @@ function slugify(value, fallback) { return value.toLowerCase().replace(/[^a-z0-9
47
61
  * Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
48
62
  function workItemSlug(ref) {
49
63
  const hashIdx = ref.indexOf("#");
50
- if (hashIdx < 0 || hashIdx === ref.length - 1)
51
- die("--work-item must be in owner/repo#id format");
64
+ if (hashIdx < 0) {
65
+ if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
66
+ die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
67
+ }
68
+ return slugify(ref, "work-item");
69
+ }
70
+ if (hashIdx === ref.length - 1)
71
+ die("--work-item must be in owner/repo#numeric-id format");
52
72
  const repoPath = ref.slice(0, hashIdx);
53
73
  const id = ref.slice(hashIdx + 1);
54
74
  if (!/^\d+$/.test(id))
@@ -62,9 +82,12 @@ function workItemSlug(ref) {
62
82
  function assignmentSubjectMatchesWorkItem(slug, ref) {
63
83
  if (ref === `local:${slug}`)
64
84
  return true;
65
- if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref))
85
+ try {
86
+ return workItemSlug(ref) === slug;
87
+ }
88
+ catch {
66
89
  return false;
67
- return workItemSlug(ref) === slug;
90
+ }
68
91
  }
69
92
  function sessionWorkItem(p, slug, dir) {
70
93
  const providerRef = opt(p, "work-item");
@@ -587,10 +610,10 @@ export function reduceCaptureLogByCommand(commandLog) {
587
610
  * @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
588
611
  * @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
589
612
  * @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
590
- * @param critiques Critique objects (from critique.json .critiques array)
613
+ * @param critiques Critique objects reconstructed from trust.bundle claims
591
614
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
592
615
  */
593
- export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir, actorKey) {
616
+ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir, actorKey, exactFlowContext) {
594
617
  const surface = await tryLoadSurface();
595
618
  if (!surface)
596
619
  return null;
@@ -603,7 +626,10 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
603
626
  // #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
604
627
  // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
605
628
  // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
606
- const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
629
+ const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
630
+ const activeStep = exactFlowContext && exactRepoRoot
631
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
632
+ : flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
607
633
  // #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
608
634
  // CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
609
635
  // RECORDED AT (frozen in metadata.gate_claim.step_id), which is routinely a DIFFERENT step than
@@ -612,6 +638,8 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
612
638
  // the gate claim was recorded at — the validation must be against the FULL flow definition
613
639
  // (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
614
640
  const sessionFlowId = (() => {
641
+ if (exactFlowContext)
642
+ return exactFlowContext.flowId;
615
643
  if (!flowAgentsDir)
616
644
  return null;
617
645
  try {
@@ -902,6 +930,11 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
902
930
  const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
903
931
  ? { algorithm: "sha256", hex: check._output_sha256 }
904
932
  : null;
933
+ const observedCommandsMeta = Array.isArray(check._observed_commands)
934
+ ? check._observed_commands
935
+ .filter((entry) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
936
+ .map((entry) => ({ 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 } : {}) }))
937
+ : null;
905
938
  // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
906
939
  // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
907
940
  // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
@@ -931,11 +964,15 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
931
964
  origin: "check",
932
965
  check_kind: String(check.kind ?? "external"),
933
966
  ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
967
+ ...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
968
+ ...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
969
+ ...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
934
970
  ...(waiver ? { waiver } : {}),
935
971
  ...(promotionMeta ? { promotion: promotionMeta } : {}),
936
972
  ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
937
973
  ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
938
974
  ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
975
+ ...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
939
976
  };
940
977
  const claimEvents = [];
941
978
  if (evStatus) {
@@ -1031,13 +1068,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1031
1068
  if (declared) {
1032
1069
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1033
1070
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
1034
- const declaredClaimObj = { 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 } : {}) } };
1071
+ const declaredClaimObj = { 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 } : {}) } };
1035
1072
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
1036
1073
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1037
1074
  }
1038
1075
  else {
1039
1076
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
1040
- const claimObj = { 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" } };
1077
+ const claimObj = { 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 : [] } } };
1041
1078
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
1042
1079
  claims.push({ ...claimObj, status: derivedStatus });
1043
1080
  }
@@ -1055,7 +1092,18 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1055
1092
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1056
1093
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1057
1094
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
1058
- const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
1095
+ const critMeta = {
1096
+ origin: "critique",
1097
+ reviewer: critiqueReviewer,
1098
+ reviewed_at: critiqueReviewedAt,
1099
+ findings: Array.isArray(c.findings) ? c.findings : [],
1100
+ lanes: Array.isArray(c.lanes) ? c.lanes : [],
1101
+ review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
1102
+ // Keep the legacy field for consumers that still render a flat artifact list.
1103
+ artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
1104
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1105
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
1106
+ };
1059
1107
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1060
1108
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1061
1109
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
@@ -1071,18 +1119,15 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1071
1119
  events.push(evt);
1072
1120
  claimEvents.push(evt);
1073
1121
  }
1074
- // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
1075
- const declared = matchExpectsEntry("critique");
1076
- const claimType = declared ? declared.claimType : legacyClaimType;
1077
- const subjectType = declared ? declared.subjectType : "workflow-critique";
1078
- const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
1079
- const claimObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
1122
+ // Critique is intentionally report-only. It must never inherit a Flow gate expectation,
1123
+ // even while a run is positioned at a gate-bearing step.
1124
+ const claimObj = { 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 };
1080
1125
  if (supersededBy) {
1081
1126
  // History: status is "superseded" directly (no verification event); excluded from evaluation.
1082
1127
  claims.push({ ...claimObj, status: "superseded" });
1083
1128
  }
1084
1129
  else {
1085
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [claimPolicy] });
1130
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
1086
1131
  claims.push({ ...claimObj, status: derivedStatus });
1087
1132
  }
1088
1133
  }
@@ -1108,7 +1153,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
1108
1153
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
1109
1154
  * @param critiques Critique objects (same as buildTrustBundle)
1110
1155
  */
1111
- export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques, actorKey) {
1156
+ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques, actorKey, exactFlowContext) {
1112
1157
  try {
1113
1158
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
1114
1159
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -1134,15 +1179,22 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
1134
1179
  const _flowAgentsDir = path.dirname(dir);
1135
1180
  const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
1136
1181
  let _scopedFlowAgentsDir = undefined;
1137
- try {
1138
- const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1139
- const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
1140
- if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1141
- _scopedFlowAgentsDir = _flowAgentsDir;
1182
+ if (exactFlowContext) {
1183
+ // The context was read from this session's persisted flow_run. Navigation pointers may
1184
+ // lag or point at another run and must not override an explicit session mutation.
1185
+ _scopedFlowAgentsDir = _flowAgentsDir;
1186
+ }
1187
+ else {
1188
+ try {
1189
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1190
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
1191
+ if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1192
+ _scopedFlowAgentsDir = _flowAgentsDir;
1193
+ }
1142
1194
  }
1195
+ catch { /* current.json absent or unreadable — no scoping */ }
1143
1196
  }
1144
- catch { /* current.json absent or unreadable no scoping */ }
1145
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
1197
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
1146
1198
  if (!bundle)
1147
1199
  return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
1148
1200
  const result = await validateTrustBundle(bundle);
@@ -1151,7 +1203,6 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
1151
1203
  return { written: false, errors: result.errors };
1152
1204
  }
1153
1205
  writeJson(path.join(dir, "trust.bundle"), bundle);
1154
- await syncBuilderFlowSessionIfPresent(dir);
1155
1206
  return { written: true, errors: [] };
1156
1207
  }
1157
1208
  catch (err) {
@@ -1761,6 +1812,9 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1761
1812
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1762
1813
  ...(branch ? { branch } : {}),
1763
1814
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1815
+ ...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
1816
+ ? { flow_run: existingState.flow_run }
1817
+ : {}),
1764
1818
  artifact_paths: relArtifacts(dir),
1765
1819
  next_action: nextActionPayload,
1766
1820
  });
@@ -1837,14 +1891,40 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
1837
1891
  const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1838
1892
  const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1839
1893
  let effective;
1894
+ let providerEvidenceBytes;
1840
1895
  const effectiveStateJsonFlag = opt(p, "effective-state-json");
1841
1896
  if (effectiveStateJsonFlag) {
1842
- const parsed = loadJsonInputFile(effectiveStateJsonFlag);
1897
+ providerEvidenceBytes = effectiveStateJsonFlag === "-"
1898
+ ? fs.readFileSync(0)
1899
+ : readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
1900
+ const parsed = JSON.parse(providerEvidenceBytes.toString("utf8"));
1843
1901
  const candidate = parsed && typeof parsed === "object" ? parsed.effective : undefined;
1902
+ const assignment = parsed && typeof parsed === "object" ? parsed.assignment : undefined;
1903
+ const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record : undefined;
1844
1904
  const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1845
1905
  if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1846
1906
  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)}`);
1847
1907
  }
1908
+ if (workItemRef && (parsed.role !== "AssignmentStatus"
1909
+ || parsed.provider !== assignmentProviderKind
1910
+ || assignment?.provider !== assignmentProviderKind
1911
+ || assignment?.subject_id !== slug
1912
+ || record?.role !== "AssignmentClaimRecord"
1913
+ || record?.status !== "claimed"
1914
+ || record?.subject_id !== slug
1915
+ || record?.work_item_ref !== workItemRef
1916
+ || record?.actor_key !== resolution.branchActorKey
1917
+ || assignment?.assignee !== resolution.branchActorKey
1918
+ || !record.actor || typeof record.actor !== "object"
1919
+ || record.actor.runtime !== resolution.actorStruct.runtime
1920
+ || record.actor.session_id !== resolution.actorStruct.session_id
1921
+ || record.actor.host !== resolution.actorStruct.host
1922
+ || (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
1923
+ || candidate.effective_state !== "held"
1924
+ || candidate.reason !== "self_is_holder"
1925
+ || candidate.holder?.actor !== resolution.branchActorKey)) {
1926
+ 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");
1927
+ }
1848
1928
  effective = candidate;
1849
1929
  }
1850
1930
  else if (assignmentProviderKind === "local-file") {
@@ -1882,9 +1962,7 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
1882
1962
  return null;
1883
1963
  }
1884
1964
  const selectedWorkEvidence = () => {
1885
- // Only a claim read from the local provider is durable evidence. Precomputed provider state
1886
- // can authorize entry, but it cannot prove that this process acquired the Work Item.
1887
- if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file")
1965
+ if (!workItemRef)
1888
1966
  return null;
1889
1967
  const assignment = readLocalAssignmentStatus(root, slug);
1890
1968
  const record = assignment.record;
@@ -1898,6 +1976,8 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
1898
1976
  return {
1899
1977
  assignmentFile: assignmentFilePath(root, slug),
1900
1978
  actorKey: resolution.branchActorKey,
1979
+ ...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
1980
+ ...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
1901
1981
  };
1902
1982
  };
1903
1983
  const resolveBranchForClaim = () => {
@@ -1911,8 +1991,22 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
1911
1991
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1912
1992
  // effective_state computation instead of silently using a different identity.
1913
1993
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1914
- if (isSelf)
1994
+ if (isSelf) {
1995
+ if (assignmentProviderKind !== "local-file") {
1996
+ const local = readLocalAssignmentStatus(root, slug).record;
1997
+ if (!local || local.status !== "claimed") {
1998
+ performLocalClaim(root, slug, resolution.actorStruct, {
1999
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
2000
+ branch: resolveBranchForClaim(),
2001
+ artifactDir: path.relative(root, dir) || ".",
2002
+ reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
2003
+ actorKey: resolution.branchActorKey,
2004
+ ...(workItemRef ? { workItemRef } : {}),
2005
+ });
2006
+ }
2007
+ }
1915
2008
  return selectedWorkEvidence();
2009
+ }
1916
2010
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1917
2011
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1918
2012
  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.`);
@@ -1964,6 +2058,9 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution, workItemR
1964
2058
  return selectedWorkEvidence();
1965
2059
  }
1966
2060
  case "free": {
2061
+ if (assignmentProviderKind !== "local-file") {
2062
+ die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
2063
+ }
1967
2064
  performLocalClaim(root, slug, resolution.actorStruct, {
1968
2065
  ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1969
2066
  branch: resolveBranchForClaim(),
@@ -2015,11 +2112,11 @@ function resolveEnsureSessionEntry(p, dir) {
2015
2112
  }
2016
2113
  return { flowId, stepId: explicitStep || firstStep, firstStep };
2017
2114
  }
2018
- function assertCanonicalBuilderArtifactRoot(root) {
2115
+ function assertCanonicalBuilderArtifactRoot(root, flowId) {
2019
2116
  const kontouraiRoot = path.dirname(root);
2020
2117
  const projectRoot = path.dirname(kontouraiRoot);
2021
2118
  if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
2022
- die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
2119
+ die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
2023
2120
  }
2024
2121
  const statIfPresent = (candidate) => {
2025
2122
  try {
@@ -2033,7 +2130,7 @@ function assertCanonicalBuilderArtifactRoot(root) {
2033
2130
  };
2034
2131
  const projectStat = statIfPresent(projectRoot);
2035
2132
  if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
2036
- die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
2133
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
2037
2134
  }
2038
2135
  const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
2039
2136
  for (const [candidate, expected, label] of [
@@ -2044,7 +2141,7 @@ function assertCanonicalBuilderArtifactRoot(root) {
2044
2141
  if (!stat)
2045
2142
  continue;
2046
2143
  if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2047
- die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2144
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
2048
2145
  }
2049
2146
  }
2050
2147
  }
@@ -2054,18 +2151,18 @@ function preflightEnsureSession(p) {
2054
2151
  const dir = sessionDirFor(root, slug);
2055
2152
  assertSafeSessionDirectory(root, dir);
2056
2153
  const entry = resolveEnsureSessionEntry(p, dir);
2057
- if (entry.flowId === "builder.build")
2058
- assertCanonicalBuilderArtifactRoot(root);
2154
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape")
2155
+ assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2059
2156
  sessionWorkItem(p, slug, dir);
2060
2157
  }
2061
- async function ensureSession(p) {
2158
+ async function ensureSession(p, allowCanonicalFlowMutation) {
2062
2159
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2063
2160
  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)"));
2064
2161
  const dir = sessionDirFor(root, slug);
2065
2162
  assertSafeSessionDirectory(root, dir);
2066
2163
  const entry = resolveEnsureSessionEntry(p, dir);
2067
- if (entry.flowId === "builder.build")
2068
- assertCanonicalBuilderArtifactRoot(root);
2164
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape")
2165
+ assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2069
2166
  const workItem = sessionWorkItem(p, slug, dir);
2070
2167
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
2071
2168
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -2075,8 +2172,20 @@ async function ensureSession(p) {
2075
2172
  const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2076
2173
  ? workItem.ref
2077
2174
  : undefined;
2078
- const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2175
+ let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2079
2176
  ensureSafeDirectory(root, dir);
2177
+ if (selectedWorkEvidence?.providerEvidenceBytes) {
2178
+ const staged = path.join(dir, "assignment-provider-state.json");
2179
+ if (fs.existsSync(staged)) {
2180
+ if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes))
2181
+ die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
2182
+ }
2183
+ else {
2184
+ fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
2185
+ }
2186
+ fs.chmodSync(staged, 0o400);
2187
+ selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
2188
+ }
2080
2189
  const timestamp = opt(p, "timestamp", now());
2081
2190
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2082
2191
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2100,9 +2209,11 @@ async function ensureSession(p) {
2100
2209
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2101
2210
  const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
2102
2211
  "workflow", "start",
2103
- "--flow", "builder.build",
2104
- "--work-item", workItem.ref,
2212
+ "--flow", entry.flowId,
2213
+ ...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
2105
2214
  ...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
2215
+ "--assignment-provider", opt(p, "assignment-provider", "local-file"),
2216
+ ...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
2106
2217
  "--artifact-root", root,
2107
2218
  "--title", opt(p, "title", slug),
2108
2219
  "--summary", opt(p, "summary", "Workflow session is durable."),
@@ -2135,9 +2246,9 @@ async function ensureSession(p) {
2135
2246
  ? persistedCurrent.active_step_id
2136
2247
  : entry.stepId;
2137
2248
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2138
- if (entry.flowId === "builder.build") {
2249
+ if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
2139
2250
  try {
2140
- const started = await startBuilderFlowSession({ sessionDir: dir });
2251
+ const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
2141
2252
  if (started.run.state.current_step === "pull-work"
2142
2253
  && selectedWorkEvidence
2143
2254
  && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
@@ -2152,8 +2263,25 @@ async function ensureSession(p) {
2152
2263
  }
2153
2264
  const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2154
2265
  const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2266
+ const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
2267
+ ? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
2268
+ : null;
2269
+ if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
2270
+ die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
2271
+ }
2272
+ const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
2155
2273
  const projectRoot = path.dirname(path.dirname(root));
2156
2274
  const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2275
+ const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
2276
+ let reportIsValid = false;
2277
+ try {
2278
+ const reportStat = fs.lstatSync(pullWorkReport);
2279
+ reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
2280
+ }
2281
+ catch { /* handled by the stable contract error below */ }
2282
+ if (!reportIsValid) {
2283
+ die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
2284
+ }
2157
2285
  await recordGateClaim(parseArgs([
2158
2286
  "record-gate-claim",
2159
2287
  dir,
@@ -2166,8 +2294,25 @@ async function ensureSession(p) {
2166
2294
  file: evidenceFile,
2167
2295
  summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2168
2296
  }),
2297
+ ...(selectedWorkEvidence.providerEvidenceFile ? [
2298
+ "--evidence-ref-json", JSON.stringify({
2299
+ kind: "artifact",
2300
+ file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
2301
+ summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
2302
+ }),
2303
+ ] : []),
2304
+ "--evidence-ref-json", JSON.stringify({
2305
+ kind: "artifact",
2306
+ file: path.relative(projectRoot, pullWorkReport),
2307
+ summary: `Concrete pull-work selection report for ${workItem.ref}.`,
2308
+ }),
2169
2309
  "--timestamp", timestamp,
2170
2310
  ]));
2311
+ if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
2312
+ && !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
2313
+ die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
2314
+ }
2315
+ await syncBuilderFlowSession({ sessionDir: dir });
2171
2316
  });
2172
2317
  }
2173
2318
  }
@@ -2304,6 +2449,423 @@ export function normalizeEvidenceRefs(raw, label) {
2304
2449
  return validateEvidenceRef({ ...ref }, label);
2305
2450
  });
2306
2451
  }
2452
+ function canonicalProjectRootForSession(dir) {
2453
+ const artifactRoot = path.dirname(dir);
2454
+ const kontouraiRoot = path.dirname(artifactRoot);
2455
+ if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai")
2456
+ die("gate evidence requires a canonical .kontourai/flow-agents session");
2457
+ const projectRoot = path.dirname(kontouraiRoot);
2458
+ const stat = fs.lstatSync(projectRoot);
2459
+ if (stat.isSymbolicLink() || !stat.isDirectory())
2460
+ die("gate evidence project root must be a non-symlink directory");
2461
+ return projectRoot;
2462
+ }
2463
+ function pathIsWithinRoot(candidate, root) {
2464
+ const relative = path.relative(root, candidate);
2465
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
2466
+ }
2467
+ function validateLocalEvidenceFile(projectRoot, raw, label) {
2468
+ const candidate = path.resolve(projectRoot, raw);
2469
+ if (!pathIsWithinRoot(candidate, projectRoot))
2470
+ die(`${label} must remain inside the canonical project root`);
2471
+ let stat;
2472
+ try {
2473
+ stat = fs.lstatSync(candidate);
2474
+ }
2475
+ catch {
2476
+ die(`${label} does not exist`);
2477
+ }
2478
+ if (stat.isSymbolicLink() || !stat.isFile())
2479
+ die(`${label} must be a non-symlink regular file`);
2480
+ if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot)))
2481
+ die(`${label} escapes the canonical project root`);
2482
+ return path.relative(projectRoot, candidate);
2483
+ }
2484
+ function globMatches(pattern, relative) {
2485
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
2486
+ return new RegExp(`^${escaped}$`).test(relative);
2487
+ }
2488
+ function expectedGateProducer(flowId, stepId, expectationId) {
2489
+ const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
2490
+ const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions : [];
2491
+ const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
2492
+ if (!action)
2493
+ die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
2494
+ const skills = Array.isArray(action.skills) ? action.skills.filter((value) => typeof value === "string") : [];
2495
+ const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles : [];
2496
+ const owners = roles.filter((role) => typeof role.skill_id === "string"
2497
+ && Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
2498
+ && Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
2499
+ && skills.includes(role.skill_id.replace(/^builder\./, "")));
2500
+ if (owners.length === 0) {
2501
+ const operations = Array.isArray(action.operations) ? action.operations.filter((value) => typeof value === "string") : [];
2502
+ const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
2503
+ if (operations.length === 1 && operationExpectations.includes(expectationId)) {
2504
+ const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value) => typeof value === "string") : [];
2505
+ return {
2506
+ id: `operation:${operations[0]}`,
2507
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2508
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2509
+ };
2510
+ }
2511
+ }
2512
+ if (owners.length !== 1)
2513
+ die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
2514
+ const owner = owners[0];
2515
+ const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value) => typeof value === "string" && value !== "ephemeral decision record");
2516
+ return {
2517
+ id: owner.skill_id,
2518
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2519
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2520
+ };
2521
+ }
2522
+ function validateReviewableGateEvidence(dir, slug, refs, producer, label) {
2523
+ if (refs.length === 0)
2524
+ die(`${label} requires at least one reviewable --evidence-ref-json`);
2525
+ const projectRoot = canonicalProjectRootForSession(dir);
2526
+ let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
2527
+ let localOrCommandEvidenceFound = false;
2528
+ for (const [index, ref] of refs.entries()) {
2529
+ if (ref.kind === "command" && hasNonEmptyString(ref.excerpt))
2530
+ localOrCommandEvidenceFound = true;
2531
+ if (typeof ref.file !== "string")
2532
+ continue;
2533
+ const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
2534
+ ref.file = relative;
2535
+ localOrCommandEvidenceFound = true;
2536
+ if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
2537
+ const patterns = producer.artifactPatterns.map((pattern) => {
2538
+ const expanded = pattern.replaceAll("<slug>", slug);
2539
+ return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
2540
+ });
2541
+ if (patterns.some((pattern) => globMatches(pattern, relative)))
2542
+ declaredProducerArtifactFound = true;
2543
+ }
2544
+ }
2545
+ if (!declaredProducerArtifactFound)
2546
+ die(`${label} requires a declared durable artifact from producer ${producer.id}`);
2547
+ if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
2548
+ 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`);
2549
+ }
2550
+ }
2551
+ function commandFromEvidenceRef(ref) {
2552
+ return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
2553
+ }
2554
+ function hasTestIntent(name) {
2555
+ return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
2556
+ }
2557
+ function resolvesExplicitTestTarget(projectRoot, token) {
2558
+ if (!hasTestIntent(token))
2559
+ return false;
2560
+ try {
2561
+ if (/[*?\[]/.test(token))
2562
+ return fs.globSync(token, { cwd: projectRoot }).length > 0;
2563
+ const target = path.resolve(projectRoot, token);
2564
+ return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
2565
+ }
2566
+ catch {
2567
+ return false;
2568
+ }
2569
+ }
2570
+ function staticTestUnits(file, executable) {
2571
+ try {
2572
+ const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
2573
+ const hashComments = !["cargo", "go"].includes(executable);
2574
+ const active = content.split("\n")
2575
+ .map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
2576
+ .filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
2577
+ .join("\n");
2578
+ if (["bash", "sh", "zsh"].includes(executable)
2579
+ && !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active))
2580
+ return 0;
2581
+ 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) ?? [];
2582
+ return assertions.length;
2583
+ }
2584
+ catch {
2585
+ return 0;
2586
+ }
2587
+ }
2588
+ /**
2589
+ * Produce evidence from the locally executed command and statically reviewable
2590
+ * test units. Runner stdout is deliberately excluded: any executable can print
2591
+ * a Vitest/Jest-looking success summary, but it cannot turn a non-test script
2592
+ * into a supported test workflow or supply this locally-created proof.
2593
+ */
2594
+ export function testExecutionProof(command, projectRoot, seenScripts = new Set(), packageScriptBody = false) {
2595
+ const normalized = command.trim().replace(/\s+/g, " ");
2596
+ if (!normalized || /[`$()]/.test(normalized))
2597
+ return null;
2598
+ if (/[;&|]/.test(normalized)) {
2599
+ if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized))
2600
+ return null;
2601
+ const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
2602
+ return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
2603
+ }
2604
+ if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized))
2605
+ return null;
2606
+ if (/^(?:echo|printf)(?:\s|$)/i.test(normalized))
2607
+ return null;
2608
+ if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized))
2609
+ return null;
2610
+ const tokens = normalized.split(" ").filter(Boolean);
2611
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? ""))
2612
+ tokens.shift();
2613
+ const executable = tokens[0] ?? "";
2614
+ const executableName = path.basename(executable);
2615
+ if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
2616
+ // `bun test` is Bun's test runner; package scripts use `bun run <script>`.
2617
+ if (executableName === "bun" && tokens[1] === "test") {
2618
+ const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
2619
+ const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
2620
+ return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
2621
+ }
2622
+ const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
2623
+ if (!script || !hasTestIntent(script) || seenScripts.has(script))
2624
+ return null;
2625
+ try {
2626
+ const pkg = loadJson(path.join(projectRoot, "package.json"));
2627
+ const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
2628
+ if (typeof scriptCommand !== "string")
2629
+ return null;
2630
+ seenScripts.add(script);
2631
+ return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
2632
+ }
2633
+ catch {
2634
+ return null;
2635
+ }
2636
+ }
2637
+ if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
2638
+ && !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
2639
+ if (executable !== executableName)
2640
+ return null;
2641
+ const targets = tokens.slice(1)
2642
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2643
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2644
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2645
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2646
+ }
2647
+ if (executableName === "cargo" && tokens[1] === "test") {
2648
+ const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
2649
+ const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
2650
+ return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
2651
+ }
2652
+ if (executableName === "go" && tokens[1] === "test") {
2653
+ const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
2654
+ const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
2655
+ return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
2656
+ }
2657
+ if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
2658
+ const runner = path.basename(tokens[1]);
2659
+ const targets = tokens.slice(2)
2660
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2661
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2662
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
2663
+ return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
2664
+ }
2665
+ if (executableName === "node" && tokens.includes("--test")) {
2666
+ const testFlag = tokens.indexOf("--test");
2667
+ const targets = tokens.slice(testFlag + 1)
2668
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2669
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2670
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2671
+ return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
2672
+ }
2673
+ const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
2674
+ if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath))
2675
+ return null;
2676
+ try {
2677
+ const resolved = path.resolve(projectRoot, scriptPath);
2678
+ const stat = fs.lstatSync(resolved);
2679
+ if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot)))
2680
+ return null;
2681
+ const units = staticTestUnits(resolved, executableName);
2682
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2683
+ }
2684
+ catch {
2685
+ return null;
2686
+ }
2687
+ }
2688
+ /** Validate test-evidence command shape without executing it. */
2689
+ export function isMeaningfulTestCommand(command, projectRoot, seenScripts = new Set(), packageScriptBody = false) {
2690
+ return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
2691
+ }
2692
+ export function observedExecutedTestCount(output) {
2693
+ const counts = [];
2694
+ for (const pattern of [
2695
+ /^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
2696
+ /^\s*1\.\.(\d+)(?:\s|$)/gim,
2697
+ /\b(\d+)\s+passed\b/gim,
2698
+ ]) {
2699
+ for (const match of output.matchAll(pattern))
2700
+ counts.push(Number(match[1]));
2701
+ }
2702
+ const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
2703
+ if (explicitPasses > 0)
2704
+ counts.push(explicitPasses);
2705
+ const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
2706
+ if (goPackages > 0)
2707
+ counts.push(goPackages);
2708
+ return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
2709
+ }
2710
+ export function inferExecutedTestCount(command, projectRoot, output, seenScripts = new Set()) {
2711
+ const proof = testExecutionProof(command, projectRoot, seenScripts);
2712
+ if (!proof)
2713
+ return 0;
2714
+ const observed = observedExecutedTestCount(output);
2715
+ return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
2716
+ }
2717
+ async function normalizeObservedCommands(commands, projectRoot, requireTestIntent, expectedStatus) {
2718
+ if (commands.length === 0)
2719
+ die("record-gate-claim requires at least one --command for observed command evidence");
2720
+ if (new Set(commands).size !== commands.length)
2721
+ die("record-gate-claim --command values must be unique");
2722
+ for (const command of commands) {
2723
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2724
+ if (!isRunnableCommandText(command))
2725
+ die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2726
+ if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot))
2727
+ 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");
2728
+ }
2729
+ // Passing test evidence is always executed exactly once by this canonical
2730
+ // writer. Caller-supplied observations remain available for non-test
2731
+ // attestations but can never stand in for locally observed test execution.
2732
+ const observed = await Promise.all(commands.map(async (command) => {
2733
+ const result = await runObservedCommand(command, projectRoot);
2734
+ const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
2735
+ return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
2736
+ }));
2737
+ if (observed.length !== commands.length)
2738
+ die("record-gate-claim requires exactly one --observed-command-json for every --command");
2739
+ const byCommand = new Map();
2740
+ for (const entry of observed) {
2741
+ 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))
2742
+ die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
2743
+ if (!commands.includes(entry.command))
2744
+ die("--observed-command-json command must exactly match one supplied --command");
2745
+ if (expectedStatus === "pass" && entry.exit_code !== 0)
2746
+ die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
2747
+ if (expectedStatus === "fail" && entry.exit_code === 0)
2748
+ die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
2749
+ if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit"))
2750
+ die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
2751
+ if (byCommand.has(entry.command))
2752
+ die("--observed-command-json command values must be unique");
2753
+ byCommand.set(entry.command, entry);
2754
+ }
2755
+ if (commands.some((command) => !byCommand.has(command)))
2756
+ die("every --command requires a corresponding --observed-command-json");
2757
+ return commands.map((command) => byCommand.get(command));
2758
+ }
2759
+ function requireObservedCommandRefs(refs, observedCommands, label, requireAll = false) {
2760
+ const commandRefs = refs.filter((ref) => ref.kind === "command");
2761
+ if (commandRefs.length === 0)
2762
+ die(`${label} requires a command evidence ref matching a successful observed command`);
2763
+ for (const ref of commandRefs) {
2764
+ if (!observedCommands.has(commandFromEvidenceRef(ref)))
2765
+ die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
2766
+ }
2767
+ if (requireAll) {
2768
+ const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
2769
+ if ([...observedCommands].some((command) => !referenced.has(command)))
2770
+ die(`${label} requires a top-level command evidence ref for every successful observed command`);
2771
+ }
2772
+ }
2773
+ function completePassingCriteria(existing, raw, observedCommands) {
2774
+ if (raw.length === 0)
2775
+ die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2776
+ const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
2777
+ const expectedById = new Map();
2778
+ for (const criterion of existing) {
2779
+ const id = String(criterion.id ?? "");
2780
+ if (id.length > 0)
2781
+ expectedById.set(id, criterion);
2782
+ }
2783
+ const expectedIds = [...expectedById.keys()];
2784
+ const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
2785
+ if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
2786
+ die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
2787
+ }
2788
+ return incoming.map((criterion, index) => {
2789
+ if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key)))
2790
+ die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
2791
+ if (criterion.status !== "pass")
2792
+ die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
2793
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
2794
+ if (refs.length === 0)
2795
+ die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2796
+ requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2797
+ return { ...expectedById.get(ids[index]), status: "pass", evidence_refs: refs };
2798
+ });
2799
+ }
2800
+ const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
2801
+ const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
2802
+ function normalizeCritiqueLanes(raw) {
2803
+ if (raw.length === 0)
2804
+ die("record-critique requires at least one --lane-json");
2805
+ const lanes = raw.map((value, index) => {
2806
+ const lane = parseJson(value, "--lane-json");
2807
+ const keys = Object.keys(lane);
2808
+ if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key)))
2809
+ die(`--lane-json ${index} contains unsupported fields`);
2810
+ if (!safeCritiqueId.test(String(lane.id ?? "")))
2811
+ die(`--lane-json ${index} id must be a unique safe identifier`);
2812
+ if (!critiqueStatuses.has(String(lane.status ?? "")))
2813
+ die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
2814
+ if (!hasNonEmptyString(lane.summary))
2815
+ die(`--lane-json ${index} summary must be non-empty`);
2816
+ const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
2817
+ if (evidenceRefs.length === 0)
2818
+ die(`--lane-json ${index} requires structured reviewable evidence_refs`);
2819
+ return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
2820
+ });
2821
+ if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length)
2822
+ die("--lane-json ids must be unique");
2823
+ return lanes;
2824
+ }
2825
+ function reviewTargetArtifacts(dir, rawPaths, label) {
2826
+ const projectRoot = canonicalProjectRootForSession(dir);
2827
+ const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
2828
+ const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
2829
+ const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
2830
+ if (new Set(files).size !== files.length)
2831
+ die(`${label} artifact refs must be unique`);
2832
+ return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
2833
+ }
2834
+ function reviewTargetArtifactsMatch(dir, reviewTarget) {
2835
+ try {
2836
+ if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget))
2837
+ return false;
2838
+ const artifacts = reviewTarget.artifacts;
2839
+ if (!Array.isArray(artifacts) || artifacts.length === 0)
2840
+ return false;
2841
+ const projectRoot = canonicalProjectRootForSession(dir);
2842
+ const files = new Set();
2843
+ return artifacts.every((artifact) => {
2844
+ if (!artifact || typeof artifact !== "object")
2845
+ return false;
2846
+ const file = artifact.file;
2847
+ const sha256 = artifact.sha256;
2848
+ if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file))
2849
+ return false;
2850
+ files.add(file);
2851
+ const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
2852
+ const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
2853
+ return digest === sha256;
2854
+ });
2855
+ }
2856
+ catch {
2857
+ return false;
2858
+ }
2859
+ }
2860
+ function critiqueIsCleanAndCurrent(dir, critique) {
2861
+ if (critique.verdict !== "pass")
2862
+ return false;
2863
+ if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane) => lane.status !== "pass"))
2864
+ return false;
2865
+ if (Array.isArray(critique.findings) && critique.findings.some((finding) => finding.status === "open"))
2866
+ return false;
2867
+ return reviewTargetArtifactsMatch(dir, critique.review_target);
2868
+ }
2307
2869
  // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2308
2870
  // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2309
2871
  // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
@@ -2637,6 +3199,20 @@ function checksFromBundle(dir) {
2637
3199
  const od = md && typeof md === "object" ? md.output_digest : undefined;
2638
3200
  return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2639
3201
  };
3202
+ const observedCommandsOf = (claim) => {
3203
+ const md = claim.metadata;
3204
+ const observed = md && typeof md === "object" ? md.observed_commands : undefined;
3205
+ return Array.isArray(observed) ? observed.filter((entry) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
3206
+ };
3207
+ const applyProducerStamp = (check, claim) => {
3208
+ const md = claim.metadata;
3209
+ if (md && typeof md.expected_producer === "string")
3210
+ check._producer = md.expected_producer;
3211
+ if (md && typeof md.recorded_by === "string")
3212
+ check._recorded_by = md.recorded_by;
3213
+ if (md && Array.isArray(md.self_produced_trust_slices))
3214
+ check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
3215
+ };
2640
3216
  // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2641
3217
  // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2642
3218
  // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
@@ -2730,6 +3306,10 @@ function checksFromBundle(dir) {
2730
3306
  const outputSha256 = outputSha256Of(claim);
2731
3307
  if (outputSha256)
2732
3308
  check._output_sha256 = outputSha256;
3309
+ const observedCommands = observedCommandsOf(claim);
3310
+ if (observedCommands)
3311
+ check._observed_commands = observedCommands;
3312
+ applyProducerStamp(check, claim);
2733
3313
  applyGateClaimStamp(check, claim);
2734
3314
  applyGateClaimShapeUnstamped(check, claim);
2735
3315
  checks.push(check);
@@ -2752,6 +3332,10 @@ function checksFromBundle(dir) {
2752
3332
  const outputSha256 = outputSha256Of(claim);
2753
3333
  if (outputSha256)
2754
3334
  check._output_sha256 = outputSha256;
3335
+ const observedCommands = observedCommandsOf(claim);
3336
+ if (observedCommands)
3337
+ check._observed_commands = observedCommands;
3338
+ applyProducerStamp(check, claim);
2755
3339
  applyGateClaimStamp(check, claim);
2756
3340
  applyGateClaimShapeUnstamped(check, claim);
2757
3341
  checks.push(check);
@@ -2786,9 +3370,18 @@ function existingCheckStampMap(checks) {
2786
3370
  */
2787
3371
  function readBundleState(dir) {
2788
3372
  const acceptance = loadJson(path.join(dir, "acceptance.json"));
3373
+ const bundledCriteria = criteriaFromBundle(dir);
3374
+ const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria : [];
3375
+ const contractSignature = (criteria) => JSON.stringify(criteria.map((criterion) => ({
3376
+ id: criterion.id ?? null,
3377
+ description: criterion.description ?? null,
3378
+ })));
3379
+ const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
3380
+ ? acceptedCriteria
3381
+ : (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
2789
3382
  return {
2790
3383
  checks: checksFromBundle(dir),
2791
- criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
3384
+ criteria,
2792
3385
  critiques: critiquesFromBundle(dir),
2793
3386
  };
2794
3387
  }
@@ -2825,14 +3418,36 @@ function critiquesFromBundle(dir) {
2825
3418
  id: String(c.subjectId || "").split("/").pop() || c.id,
2826
3419
  verdict: c.value ?? "not_verified",
2827
3420
  summary: c.fieldOrBehavior || "",
2828
- findings: [],
3421
+ findings: Array.isArray(md.findings) ? md.findings : [],
3422
+ lanes: Array.isArray(md.lanes) ? md.lanes : [],
3423
+ review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
2829
3424
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
2830
3425
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
2831
- artifact_refs: [],
3426
+ artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
2832
3427
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
2833
3428
  };
2834
3429
  });
2835
3430
  }
3431
+ function criteriaFromBundle(dir) {
3432
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
3433
+ if (!Array.isArray(bundle.claims))
3434
+ return [];
3435
+ for (const claim of bundle.claims)
3436
+ requireStampedClaim(claim, dir);
3437
+ return bundle.claims
3438
+ .filter((claim) => claim && claimOrigin(claim) === "acceptance")
3439
+ .map((claim) => {
3440
+ const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata : {};
3441
+ const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion : {};
3442
+ return {
3443
+ id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
3444
+ description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3445
+ status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3446
+ evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3447
+ };
3448
+ })
3449
+ .filter((criterion) => typeof criterion.id === "string" && criterion.id.length > 0);
3450
+ }
2836
3451
  // ─────────────────────────────────────────────────────────────────────────────
2837
3452
  /**
2838
3453
  * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
@@ -2895,13 +3510,11 @@ async function recordEvidence(p) {
2895
3510
  // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2896
3511
  // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2897
3512
  // exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
2898
- const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
2899
- const _criteriaForBundle = _existingState.criteria.map((c) => ({ ...c, status: _criteriaStatus }));
2900
3513
  const _mergedChecks = mergeChecksById(_existingState.checks, checks);
2901
3514
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
2902
3515
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2903
3516
  // whenever it ran after a critique existed.
2904
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
3517
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2905
3518
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
2906
3519
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
2907
3520
  return 0;
@@ -3108,14 +3721,23 @@ async function recordGateClaim(p) {
3108
3721
  die("--route-reason is only valid with --status fail");
3109
3722
  if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason))
3110
3723
  die("--route-reason must be a lowercase classifier identifier");
3111
- // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
3112
- // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
3113
- // unconditional legacy-only read this is the fix for record-gate-claim's pre-existing (#291
3114
- // Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
3115
- // view, not a different actor's more-recently-written legacy pointer.
3724
+ // Prefer the exact session's canonical Flow projection. Actor/global current pointers are
3725
+ // ambient navigation state and may legitimately point at another run or lag this run.
3726
+ // Legacy sessions without flow_run retain the per-actor current-pointer fallback.
3116
3727
  const flowAgentsDir = path.dirname(dir);
3117
3728
  const gateClaimActorKey = resolveReadActorKey(p);
3118
- const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3729
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3730
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3731
+ ? sidecarState.flow_run
3732
+ : null;
3733
+ const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
3734
+ const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
3735
+ const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
3736
+ const activeStep = exactFlowContext
3737
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
3738
+ : resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3739
+ if (exactFlowContext && !activeStep)
3740
+ die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
3119
3741
  if (!activeStep)
3120
3742
  die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
3121
3743
  if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
@@ -3138,17 +3760,38 @@ async function recordGateClaim(p) {
3138
3760
  die(`record-gate-claim: gate "${activeStep.gateId}" has ${expects.length} expects[] entries; --expectation <id> is required. Available: ${expects.map((e) => e.id).join(", ")}`);
3139
3761
  }
3140
3762
  const { claimType, subjectType } = targetExpectation.bundle_claim;
3141
- // Build a synthetic external check that will be matched by matchExpectsEntry to produce
3142
- // a correctly-typed claim. We use kind="external" so it routes through the non-policy,
3143
- // non-flow-step fallback path. The subjectType on the resulting claim comes from the
3144
- // expects[] entry via matchExpectsEntry.
3763
+ const gateCommands = opts(p, "command");
3764
+ const gateCommand = gateCommands[0] ?? "";
3765
+ const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
3766
+ const observedCommandRaw = opts(p, "observed-command-json");
3767
+ if (mustRunTests && gateCommands.length === 0)
3768
+ die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
3769
+ const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
3770
+ const observedCommands = gateCommands.length > 0
3771
+ ? await normalizeObservedCommands(gateCommands, projectRoot, mustRunTests, statusVal)
3772
+ : [];
3773
+ const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
3774
+ let outputSha256 = null;
3775
+ if (!mustRunTests && gateCommands.length > 1)
3776
+ die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
3777
+ if (gateCommands.length === 0 && observedCommandRaw.length > 0)
3778
+ die("--observed-command-json requires --command");
3779
+ if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
3780
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
3781
+ if (!isRunnableCommandText(gateCommand))
3782
+ die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3783
+ }
3784
+ if (observedCommands.length > 0)
3785
+ outputSha256 = observedCommands[0].output_sha256;
3786
+ // Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
3787
+ // Command-backed checks retain their executable evidence classification.
3145
3788
  const checkId = expectationId || targetExpectation.id;
3146
3789
  // Build a minimal "external" check. Include _gate_claim_expectation_id so that
3147
3790
  // matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
3148
3791
  // normalizeCheck preserves extra underscore-prefixed fields without stripping them.
3149
3792
  const check = {
3150
3793
  id: `gate-claim-${checkId}`,
3151
- kind: "external",
3794
+ kind: gateCommands.length > 0 ? "command" : "external",
3152
3795
  status: statusVal,
3153
3796
  summary,
3154
3797
  _gate_claim_expectation_id: targetExpectation.id,
@@ -3156,23 +3799,31 @@ async function recordGateClaim(p) {
3156
3799
  };
3157
3800
  // Include structured evidence refs if provided
3158
3801
  const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
3802
+ const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
3803
+ if (statusVal === "pass")
3804
+ validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
3805
+ if (mustRunTests)
3806
+ requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
3159
3807
  if (evidenceRefs.length > 0) {
3160
3808
  check.artifact_refs = evidenceRefs;
3161
3809
  }
3810
+ check._producer = producer.id;
3811
+ check._recorded_by = gateClaimActorKey;
3812
+ if (producer.selfProducedTrustSlices.length > 0)
3813
+ check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
3162
3814
  // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
3163
3815
  // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
3164
3816
  // finds a real command to re-run instead of falling back to fieldOrBehavior (the --summary
3165
3817
  // prose itself), which it would otherwise execute as a shell command under
3166
3818
  // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
3167
3819
  // heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
3168
- const gateCommand = opt(p, "command");
3169
- if (gateCommand) {
3170
- const { isRunnableCommandText } = loadRunnableCommandHelper();
3171
- if (!isRunnableCommandText(gateCommand))
3172
- die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3820
+ if (gateCommand)
3173
3821
  check.command = gateCommand;
3174
- }
3822
+ if (observedCommands.length > 0)
3823
+ check._observed_commands = observedCommands;
3175
3824
  const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
3825
+ if (outputSha256)
3826
+ checkNormalized._output_sha256 = outputSha256;
3176
3827
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
3177
3828
  const gateWaiver = parseWaiver(p, ts);
3178
3829
  if (gateWaiver)
@@ -3185,8 +3836,17 @@ async function recordGateClaim(p) {
3185
3836
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3186
3837
  // gate claim against a different expectation is additive.
3187
3838
  const _existingState = readBundleState(dir);
3839
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
3840
+ if (mustRunTests) {
3841
+ const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3842
+ if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
3843
+ die("a passing tests-evidence claim requires a current clean critique first");
3844
+ }
3845
+ for (const criterion of criteria)
3846
+ validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
3847
+ }
3188
3848
  const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
3189
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
3849
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
3190
3850
  return 0;
3191
3851
  }
3192
3852
  /**
@@ -3373,12 +4033,46 @@ export function normalizeFinding(raw) {
3373
4033
  async function recordCritique(p) {
3374
4034
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3375
4035
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3376
- // Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
3377
- // Fall back to critique.json for legacy sessions that still have it on disk.
3378
- const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
3379
- const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
3380
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
3381
- 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"))) };
4036
+ const verdict = opt(p, "verdict");
4037
+ if (!critiqueStatuses.has(verdict))
4038
+ die("record-critique requires --verdict pass, fail, or not_verified");
4039
+ const summary = opt(p, "summary");
4040
+ if (!hasNonEmptyString(summary))
4041
+ die("record-critique requires --summary");
4042
+ const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
4043
+ const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
4044
+ if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
4045
+ die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
4046
+ }
4047
+ const sidecarState = loadJson(path.join(dir, "state.json"));
4048
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
4049
+ ? sidecarState.flow_run
4050
+ : null;
4051
+ const exactFlowContext = projectedRun
4052
+ && typeof projectedRun.definition_id === "string"
4053
+ && typeof projectedRun.current_step === "string"
4054
+ ? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
4055
+ : undefined;
4056
+ // trust.bundle is the sole critique source. critique.json is unsupported historical residue.
4057
+ const _critiqueState = readBundleState(dir);
4058
+ const bundleCritiques = _critiqueState.critiques;
4059
+ const critiqueId = opt(p, "id", "review");
4060
+ if (!safeCritiqueId.test(critiqueId))
4061
+ die("record-critique --id must be a safe identifier");
4062
+ const critique = {
4063
+ id: critiqueId,
4064
+ reviewer: opt(p, "reviewer", "tool-code-reviewer"),
4065
+ reviewed_at: opt(p, "timestamp", now()),
4066
+ verdict,
4067
+ summary,
4068
+ lanes,
4069
+ review_target: {
4070
+ artifacts: reviewArtifacts,
4071
+ workspace_snapshot: captureReviewWorkspaceSnapshot(canonicalProjectRootForSession(dir), reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) }))),
4072
+ },
4073
+ artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
4074
+ findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
4075
+ };
3382
4076
  if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
3383
4077
  die("required critique must pass");
3384
4078
  // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
@@ -3405,8 +4099,7 @@ async function recordCritique(p) {
3405
4099
  // checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
3406
4100
  // already exists to share; recordLearning uses it directly (see below) and recordCritique
3407
4101
  // previously duplicated it by hand for no reason.
3408
- const _critiqueState = readBundleState(dir);
3409
- assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
4102
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
3410
4103
  return 0;
3411
4104
  }
3412
4105
  function frontmatter(text, key) {
@@ -3432,7 +4125,22 @@ async function importCritique(p) {
3432
4125
  const title = m.groups?.title ?? "finding";
3433
4126
  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] });
3434
4127
  }
3435
- 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 };
4128
+ const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
4129
+ const parsed = {
4130
+ ...p,
4131
+ positional: [dir],
4132
+ opts: {
4133
+ ...p.opts,
4134
+ id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
4135
+ reviewer: ["tool-code-reviewer"],
4136
+ verdict: [verdict],
4137
+ summary: [`Imported critique from ${path.basename(review)}`],
4138
+ "artifact-ref": [importedArtifact],
4139
+ "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." }] })],
4140
+ "finding-json": findings.map((f) => JSON.stringify(f)),
4141
+ },
4142
+ flags: p.flags,
4143
+ };
3436
4144
  const result = await recordCritique(parsed);
3437
4145
  if (verdict !== "pass")
3438
4146
  die("required critique must pass");
@@ -4782,10 +5490,7 @@ function evidenceClean(dir) {
4782
5490
  });
4783
5491
  }
4784
5492
  function critiqueClean(dir) {
4785
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
4786
- // legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
4787
- // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
4788
- // for an unstamped claim (#268/#344).
5493
+ // trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
4789
5494
  const bundle = loadJson(path.join(dir, "trust.bundle"));
4790
5495
  if (Array.isArray(bundle.claims)) {
4791
5496
  for (const c of bundle.claims)
@@ -4800,14 +5505,11 @@ function critiqueClean(dir) {
4800
5505
  });
4801
5506
  if (critiqueClaims.length === 0)
4802
5507
  return false; // no critique written yet
4803
- return critiqueClaims.every((c) => {
4804
- const v = String(c.value || "");
4805
- return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
4806
- });
5508
+ return critiquesFromBundle(dir)
5509
+ .filter((critique) => !critique.superseded_by)
5510
+ .every((critique) => critiqueIsCleanAndCurrent(dir, critique));
4807
5511
  }
4808
- // Legacy fallback: critique.json
4809
- const c = loadJson(path.join(dir, "critique.json"), {});
4810
- return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
5512
+ return false;
4811
5513
  }
4812
5514
  function assertExistingLearningValid(dir) {
4813
5515
  const file = path.join(dir, "learning.json");
@@ -4865,8 +5567,7 @@ async function dogfoodPass(p) {
4865
5567
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4866
5568
  if (!opt(p, "critique-id") && !critiqueClean(dir))
4867
5569
  die("requires passing critique");
4868
- // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
4869
- if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
5570
+ if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle")))
4870
5571
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4871
5572
  }
4872
5573
  }
@@ -5124,7 +5825,7 @@ async function gateReview(p) {
5124
5825
  // Locate trust.bundle — required per SKILL.md contract
5125
5826
  const bundlePath = path.join(dir, "trust.bundle");
5126
5827
  if (!fs.existsSync(bundlePath)) {
5127
- process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
5828
+ process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
5128
5829
  return 1;
5129
5830
  }
5130
5831
  // Load Surface (ESM, fail-open)
@@ -5859,7 +6560,10 @@ Available claim ids:
5859
6560
  return 0;
5860
6561
  }
5861
6562
  // ─────────────────────────────────────────────────────────────────────────────
5862
- export async function main(argv = process.argv.slice(2)) {
6563
+ export function mainFromPublicWorkflow(argv) {
6564
+ return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
6565
+ }
6566
+ export async function main(argv = process.argv.slice(2), authority) {
5863
6567
  const _rawArgv = argv;
5864
6568
  // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5865
6569
  // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
@@ -5901,7 +6605,7 @@ export async function main(argv = process.argv.slice(2)) {
5901
6605
  : 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]) : "";
5902
6606
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
5903
6607
  switch (p.command) {
5904
- case "ensure-session": return ensureSession(p);
6608
+ case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
5905
6609
  case "current": return current(p);
5906
6610
  case "record-agent-event": return recordAgentEvent(p);
5907
6611
  case "init-plan": return initPlan(p);