@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, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
+ import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolveFlowStep, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
12
  import { ensureSafeDirectory } from "../lib/fs.js";
13
- import { 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
@@ -27,9 +28,20 @@ export const checkKinds = new Set(["build", "types", "lint", "test", "command",
27
28
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
28
29
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
29
30
  export const WORKFLOW_WRITER_CONTRACT_VERSION = "1.0";
31
+ const PUBLIC_WORKFLOW_AUTHORITY = Symbol("public-workflow-authority");
30
32
 
31
33
  function now(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
32
34
  function read(file: string): string { return fs.readFileSync(file, "utf8"); }
35
+ function readRegularFileNoFollow(file: string, label: string): Buffer {
36
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
37
+ try {
38
+ const stat = fs.fstatSync(fd);
39
+ if (!stat.isFile()) die(`${label} must be a regular file`);
40
+ return fs.readFileSync(fd);
41
+ } finally {
42
+ fs.closeSync(fd);
43
+ }
44
+ }
33
45
  export function writeJson(file: string, payload: AnyObj): void { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`); }
34
46
  // Single-line but readable "key": "value" form. Built by collapsing the
35
47
  // structural whitespace from an indented stringify — corruption-proof, unlike a
@@ -51,7 +63,13 @@ function slugify(value: string, fallback: string): string { return value.toLower
51
63
  * Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
52
64
  function workItemSlug(ref: string): string {
53
65
  const hashIdx = ref.indexOf("#");
54
- if (hashIdx < 0 || hashIdx === ref.length - 1) die("--work-item must be in owner/repo#id format");
66
+ if (hashIdx < 0) {
67
+ if (!/^[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(ref) || ref.includes("..")) {
68
+ die("--work-item must be a provider-neutral provider:id ref or owner/repo#numeric-id");
69
+ }
70
+ return slugify(ref, "work-item");
71
+ }
72
+ if (hashIdx === ref.length - 1) die("--work-item must be in owner/repo#numeric-id format");
55
73
  const repoPath = ref.slice(0, hashIdx);
56
74
  const id = ref.slice(hashIdx + 1);
57
75
  if (!/^\d+$/.test(id)) die("--work-item id must be a numeric issue number");
@@ -63,8 +81,7 @@ function workItemSlug(ref: string): string {
63
81
 
64
82
  function assignmentSubjectMatchesWorkItem(slug: string, ref: string): boolean {
65
83
  if (ref === `local:${slug}`) return true;
66
- if (!/^[^/#]+\/[^/#]+#\d+$/.test(ref)) return false;
67
- return workItemSlug(ref) === slug;
84
+ try { return workItemSlug(ref) === slug; } catch { return false; }
68
85
  }
69
86
 
70
87
  type SessionWorkItem = {
@@ -632,10 +649,10 @@ export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map
632
649
  * @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
633
650
  * @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
634
651
  * @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
635
- * @param critiques Critique objects (from critique.json .critiques array)
652
+ * @param critiques Critique objects reconstructed from trust.bundle claims
636
653
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
637
654
  */
638
- export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
655
+ export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<AnyObj | null> {
639
656
  const surface = await tryLoadSurface();
640
657
  if (!surface) return null;
641
658
  const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
@@ -648,7 +665,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
648
665
  // #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
649
666
  // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
650
667
  // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
651
- const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
668
+ const exactRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
669
+ const activeStep: ActiveFlowStep | null = exactFlowContext && exactRepoRoot
670
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, exactRepoRoot)
671
+ : flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
652
672
 
653
673
  // #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
654
674
  // CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
@@ -658,6 +678,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
658
678
  // the gate claim was recorded at — the validation must be against the FULL flow definition
659
679
  // (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
660
680
  const sessionFlowId: string | null = (() => {
681
+ if (exactFlowContext) return exactFlowContext.flowId;
661
682
  if (!flowAgentsDir) return null;
662
683
  try {
663
684
  const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
@@ -945,6 +966,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
945
966
  const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
946
967
  ? { algorithm: "sha256", hex: check._output_sha256 }
947
968
  : null;
969
+ const observedCommandsMeta = Array.isArray(check._observed_commands)
970
+ ? check._observed_commands
971
+ .filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
972
+ .map((entry: AnyObj) => ({ command: entry.command, exit_code: entry.exit_code, output_sha256: entry.output_sha256, ...(Number.isSafeInteger(entry.test_count) ? { test_count: entry.test_count } : {}), ...(entry.execution_proof && typeof entry.execution_proof === "object" ? { execution_proof: entry.execution_proof } : {}) }))
973
+ : null;
948
974
  // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
949
975
  // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
950
976
  // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
@@ -974,11 +1000,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
974
1000
  origin: "check",
975
1001
  check_kind: String(check.kind ?? "external"),
976
1002
  ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1003
+ ...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
1004
+ ...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
1005
+ ...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
977
1006
  ...(waiver ? { waiver } : {}),
978
1007
  ...(promotionMeta ? { promotion: promotionMeta } : {}),
979
1008
  ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
980
1009
  ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
981
1010
  ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
1011
+ ...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
982
1012
  };
983
1013
 
984
1014
  const claimEvents: AnyObj[] = [];
@@ -1075,12 +1105,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1075
1105
  if (declared) {
1076
1106
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1077
1107
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
1078
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1108
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1079
1109
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
1080
1110
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1081
1111
  } else {
1082
1112
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
1083
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
1113
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance", criterion: { id: criterion.id, description: criterion.description ?? criterion.id, status: criterion.status, evidence_refs: Array.isArray(criterion.evidence_refs) ? criterion.evidence_refs : [] } } };
1084
1114
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1085
1115
  claims.push({ ...claimObj, status: derivedStatus });
1086
1116
  }
@@ -1098,7 +1128,18 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1098
1128
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1099
1129
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1100
1130
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
1101
- const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
1131
+ const critMeta: AnyObj = {
1132
+ origin: "critique",
1133
+ reviewer: critiqueReviewer,
1134
+ reviewed_at: critiqueReviewedAt,
1135
+ findings: Array.isArray(c.findings) ? c.findings : [],
1136
+ lanes: Array.isArray(c.lanes) ? c.lanes : [],
1137
+ review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
1138
+ // Keep the legacy field for consumers that still render a flat artifact list.
1139
+ artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
1140
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1141
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
1142
+ };
1102
1143
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1103
1144
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1104
1145
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
@@ -1115,17 +1156,14 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1115
1156
  claimEvents.push(evt);
1116
1157
  }
1117
1158
 
1118
- // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
1119
- const declared = matchExpectsEntry("critique");
1120
- const claimType = declared ? declared.claimType : legacyClaimType;
1121
- const subjectType = declared ? declared.subjectType : "workflow-critique";
1122
- const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
1123
- const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
1159
+ // Critique is intentionally report-only. It must never inherit a Flow gate expectation,
1160
+ // even while a run is positioned at a gate-bearing step.
1161
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id, metadata: critMeta };
1124
1162
  if (supersededBy) {
1125
1163
  // History: status is "superseded" directly (no verification event); excluded from evaluation.
1126
1164
  claims.push({ ...claimObj, status: "superseded" });
1127
1165
  } else {
1128
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
1166
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1129
1167
  claims.push({ ...claimObj, status: derivedStatus });
1130
1168
  }
1131
1169
  }
@@ -1153,7 +1191,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1153
1191
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
1154
1192
  * @param critiques Critique objects (same as buildTrustBundle)
1155
1193
  */
1156
- export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
1194
+ export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string, exactFlowContext?: { flowId: string; stepId: string }): Promise<{ written: boolean; errors: string[] }> {
1157
1195
  try {
1158
1196
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
1159
1197
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -1173,14 +1211,20 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1173
1211
  const _flowAgentsDir = path.dirname(dir);
1174
1212
  const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
1175
1213
  let _scopedFlowAgentsDir: string | undefined = undefined;
1176
- try {
1177
- const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1178
- const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
1179
- if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1180
- _scopedFlowAgentsDir = _flowAgentsDir;
1181
- }
1182
- } catch { /* current.json absent or unreadable — no scoping */ }
1183
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
1214
+ if (exactFlowContext) {
1215
+ // The context was read from this session's persisted flow_run. Navigation pointers may
1216
+ // lag or point at another run and must not override an explicit session mutation.
1217
+ _scopedFlowAgentsDir = _flowAgentsDir;
1218
+ } else {
1219
+ try {
1220
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1221
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
1222
+ if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1223
+ _scopedFlowAgentsDir = _flowAgentsDir;
1224
+ }
1225
+ } catch { /* current.json absent or unreadable — no scoping */ }
1226
+ }
1227
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
1184
1228
  if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
1185
1229
  const result = await validateTrustBundle(bundle);
1186
1230
  if (result.available && !result.valid) {
@@ -1188,7 +1232,6 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1188
1232
  return { written: false, errors: result.errors };
1189
1233
  }
1190
1234
  writeJson(path.join(dir, "trust.bundle"), bundle);
1191
- await syncBuilderFlowSessionIfPresent(dir);
1192
1235
  return { written: true, errors: [] };
1193
1236
  } catch (err) {
1194
1237
  const message = err instanceof Error ? err.message : String(err);
@@ -1766,6 +1809,9 @@ function initSidecars(
1766
1809
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1767
1810
  ...(branch ? { branch } : {}),
1768
1811
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1812
+ ...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
1813
+ ? { flow_run: existingState.flow_run }
1814
+ : {}),
1769
1815
  artifact_paths: relArtifacts(dir),
1770
1816
  next_action: nextActionPayload,
1771
1817
  });
@@ -1819,7 +1865,7 @@ function enforceEnsureSessionOwnership(
1819
1865
  dir: string,
1820
1866
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1821
1867
  workItemRef?: string,
1822
- ): { assignmentFile: string; actorKey: string } | null {
1868
+ ): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null {
1823
1869
  if (p.flags.has("skip-ownership-guard")) {
1824
1870
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1825
1871
  return null;
@@ -1854,15 +1900,41 @@ function enforceEnsureSessionOwnership(
1854
1900
 
1855
1901
  type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
1856
1902
  let effective: EffectiveResult;
1903
+ let providerEvidenceBytes: Buffer | undefined;
1857
1904
 
1858
1905
  const effectiveStateJsonFlag = opt(p, "effective-state-json");
1859
1906
  if (effectiveStateJsonFlag) {
1860
- const parsed = loadJsonInputFile(effectiveStateJsonFlag) as AnyObj;
1907
+ providerEvidenceBytes = effectiveStateJsonFlag === "-"
1908
+ ? fs.readFileSync(0)
1909
+ : readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
1910
+ const parsed = JSON.parse(providerEvidenceBytes.toString("utf8")) as AnyObj;
1861
1911
  const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
1912
+ const assignment = parsed && typeof parsed === "object" ? (parsed.assignment as AnyObj | undefined) : undefined;
1913
+ const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record as AnyObj : undefined;
1862
1914
  const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1863
1915
  if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1864
1916
  die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
1865
1917
  }
1918
+ if (workItemRef && (parsed.role !== "AssignmentStatus"
1919
+ || parsed.provider !== assignmentProviderKind
1920
+ || assignment?.provider !== assignmentProviderKind
1921
+ || assignment?.subject_id !== slug
1922
+ || record?.role !== "AssignmentClaimRecord"
1923
+ || record?.status !== "claimed"
1924
+ || record?.subject_id !== slug
1925
+ || record?.work_item_ref !== workItemRef
1926
+ || record?.actor_key !== resolution.branchActorKey
1927
+ || assignment?.assignee !== resolution.branchActorKey
1928
+ || !record.actor || typeof record.actor !== "object"
1929
+ || record.actor.runtime !== resolution.actorStruct.runtime
1930
+ || record.actor.session_id !== resolution.actorStruct.session_id
1931
+ || record.actor.host !== resolution.actorStruct.host
1932
+ || (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
1933
+ || candidate.effective_state !== "held"
1934
+ || candidate.reason !== "self_is_holder"
1935
+ || candidate.holder?.actor !== resolution.branchActorKey)) {
1936
+ die("ensure-session --effective-state-json must be the configured provider's AssignmentStatus for this Work Item, with a claimed record and self_is_holder actor matching the current runtime");
1937
+ }
1866
1938
  effective = candidate as EffectiveResult;
1867
1939
  } else if (assignmentProviderKind === "local-file") {
1868
1940
  // Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
@@ -1898,10 +1970,8 @@ function enforceEnsureSessionOwnership(
1898
1970
  return null;
1899
1971
  }
1900
1972
 
1901
- const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string } | null => {
1902
- // Only a claim read from the local provider is durable evidence. Precomputed provider state
1903
- // can authorize entry, but it cannot prove that this process acquired the Work Item.
1904
- if (!workItemRef || effectiveStateJsonFlag || assignmentProviderKind !== "local-file") return null;
1973
+ const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null => {
1974
+ if (!workItemRef) return null;
1905
1975
  const assignment = readLocalAssignmentStatus(root, slug);
1906
1976
  const record = assignment.record;
1907
1977
  if (!record
@@ -1914,6 +1984,8 @@ function enforceEnsureSessionOwnership(
1914
1984
  return {
1915
1985
  assignmentFile: assignmentFilePath(root, slug),
1916
1986
  actorKey: resolution.branchActorKey,
1987
+ ...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
1988
+ ...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
1917
1989
  };
1918
1990
  };
1919
1991
 
@@ -1929,7 +2001,22 @@ function enforceEnsureSessionOwnership(
1929
2001
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1930
2002
  // effective_state computation instead of silently using a different identity.
1931
2003
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1932
- if (isSelf) return selectedWorkEvidence();
2004
+ if (isSelf) {
2005
+ if (assignmentProviderKind !== "local-file") {
2006
+ const local = readLocalAssignmentStatus(root, slug).record;
2007
+ if (!local || local.status !== "claimed") {
2008
+ performLocalClaim(root, slug, resolution.actorStruct, {
2009
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
2010
+ branch: resolveBranchForClaim(),
2011
+ artifactDir: path.relative(root, dir) || ".",
2012
+ reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
2013
+ actorKey: resolution.branchActorKey,
2014
+ ...(workItemRef ? { workItemRef } : {}),
2015
+ });
2016
+ }
2017
+ }
2018
+ return selectedWorkEvidence();
2019
+ }
1933
2020
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1934
2021
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1935
2022
  die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
@@ -1980,6 +2067,9 @@ function enforceEnsureSessionOwnership(
1980
2067
  return selectedWorkEvidence();
1981
2068
  }
1982
2069
  case "free": {
2070
+ if (assignmentProviderKind !== "local-file") {
2071
+ die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
2072
+ }
1983
2073
  performLocalClaim(root, slug, resolution.actorStruct, {
1984
2074
  ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1985
2075
  branch: resolveBranchForClaim(),
@@ -2032,11 +2122,11 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
2032
2122
  return { flowId, stepId: explicitStep || firstStep, firstStep };
2033
2123
  }
2034
2124
 
2035
- function assertCanonicalBuilderArtifactRoot(root: string): void {
2125
+ function assertCanonicalBuilderArtifactRoot(root: string, flowId: "builder.build" | "builder.shape"): void {
2036
2126
  const kontouraiRoot = path.dirname(root);
2037
2127
  const projectRoot = path.dirname(kontouraiRoot);
2038
2128
  if (path.basename(root) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") {
2039
- die("ensure-session --flow-id builder.build requires --artifact-root <project>/.kontourai/flow-agents");
2129
+ die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
2040
2130
  }
2041
2131
 
2042
2132
  const statIfPresent = (candidate: string): fs.Stats | null => {
@@ -2049,7 +2139,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2049
2139
  };
2050
2140
  const projectStat = statIfPresent(projectRoot);
2051
2141
  if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
2052
- die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
2142
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
2053
2143
  }
2054
2144
  const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
2055
2145
  for (const [candidate, expected, label] of [
@@ -2059,7 +2149,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2059
2149
  const stat = statIfPresent(candidate);
2060
2150
  if (!stat) continue;
2061
2151
  if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2062
- die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2152
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
2063
2153
  }
2064
2154
  }
2065
2155
  }
@@ -2070,17 +2160,17 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
2070
2160
  const dir = sessionDirFor(root, slug);
2071
2161
  assertSafeSessionDirectory(root, dir);
2072
2162
  const entry = resolveEnsureSessionEntry(p, dir);
2073
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2163
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2074
2164
  sessionWorkItem(p, slug, dir);
2075
2165
  }
2076
2166
 
2077
- async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2167
+ async function ensureSession(p: ReturnType<typeof parseArgs>, allowCanonicalFlowMutation: boolean): Promise<number> {
2078
2168
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2079
2169
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
2080
2170
  const dir = sessionDirFor(root, slug);
2081
2171
  assertSafeSessionDirectory(root, dir);
2082
2172
  const entry = resolveEnsureSessionEntry(p, dir);
2083
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2173
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2084
2174
  const workItem = sessionWorkItem(p, slug, dir);
2085
2175
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
2086
2176
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -2090,8 +2180,18 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2090
2180
  const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2091
2181
  ? workItem.ref
2092
2182
  : undefined;
2093
- const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2183
+ let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2094
2184
  ensureSafeDirectory(root, dir);
2185
+ if (selectedWorkEvidence?.providerEvidenceBytes) {
2186
+ const staged = path.join(dir, "assignment-provider-state.json");
2187
+ if (fs.existsSync(staged)) {
2188
+ if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes)) die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
2189
+ } else {
2190
+ fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
2191
+ }
2192
+ fs.chmodSync(staged, 0o400);
2193
+ selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
2194
+ }
2095
2195
  const timestamp = opt(p, "timestamp", now());
2096
2196
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2097
2197
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2114,9 +2214,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2114
2214
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2115
2215
  const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
2116
2216
  "workflow", "start",
2117
- "--flow", "builder.build",
2118
- "--work-item", workItem.ref,
2217
+ "--flow", entry.flowId,
2218
+ ...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
2119
2219
  ...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
2220
+ "--assignment-provider", opt(p, "assignment-provider", "local-file"),
2221
+ ...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
2120
2222
  "--artifact-root", root,
2121
2223
  "--title", opt(p, "title", slug),
2122
2224
  "--summary", opt(p, "summary", "Workflow session is durable."),
@@ -2159,9 +2261,9 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2159
2261
  ? persistedCurrent.active_step_id
2160
2262
  : entry.stepId;
2161
2263
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2162
- if (entry.flowId === "builder.build") {
2264
+ if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
2163
2265
  try {
2164
- const started = await startBuilderFlowSession({ sessionDir: dir });
2266
+ const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
2165
2267
  if (started.run.state.current_step === "pull-work"
2166
2268
  && selectedWorkEvidence
2167
2269
  && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
@@ -2176,8 +2278,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2176
2278
  }
2177
2279
  const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2178
2280
  const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2281
+ const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
2282
+ ? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
2283
+ : null;
2284
+ if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
2285
+ die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
2286
+ }
2287
+ const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
2179
2288
  const projectRoot = path.dirname(path.dirname(root));
2180
2289
  const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2290
+ const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
2291
+ let reportIsValid = false;
2292
+ try {
2293
+ const reportStat = fs.lstatSync(pullWorkReport);
2294
+ reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
2295
+ } catch { /* handled by the stable contract error below */ }
2296
+ if (!reportIsValid) {
2297
+ die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
2298
+ }
2181
2299
  await recordGateClaim(parseArgs([
2182
2300
  "record-gate-claim",
2183
2301
  dir,
@@ -2190,8 +2308,25 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2190
2308
  file: evidenceFile,
2191
2309
  summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2192
2310
  }),
2311
+ ...(selectedWorkEvidence.providerEvidenceFile ? [
2312
+ "--evidence-ref-json", JSON.stringify({
2313
+ kind: "artifact",
2314
+ file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
2315
+ summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
2316
+ }),
2317
+ ] : []),
2318
+ "--evidence-ref-json", JSON.stringify({
2319
+ kind: "artifact",
2320
+ file: path.relative(projectRoot, pullWorkReport),
2321
+ summary: `Concrete pull-work selection report for ${workItem.ref}.`,
2322
+ }),
2193
2323
  "--timestamp", timestamp,
2194
2324
  ]));
2325
+ if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
2326
+ && !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
2327
+ die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
2328
+ }
2329
+ await syncBuilderFlowSession({ sessionDir: dir });
2195
2330
  });
2196
2331
  }
2197
2332
  } catch (error) {
@@ -2303,6 +2438,373 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
2303
2438
  return validateEvidenceRef({ ...ref as AnyObj }, label);
2304
2439
  });
2305
2440
  }
2441
+
2442
+ function canonicalProjectRootForSession(dir: string): string {
2443
+ const artifactRoot = path.dirname(dir);
2444
+ const kontouraiRoot = path.dirname(artifactRoot);
2445
+ if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") die("gate evidence requires a canonical .kontourai/flow-agents session");
2446
+ const projectRoot = path.dirname(kontouraiRoot);
2447
+ const stat = fs.lstatSync(projectRoot);
2448
+ if (stat.isSymbolicLink() || !stat.isDirectory()) die("gate evidence project root must be a non-symlink directory");
2449
+ return projectRoot;
2450
+ }
2451
+
2452
+ function pathIsWithinRoot(candidate: string, root: string): boolean {
2453
+ const relative = path.relative(root, candidate);
2454
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
2455
+ }
2456
+
2457
+ function validateLocalEvidenceFile(projectRoot: string, raw: string, label: string): string {
2458
+ const candidate = path.resolve(projectRoot, raw);
2459
+ if (!pathIsWithinRoot(candidate, projectRoot)) die(`${label} must remain inside the canonical project root`);
2460
+ let stat: fs.Stats;
2461
+ try { stat = fs.lstatSync(candidate); } catch { die(`${label} does not exist`); }
2462
+ if (stat.isSymbolicLink() || !stat.isFile()) die(`${label} must be a non-symlink regular file`);
2463
+ if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot))) die(`${label} escapes the canonical project root`);
2464
+ return path.relative(projectRoot, candidate);
2465
+ }
2466
+
2467
+ function globMatches(pattern: string, relative: string): boolean {
2468
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
2469
+ return new RegExp(`^${escaped}$`).test(relative);
2470
+ }
2471
+
2472
+ type GateProducer = { id: string; artifactPatterns: string[]; selfProducedTrustSlices: string[] };
2473
+
2474
+ function expectedGateProducer(flowId: string, stepId: string, expectationId: string): GateProducer {
2475
+ const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
2476
+ const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions as AnyObj[] : [];
2477
+ const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
2478
+ if (!action) die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
2479
+ const skills = Array.isArray(action.skills) ? action.skills.filter((value: unknown): value is string => typeof value === "string") : [];
2480
+ const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles as AnyObj[] : [];
2481
+ const owners = roles.filter((role) => typeof role.skill_id === "string"
2482
+ && Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
2483
+ && Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
2484
+ && skills.includes(role.skill_id.replace(/^builder\./, "")));
2485
+ if (owners.length === 0) {
2486
+ const operations = Array.isArray(action.operations) ? action.operations.filter((value: unknown): value is string => typeof value === "string") : [];
2487
+ const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
2488
+ if (operations.length === 1 && operationExpectations.includes(expectationId)) {
2489
+ const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value: unknown): value is string => typeof value === "string") : [];
2490
+ return {
2491
+ id: `operation:${operations[0]}`,
2492
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2493
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2494
+ };
2495
+ }
2496
+ }
2497
+ if (owners.length !== 1) die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
2498
+ const owner = owners[0]!;
2499
+ const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value): value is string => typeof value === "string" && value !== "ephemeral decision record");
2500
+ return {
2501
+ id: owner.skill_id,
2502
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2503
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2504
+ };
2505
+ }
2506
+
2507
+ function validateReviewableGateEvidence(dir: string, slug: string, refs: AnyObj[], producer: GateProducer, label: string): void {
2508
+ if (refs.length === 0) die(`${label} requires at least one reviewable --evidence-ref-json`);
2509
+ const projectRoot = canonicalProjectRootForSession(dir);
2510
+ let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
2511
+ let localOrCommandEvidenceFound = false;
2512
+ for (const [index, ref] of refs.entries()) {
2513
+ if (ref.kind === "command" && hasNonEmptyString(ref.excerpt)) localOrCommandEvidenceFound = true;
2514
+ if (typeof ref.file !== "string") continue;
2515
+ const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
2516
+ ref.file = relative;
2517
+ localOrCommandEvidenceFound = true;
2518
+ if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
2519
+ const patterns = producer.artifactPatterns.map((pattern) => {
2520
+ const expanded = pattern.replaceAll("<slug>", slug);
2521
+ return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
2522
+ });
2523
+ if (patterns.some((pattern) => globMatches(pattern, relative))) declaredProducerArtifactFound = true;
2524
+ }
2525
+ }
2526
+ if (!declaredProducerArtifactFound) die(`${label} requires a declared durable artifact from producer ${producer.id}`);
2527
+ if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
2528
+ die(`${label} is produced as trust.bundle fragment(s) ${producer.selfProducedTrustSlices.join(", ")} and requires local artifact or command evidence; external-only references cannot prove a passing claim`);
2529
+ }
2530
+ }
2531
+
2532
+ function commandFromEvidenceRef(ref: AnyObj): string {
2533
+ return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
2534
+ }
2535
+
2536
+ function hasTestIntent(name: string): boolean {
2537
+ return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
2538
+ }
2539
+
2540
+ function resolvesExplicitTestTarget(projectRoot: string, token: string): boolean {
2541
+ if (!hasTestIntent(token)) return false;
2542
+ try {
2543
+ if (/[*?\[]/.test(token)) return fs.globSync(token, { cwd: projectRoot }).length > 0;
2544
+ const target = path.resolve(projectRoot, token);
2545
+ return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
2546
+ } catch { return false; }
2547
+ }
2548
+
2549
+ /** Validate test-evidence command shape without executing it. */
2550
+ type TestExecutionProof = {
2551
+ kind: "local-process-exit";
2552
+ runner: string;
2553
+ static_test_units: number;
2554
+ };
2555
+
2556
+ function staticTestUnits(file: string, executable: string): number {
2557
+ try {
2558
+ const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
2559
+ const hashComments = !["cargo", "go"].includes(executable);
2560
+ const active = content.split("\n")
2561
+ .map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
2562
+ .filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
2563
+ .join("\n");
2564
+ if (["bash", "sh", "zsh"].includes(executable)
2565
+ && !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active)) return 0;
2566
+ const assertions = active.match(/(?:\b(?:test|it)\s*\(|\b(?:assert|expect)\s*\(|^\s*(?:async\s+)?def\s+test_|^\s*(?:public\s+)?function\s+test|^\s*it\s+["']|^\s*func\s+(?:Test|Fuzz|Example)\w*\s*\(|#\[(?:\w+::)?test\]|^(?:if\s+! ?)?(?:test\s|\[\s|\[\[\s|(?:grep|rg|diff|cmp)\s))/gm) ?? [];
2567
+ return assertions.length;
2568
+ } catch {
2569
+ return 0;
2570
+ }
2571
+ }
2572
+
2573
+ /**
2574
+ * Produce evidence from the locally executed command and statically reviewable
2575
+ * test units. Runner stdout is deliberately excluded: any executable can print
2576
+ * a Vitest/Jest-looking success summary, but it cannot turn a non-test script
2577
+ * into a supported test workflow or supply this locally-created proof.
2578
+ */
2579
+ export function testExecutionProof(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): TestExecutionProof | null {
2580
+ const normalized = command.trim().replace(/\s+/g, " ");
2581
+ if (!normalized || /[`$()]/.test(normalized)) return null;
2582
+ if (/[;&|]/.test(normalized)) {
2583
+ if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized)) return null;
2584
+ const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
2585
+ return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
2586
+ }
2587
+ if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized)) return null;
2588
+ if (/^(?:echo|printf)(?:\s|$)/i.test(normalized)) return null;
2589
+ if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized)) return null;
2590
+ const tokens = normalized.split(" ").filter(Boolean);
2591
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? "")) tokens.shift();
2592
+ const executable = tokens[0] ?? "";
2593
+ const executableName = path.basename(executable);
2594
+ if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
2595
+ // `bun test` is Bun's test runner; package scripts use `bun run <script>`.
2596
+ if (executableName === "bun" && tokens[1] === "test") {
2597
+ const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
2598
+ const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
2599
+ return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
2600
+ }
2601
+ const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
2602
+ if (!script || !hasTestIntent(script) || seenScripts.has(script)) return null;
2603
+ try {
2604
+ const pkg = loadJson(path.join(projectRoot, "package.json"));
2605
+ const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
2606
+ if (typeof scriptCommand !== "string") return null;
2607
+ seenScripts.add(script);
2608
+ return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
2609
+ } catch { return null; }
2610
+ }
2611
+ if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
2612
+ && !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
2613
+ if (executable !== executableName) return null;
2614
+ const targets = tokens.slice(1)
2615
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2616
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2617
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2618
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2619
+ }
2620
+ if (executableName === "cargo" && tokens[1] === "test") {
2621
+ const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
2622
+ const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
2623
+ return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
2624
+ }
2625
+ if (executableName === "go" && tokens[1] === "test") {
2626
+ const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
2627
+ const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
2628
+ return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
2629
+ }
2630
+ if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
2631
+ const runner = path.basename(tokens[1]);
2632
+ const targets = tokens.slice(2)
2633
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2634
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2635
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), runner), 0);
2636
+ return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
2637
+ }
2638
+ if (executableName === "node" && tokens.includes("--test")) {
2639
+ const testFlag = tokens.indexOf("--test");
2640
+ const targets = tokens.slice(testFlag + 1)
2641
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2642
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2643
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2644
+ return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
2645
+ }
2646
+ const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
2647
+ if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath)) return null;
2648
+ try {
2649
+ const resolved = path.resolve(projectRoot, scriptPath);
2650
+ const stat = fs.lstatSync(resolved);
2651
+ if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot))) return null;
2652
+ const units = staticTestUnits(resolved, executableName);
2653
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2654
+ } catch { return null; }
2655
+ }
2656
+
2657
+ /** Validate test-evidence command shape without executing it. */
2658
+ export function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): boolean {
2659
+ return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
2660
+ }
2661
+
2662
+ export function observedExecutedTestCount(output: string): number {
2663
+ const counts: number[] = [];
2664
+ for (const pattern of [
2665
+ /^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
2666
+ /^\s*1\.\.(\d+)(?:\s|$)/gim,
2667
+ /\b(\d+)\s+passed\b/gim,
2668
+ ]) {
2669
+ for (const match of output.matchAll(pattern)) counts.push(Number(match[1]));
2670
+ }
2671
+ const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
2672
+ if (explicitPasses > 0) counts.push(explicitPasses);
2673
+ const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
2674
+ if (goPackages > 0) counts.push(goPackages);
2675
+ return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
2676
+ }
2677
+
2678
+ export function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts = new Set<string>()): number {
2679
+ const proof = testExecutionProof(command, projectRoot, seenScripts);
2680
+ if (!proof) return 0;
2681
+ const observed = observedExecutedTestCount(output);
2682
+ return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
2683
+ }
2684
+
2685
+ type ObservedCommand = { command: string; exit_code: number; output_sha256: string; test_count?: number; execution_proof?: TestExecutionProof };
2686
+
2687
+ async function normalizeObservedCommands(commands: string[], projectRoot: string, requireTestIntent: boolean, expectedStatus: string): Promise<ObservedCommand[]> {
2688
+ if (commands.length === 0) die("record-gate-claim requires at least one --command for observed command evidence");
2689
+ if (new Set(commands).size !== commands.length) die("record-gate-claim --command values must be unique");
2690
+ for (const command of commands) {
2691
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2692
+ if (!isRunnableCommandText(command)) die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2693
+ if (requireTestIntent && !isMeaningfulTestCommand(command, projectRoot)) die("record-gate-claim tests-evidence command must resolve through a non-vacuous package script or a known test/check/verify/eval runner or project-local test path; shell wrappers, no-ops, version/help commands, and arbitrary node -e commands are not evidence");
2694
+ }
2695
+ // Passing test evidence is always executed exactly once by this canonical
2696
+ // writer. Caller-supplied observations remain available for non-test
2697
+ // attestations but can never stand in for locally observed test execution.
2698
+ const observed = await Promise.all(commands.map(async (command) => {
2699
+ const result = await runObservedCommand(command, projectRoot);
2700
+ const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
2701
+ return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
2702
+ }));
2703
+ if (observed.length !== commands.length) die("record-gate-claim requires exactly one --observed-command-json for every --command");
2704
+ const byCommand = new Map<string, ObservedCommand>();
2705
+ for (const entry of observed) {
2706
+ if (typeof entry.command !== "string" || typeof entry.exit_code !== "number" || !Number.isInteger(entry.exit_code) || typeof entry.output_sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(entry.output_sha256)) die("--observed-command-json must contain command, integer exit_code, and sha256 output_sha256");
2707
+ if (!commands.includes(entry.command)) die("--observed-command-json command must exactly match one supplied --command");
2708
+ if (expectedStatus === "pass" && entry.exit_code !== 0) die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
2709
+ if (expectedStatus === "fail" && entry.exit_code === 0) die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
2710
+ if (requireTestIntent && (!Number.isSafeInteger(entry.test_count) || Number(entry.test_count) <= 0 || !entry.execution_proof || entry.execution_proof.kind !== "local-process-exit")) die(`record-gate-claim passing tests-evidence command did not produce a local execution proof: ${entry.command}`);
2711
+ if (byCommand.has(entry.command)) die("--observed-command-json command values must be unique");
2712
+ byCommand.set(entry.command, entry as ObservedCommand);
2713
+ }
2714
+ if (commands.some((command) => !byCommand.has(command))) die("every --command requires a corresponding --observed-command-json");
2715
+ return commands.map((command) => byCommand.get(command)!);
2716
+ }
2717
+
2718
+ function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySet<string>, label: string, requireAll = false): void {
2719
+ const commandRefs = refs.filter((ref) => ref.kind === "command");
2720
+ if (commandRefs.length === 0) die(`${label} requires a command evidence ref matching a successful observed command`);
2721
+ for (const ref of commandRefs) {
2722
+ if (!observedCommands.has(commandFromEvidenceRef(ref))) die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
2723
+ }
2724
+ if (requireAll) {
2725
+ const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
2726
+ if ([...observedCommands].some((command) => !referenced.has(command))) die(`${label} requires a top-level command evidence ref for every successful observed command`);
2727
+ }
2728
+ }
2729
+
2730
+ function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>): AnyObj[] {
2731
+ if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2732
+ const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
2733
+ const expectedById = new Map<string, AnyObj>();
2734
+ for (const criterion of existing) {
2735
+ const id = String(criterion.id ?? "");
2736
+ if (id.length > 0) expectedById.set(id, criterion);
2737
+ }
2738
+ const expectedIds = [...expectedById.keys()];
2739
+ const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
2740
+ if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
2741
+ die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
2742
+ }
2743
+ return incoming.map((criterion, index) => {
2744
+ if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key))) die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
2745
+ if (criterion.status !== "pass") die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
2746
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
2747
+ if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2748
+ requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2749
+ return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs };
2750
+ });
2751
+ }
2752
+
2753
+ const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
2754
+ const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
2755
+
2756
+ function normalizeCritiqueLanes(raw: string[]): AnyObj[] {
2757
+ if (raw.length === 0) die("record-critique requires at least one --lane-json");
2758
+ const lanes = raw.map((value, index) => {
2759
+ const lane = parseJson(value, "--lane-json");
2760
+ const keys = Object.keys(lane);
2761
+ if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key))) die(`--lane-json ${index} contains unsupported fields`);
2762
+ if (!safeCritiqueId.test(String(lane.id ?? ""))) die(`--lane-json ${index} id must be a unique safe identifier`);
2763
+ if (!critiqueStatuses.has(String(lane.status ?? ""))) die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
2764
+ if (!hasNonEmptyString(lane.summary)) die(`--lane-json ${index} summary must be non-empty`);
2765
+ const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
2766
+ if (evidenceRefs.length === 0) die(`--lane-json ${index} requires structured reviewable evidence_refs`);
2767
+ return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
2768
+ });
2769
+ if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) die("--lane-json ids must be unique");
2770
+ return lanes;
2771
+ }
2772
+
2773
+ function reviewTargetArtifacts(dir: string, rawPaths: string[], label: string): AnyObj[] {
2774
+ const projectRoot = canonicalProjectRootForSession(dir);
2775
+ const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
2776
+ const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
2777
+ const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
2778
+ if (new Set(files).size !== files.length) die(`${label} artifact refs must be unique`);
2779
+ return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
2780
+ }
2781
+
2782
+ function reviewTargetArtifactsMatch(dir: string, reviewTarget: unknown): boolean {
2783
+ try {
2784
+ if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget)) return false;
2785
+ const artifacts = (reviewTarget as AnyObj).artifacts;
2786
+ if (!Array.isArray(artifacts) || artifacts.length === 0) return false;
2787
+ const projectRoot = canonicalProjectRootForSession(dir);
2788
+ const files = new Set<string>();
2789
+ return artifacts.every((artifact) => {
2790
+ if (!artifact || typeof artifact !== "object") return false;
2791
+ const file = (artifact as AnyObj).file;
2792
+ const sha256 = (artifact as AnyObj).sha256;
2793
+ if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file)) return false;
2794
+ files.add(file);
2795
+ const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
2796
+ const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
2797
+ return digest === sha256;
2798
+ });
2799
+ } catch { return false; }
2800
+ }
2801
+
2802
+ function critiqueIsCleanAndCurrent(dir: string, critique: AnyObj): boolean {
2803
+ if (critique.verdict !== "pass") return false;
2804
+ if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane: AnyObj) => lane.status !== "pass")) return false;
2805
+ if (Array.isArray(critique.findings) && critique.findings.some((finding: AnyObj) => finding.status === "open")) return false;
2806
+ return reviewTargetArtifactsMatch(dir, critique.review_target);
2807
+ }
2306
2808
  // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2307
2809
  // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2308
2810
  // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
@@ -2604,6 +3106,17 @@ function checksFromBundle(dir: string): AnyObj[] {
2604
3106
  const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
2605
3107
  return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2606
3108
  };
3109
+ const observedCommandsOf = (claim: AnyObj): ObservedCommand[] | undefined => {
3110
+ const md = claim.metadata as AnyObj;
3111
+ const observed = md && typeof md === "object" ? md.observed_commands : undefined;
3112
+ return Array.isArray(observed) ? observed.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
3113
+ };
3114
+ const applyProducerStamp = (check: AnyObj, claim: AnyObj): void => {
3115
+ const md = claim.metadata as AnyObj;
3116
+ if (md && typeof md.expected_producer === "string") check._producer = md.expected_producer;
3117
+ if (md && typeof md.recorded_by === "string") check._recorded_by = md.recorded_by;
3118
+ if (md && Array.isArray(md.self_produced_trust_slices)) check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
3119
+ };
2607
3120
  // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2608
3121
  // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2609
3122
  // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
@@ -2677,6 +3190,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2677
3190
  Object.assign(check, refsOf(claim));
2678
3191
  const outputSha256 = outputSha256Of(claim);
2679
3192
  if (outputSha256) check._output_sha256 = outputSha256;
3193
+ const observedCommands = observedCommandsOf(claim);
3194
+ if (observedCommands) check._observed_commands = observedCommands;
3195
+ applyProducerStamp(check, claim);
2680
3196
  applyGateClaimStamp(check, claim);
2681
3197
  applyGateClaimShapeUnstamped(check, claim);
2682
3198
  checks.push(check);
@@ -2694,6 +3210,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2694
3210
  Object.assign(check, refsOf(claim));
2695
3211
  const outputSha256 = outputSha256Of(claim);
2696
3212
  if (outputSha256) check._output_sha256 = outputSha256;
3213
+ const observedCommands = observedCommandsOf(claim);
3214
+ if (observedCommands) check._observed_commands = observedCommands;
3215
+ applyProducerStamp(check, claim);
2697
3216
  applyGateClaimStamp(check, claim);
2698
3217
  applyGateClaimShapeUnstamped(check, claim);
2699
3218
  checks.push(check);
@@ -2728,9 +3247,18 @@ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
2728
3247
  */
2729
3248
  function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
2730
3249
  const acceptance = loadJson(path.join(dir, "acceptance.json"));
3250
+ const bundledCriteria = criteriaFromBundle(dir);
3251
+ const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria as AnyObj[] : [];
3252
+ const contractSignature = (criteria: AnyObj[]): string => JSON.stringify(criteria.map((criterion) => ({
3253
+ id: criterion.id ?? null,
3254
+ description: criterion.description ?? null,
3255
+ })));
3256
+ const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
3257
+ ? acceptedCriteria
3258
+ : (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
2731
3259
  return {
2732
3260
  checks: checksFromBundle(dir),
2733
- criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
3261
+ criteria,
2734
3262
  critiques: critiquesFromBundle(dir),
2735
3263
  };
2736
3264
  }
@@ -2761,14 +3289,35 @@ function critiquesFromBundle(dir: string): AnyObj[] {
2761
3289
  id: String(c.subjectId || "").split("/").pop() || c.id,
2762
3290
  verdict: c.value ?? "not_verified",
2763
3291
  summary: c.fieldOrBehavior || "",
2764
- findings: [],
3292
+ findings: Array.isArray(md.findings) ? md.findings : [],
3293
+ lanes: Array.isArray(md.lanes) ? md.lanes : [],
3294
+ review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
2765
3295
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
2766
3296
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
2767
- artifact_refs: [],
3297
+ artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
2768
3298
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
2769
3299
  };
2770
3300
  });
2771
3301
  }
3302
+
3303
+ function criteriaFromBundle(dir: string): AnyObj[] {
3304
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
3305
+ if (!Array.isArray(bundle.claims)) return [];
3306
+ for (const claim of bundle.claims) requireStampedClaim(claim, dir);
3307
+ return bundle.claims
3308
+ .filter((claim: AnyObj) => claim && claimOrigin(claim) === "acceptance")
3309
+ .map((claim: AnyObj) => {
3310
+ const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata as AnyObj : {};
3311
+ const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion as AnyObj : {};
3312
+ return {
3313
+ id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
3314
+ description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3315
+ status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3316
+ evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3317
+ };
3318
+ })
3319
+ .filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
3320
+ }
2772
3321
  // ─────────────────────────────────────────────────────────────────────────────
2773
3322
  /**
2774
3323
  * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
@@ -2827,13 +3376,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
2827
3376
  // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2828
3377
  // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2829
3378
  // exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
2830
- const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
2831
- const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
2832
3379
  const _mergedChecks = mergeChecksById(_existingState.checks, checks);
2833
3380
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
2834
3381
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2835
3382
  // whenever it ran after a critique existed.
2836
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
3383
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2837
3384
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
2838
3385
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
2839
3386
  return 0;
@@ -3038,14 +3585,22 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3038
3585
  if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
3039
3586
  if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
3040
3587
 
3041
- // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
3042
- // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
3043
- // unconditional legacy-only read this is the fix for record-gate-claim's pre-existing (#291
3044
- // Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
3045
- // view, not a different actor's more-recently-written legacy pointer.
3588
+ // Prefer the exact session's canonical Flow projection. Actor/global current pointers are
3589
+ // ambient navigation state and may legitimately point at another run or lag this run.
3590
+ // Legacy sessions without flow_run retain the per-actor current-pointer fallback.
3046
3591
  const flowAgentsDir = path.dirname(dir);
3047
3592
  const gateClaimActorKey = resolveReadActorKey(p);
3048
- const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3593
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3594
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3595
+ ? sidecarState.flow_run as AnyObj
3596
+ : null;
3597
+ const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
3598
+ const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
3599
+ const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
3600
+ const activeStep = exactFlowContext
3601
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
3602
+ : resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3603
+ if (exactFlowContext && !activeStep) die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
3049
3604
  if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
3050
3605
  if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
3051
3606
  die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
@@ -3066,18 +3621,34 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3066
3621
  }
3067
3622
 
3068
3623
  const { claimType, subjectType } = targetExpectation.bundle_claim;
3624
+ const gateCommands = opts(p, "command");
3625
+ const gateCommand = gateCommands[0] ?? "";
3626
+ const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
3627
+ const observedCommandRaw = opts(p, "observed-command-json");
3628
+ if (mustRunTests && gateCommands.length === 0) die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
3629
+ const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
3630
+ const observedCommands = gateCommands.length > 0
3631
+ ? await normalizeObservedCommands(gateCommands, projectRoot!, mustRunTests, statusVal)
3632
+ : [];
3633
+ const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
3634
+ let outputSha256: string | null = null;
3635
+ if (!mustRunTests && gateCommands.length > 1) die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
3636
+ if (gateCommands.length === 0 && observedCommandRaw.length > 0) die("--observed-command-json requires --command");
3637
+ if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
3638
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
3639
+ if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3640
+ }
3641
+ if (observedCommands.length > 0) outputSha256 = observedCommands[0]!.output_sha256;
3069
3642
 
3070
- // Build a synthetic external check that will be matched by matchExpectsEntry to produce
3071
- // a correctly-typed claim. We use kind="external" so it routes through the non-policy,
3072
- // non-flow-step fallback path. The subjectType on the resulting claim comes from the
3073
- // expects[] entry via matchExpectsEntry.
3643
+ // Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
3644
+ // Command-backed checks retain their executable evidence classification.
3074
3645
  const checkId = expectationId || targetExpectation.id;
3075
3646
  // Build a minimal "external" check. Include _gate_claim_expectation_id so that
3076
3647
  // matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
3077
3648
  // normalizeCheck preserves extra underscore-prefixed fields without stripping them.
3078
3649
  const check: AnyObj = {
3079
3650
  id: `gate-claim-${checkId}`,
3080
- kind: "external",
3651
+ kind: gateCommands.length > 0 ? "command" : "external",
3081
3652
  status: statusVal,
3082
3653
  summary,
3083
3654
  _gate_claim_expectation_id: targetExpectation.id,
@@ -3086,10 +3657,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3086
3657
 
3087
3658
  // Include structured evidence refs if provided
3088
3659
  const evidenceRefs: AnyObj[] = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
3660
+ const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
3661
+ if (statusVal === "pass") validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
3662
+ if (mustRunTests) requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
3089
3663
 
3090
3664
  if (evidenceRefs.length > 0) {
3091
3665
  check.artifact_refs = evidenceRefs;
3092
3666
  }
3667
+ check._producer = producer.id;
3668
+ check._recorded_by = gateClaimActorKey;
3669
+ if (producer.selfProducedTrustSlices.length > 0) check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
3093
3670
 
3094
3671
  // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
3095
3672
  // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
@@ -3097,14 +3674,11 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3097
3674
  // prose itself), which it would otherwise execute as a shell command under
3098
3675
  // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
3099
3676
  // heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
3100
- const gateCommand = opt(p, "command");
3101
- if (gateCommand) {
3102
- const { isRunnableCommandText } = loadRunnableCommandHelper();
3103
- if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3104
- check.command = gateCommand;
3105
- }
3677
+ if (gateCommand) check.command = gateCommand;
3678
+ if (observedCommands.length > 0) check._observed_commands = observedCommands;
3106
3679
 
3107
3680
  const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
3681
+ if (outputSha256) checkNormalized._output_sha256 = outputSha256;
3108
3682
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
3109
3683
  const gateWaiver = parseWaiver(p, ts);
3110
3684
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
@@ -3116,8 +3690,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3116
3690
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3117
3691
  // gate claim against a different expectation is additive.
3118
3692
  const _existingState = readBundleState(dir);
3693
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames) : _existingState.criteria;
3694
+ if (mustRunTests) {
3695
+ const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3696
+ if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
3697
+ die("a passing tests-evidence claim requires a current clean critique first");
3698
+ }
3699
+ for (const criterion of criteria) validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
3700
+ }
3119
3701
  const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
3120
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
3702
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
3121
3703
  return 0;
3122
3704
  }
3123
3705
 
@@ -3299,12 +3881,46 @@ export function normalizeFinding(raw: AnyObj): AnyObj {
3299
3881
  async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number> {
3300
3882
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3301
3883
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3302
- // Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
3303
- // Fall back to critique.json for legacy sessions that still have it on disk.
3304
- const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
3305
- const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
3306
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
3307
- const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
3884
+ const verdict = opt(p, "verdict");
3885
+ if (!critiqueStatuses.has(verdict)) die("record-critique requires --verdict pass, fail, or not_verified");
3886
+ const summary = opt(p, "summary");
3887
+ if (!hasNonEmptyString(summary)) die("record-critique requires --summary");
3888
+ const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
3889
+ const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
3890
+ if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
3891
+ die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
3892
+ }
3893
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3894
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3895
+ ? sidecarState.flow_run as AnyObj
3896
+ : null;
3897
+ const exactFlowContext = projectedRun
3898
+ && typeof projectedRun.definition_id === "string"
3899
+ && typeof projectedRun.current_step === "string"
3900
+ ? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
3901
+ : undefined;
3902
+ // trust.bundle is the sole critique source. critique.json is unsupported historical residue.
3903
+ const _critiqueState = readBundleState(dir);
3904
+ const bundleCritiques = _critiqueState.critiques;
3905
+ const critiqueId = opt(p, "id", "review");
3906
+ if (!safeCritiqueId.test(critiqueId)) die("record-critique --id must be a safe identifier");
3907
+ const critique = {
3908
+ id: critiqueId,
3909
+ reviewer: opt(p, "reviewer", "tool-code-reviewer"),
3910
+ reviewed_at: opt(p, "timestamp", now()),
3911
+ verdict,
3912
+ summary,
3913
+ lanes,
3914
+ review_target: {
3915
+ artifacts: reviewArtifacts,
3916
+ workspace_snapshot: captureReviewWorkspaceSnapshot(
3917
+ canonicalProjectRootForSession(dir),
3918
+ reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) })),
3919
+ ),
3920
+ },
3921
+ artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
3922
+ findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
3923
+ };
3308
3924
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
3309
3925
  // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
3310
3926
  // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
@@ -3329,8 +3945,7 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3329
3945
  // checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
3330
3946
  // already exists to share; recordLearning uses it directly (see below) and recordCritique
3331
3947
  // previously duplicated it by hand for no reason.
3332
- const _critiqueState = readBundleState(dir);
3333
- assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
3948
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
3334
3949
  return 0;
3335
3950
  }
3336
3951
  function frontmatter(text: string, key: string): string {
@@ -3353,7 +3968,22 @@ async function importCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3353
3968
  const title = m.groups?.title ?? "finding";
3354
3969
  findings.push({ id: slugify(title, `finding-${findings.length + 1}`), severity: (m.groups?.severity ?? "info").toLowerCase(), status: opt(p, "finding-status", verdict === "pass" ? "fixed" : "open"), description: title, file_refs: [m.groups?.target ?? review] });
3355
3970
  }
3356
- const parsed = { ...p, positional: [dir], opts: { ...p.opts, id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")], reviewer: ["tool-code-reviewer"], verdict: [verdict], summary: [`Imported critique from ${path.basename(review)}`], "finding-json": findings.map((f) => JSON.stringify(f)) }, flags: p.flags };
3971
+ const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
3972
+ const parsed = {
3973
+ ...p,
3974
+ positional: [dir],
3975
+ opts: {
3976
+ ...p.opts,
3977
+ id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
3978
+ reviewer: ["tool-code-reviewer"],
3979
+ verdict: [verdict],
3980
+ summary: [`Imported critique from ${path.basename(review)}`],
3981
+ "artifact-ref": [importedArtifact],
3982
+ "lane-json": [JSON.stringify({ id: "imported-review", status: verdict, summary: `Imported review artifact ${path.basename(review)}.`, evidence_refs: [{ kind: "artifact", file: importedArtifact, summary: "Imported review artifact." }] })],
3983
+ "finding-json": findings.map((f) => JSON.stringify(f)),
3984
+ },
3985
+ flags: p.flags,
3986
+ };
3357
3987
  const result = await recordCritique(parsed);
3358
3988
  if (verdict !== "pass") die("required critique must pass");
3359
3989
  return result;
@@ -4774,10 +5404,7 @@ function evidenceClean(dir: string): boolean {
4774
5404
  });
4775
5405
  }
4776
5406
  function critiqueClean(dir: string): boolean {
4777
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
4778
- // legacy (pre-bundle-era) sessions — unrelated to origin stamping. When a trust.bundle IS
4779
- // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
4780
- // for an unstamped claim (#268/#344).
5407
+ // trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
4781
5408
  const bundle = loadJson(path.join(dir, "trust.bundle"));
4782
5409
  if (Array.isArray(bundle.claims)) {
4783
5410
  for (const c of bundle.claims) requireStampedClaim(c, dir);
@@ -4788,14 +5415,11 @@ function critiqueClean(dir: string): boolean {
4788
5415
  return claimOrigin(c) === "critique";
4789
5416
  });
4790
5417
  if (critiqueClaims.length === 0) return false; // no critique written yet
4791
- return critiqueClaims.every((c: AnyObj) => {
4792
- const v = String(c.value || "");
4793
- return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
4794
- });
5418
+ return critiquesFromBundle(dir)
5419
+ .filter((critique) => !critique.superseded_by)
5420
+ .every((critique) => critiqueIsCleanAndCurrent(dir, critique));
4795
5421
  }
4796
- // Legacy fallback: critique.json
4797
- const c = loadJson(path.join(dir, "critique.json"), {});
4798
- return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x: AnyObj) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f: AnyObj) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
5422
+ return false;
4799
5423
  }
4800
5424
  function assertExistingLearningValid(dir: string): void {
4801
5425
  const file = path.join(dir, "learning.json");
@@ -4840,8 +5464,7 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
4840
5464
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
4841
5465
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4842
5466
  if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
4843
- // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
4844
- if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
5467
+ if (!critiqueClean(dir) && fs.existsSync(path.join(dir, "trust.bundle"))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4845
5468
  }
4846
5469
  }
4847
5470
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
@@ -5161,7 +5784,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
5161
5784
  // Locate trust.bundle — required per SKILL.md contract
5162
5785
  const bundlePath = path.join(dir, "trust.bundle");
5163
5786
  if (!fs.existsSync(bundlePath)) {
5164
- process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
5787
+ process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
5165
5788
  return 1;
5166
5789
  }
5167
5790
 
@@ -5908,7 +6531,11 @@ Available claim ids:
5908
6531
  // ─────────────────────────────────────────────────────────────────────────────
5909
6532
 
5910
6533
 
5911
- export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
6534
+ export function mainFromPublicWorkflow(argv: string[]): Promise<number> {
6535
+ return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
6536
+ }
6537
+
6538
+ export async function main(argv: string[] = process.argv.slice(2), authority?: symbol): Promise<number> {
5912
6539
  const _rawArgv = argv;
5913
6540
  // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5914
6541
  // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
@@ -5948,7 +6575,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
5948
6575
  : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
5949
6576
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
5950
6577
  switch (p.command) {
5951
- case "ensure-session": return ensureSession(p);
6578
+ case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
5952
6579
  case "current": return current(p);
5953
6580
  case "record-agent-event": return recordAgentEvent(p);
5954
6581
  case "init-plan": return initPlan(p);