@kontourai/flow-agents 3.6.0 → 3.8.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 (96) hide show
  1. package/CHANGELOG.md +19 -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 +407 -51
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +80 -14
  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 +825 -103
  11. package/build/src/cli/workflow.js +301 -43
  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/build-universal-bundles.js +53 -3
  18. package/build/src/tools/generate-context-map.js +5 -3
  19. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  20. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  21. package/docs/context-map.md +22 -20
  22. package/docs/developer-architecture.md +1 -1
  23. package/docs/getting-started.md +9 -1
  24. package/docs/public-workflow-cli.md +76 -7
  25. package/docs/skills-map.md +51 -27
  26. package/docs/spec/builder-flow-runtime.md +75 -40
  27. package/docs/workflow-usage-guide.md +109 -42
  28. package/evals/fixtures/hook-influence/cases.json +2 -2
  29. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  30. package/evals/integration/test_builder_step_producers.sh +330 -6
  31. package/evals/integration/test_bundle_install.sh +318 -65
  32. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  33. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  34. package/evals/integration/test_current_json_per_actor.sh +12 -0
  35. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  36. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  37. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  38. package/evals/integration/test_gate_lockdown.sh +3 -5
  39. package/evals/integration/test_goal_fit_hook.sh +60 -2
  40. package/evals/integration/test_install_merge.sh +18 -8
  41. package/evals/integration/test_liveness_verdict.sh +14 -17
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  43. package/evals/integration/test_public_workflow_cli.sh +334 -14
  44. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  45. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  46. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  47. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  48. package/evals/run.sh +2 -0
  49. package/evals/static/test_builder_skill_coherence.sh +247 -0
  50. package/evals/static/test_library_exports.sh +5 -2
  51. package/evals/static/test_universal_bundles.sh +44 -1
  52. package/evals/static/test_workflow_skills.sh +13 -325
  53. package/kits/builder/flows/build.flow.json +22 -0
  54. package/kits/builder/flows/shape.flow.json +9 -9
  55. package/kits/builder/kit.json +70 -16
  56. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  57. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  58. package/kits/builder/skills/deliver/SKILL.md +96 -442
  59. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  60. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  61. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  62. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  63. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  64. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  65. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  66. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  67. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  68. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  69. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  70. package/kits/builder/skills/review-work/SKILL.md +89 -176
  71. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  72. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  73. package/package.json +2 -2
  74. package/packaging/README.md +1 -1
  75. package/scripts/README.md +1 -1
  76. package/scripts/hooks/lib/kit-catalog.js +1 -1
  77. package/scripts/hooks/stop-goal-fit.js +78 -9
  78. package/scripts/install-codex-home.sh +63 -23
  79. package/scripts/install-owned-files.js +18 -2
  80. package/src/builder-flow-run-adapter.ts +118 -32
  81. package/src/builder-flow-runtime.ts +426 -64
  82. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  83. package/src/cli/assignment-provider.ts +75 -14
  84. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  85. package/src/cli/builder-flow-runtime.test.mjs +436 -18
  86. package/src/cli/builder-run.ts +3 -9
  87. package/src/cli/kit-metadata-security.test.mjs +232 -6
  88. package/src/cli/public-api.test.mjs +15 -0
  89. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  90. package/src/cli/workflow-sidecar.ts +746 -103
  91. package/src/cli/workflow.ts +288 -41
  92. package/src/flow-kit/validate.ts +320 -2
  93. package/src/index.ts +6 -5
  94. package/src/lib/observed-command.ts +96 -0
  95. package/src/tools/build-universal-bundles.ts +51 -3
  96. 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);
@@ -894,7 +915,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
894
915
  if (!check.id) continue;
895
916
  const subjectId = `${slug}/${check.id}`;
896
917
  const fieldOrBehavior = String(check.summary ?? check.id);
897
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
918
+ const gateClaimIdentityVersion = check._gate_claim_identity_version === 2 ? 2 : 1;
919
+ const gateClaimRecordedAt = gateClaimIdentityVersion === 2 && typeof check._gate_claim_recorded_at === "string" ? check._gate_claim_recorded_at : null;
920
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", gateClaimRecordedAt ? `${fieldOrBehavior}::gate-visit::${gateClaimRecordedAt}` : fieldOrBehavior);
898
921
  const evId = `ev:${claimId}`;
899
922
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
900
923
 
@@ -945,6 +968,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
945
968
  const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
946
969
  ? { algorithm: "sha256", hex: check._output_sha256 }
947
970
  : null;
971
+ const observedCommandsMeta = Array.isArray(check._observed_commands)
972
+ ? check._observed_commands
973
+ .filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string")
974
+ .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 } : {}) }))
975
+ : null;
948
976
  // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
949
977
  // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
950
978
  // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
@@ -974,11 +1002,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
974
1002
  origin: "check",
975
1003
  check_kind: String(check.kind ?? "external"),
976
1004
  ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1005
+ ...(typeof check._producer === "string" ? { expected_producer: check._producer } : {}),
1006
+ ...(typeof check._recorded_by === "string" ? { recorded_by: check._recorded_by } : {}),
1007
+ ...(Array.isArray(check._producer_self_produced_trust_slices) ? { self_produced_trust_slices: check._producer_self_produced_trust_slices } : {}),
977
1008
  ...(waiver ? { waiver } : {}),
978
1009
  ...(promotionMeta ? { promotion: promotionMeta } : {}),
979
1010
  ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
980
1011
  ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
981
1012
  ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
1013
+ ...(observedCommandsMeta && observedCommandsMeta.length > 0 ? { observed_commands: observedCommandsMeta } : {}),
982
1014
  };
983
1015
 
984
1016
  const claimEvents: AnyObj[] = [];
@@ -1041,7 +1073,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1041
1073
  // restored stamp) takes the currently-active step's id.
1042
1074
  const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
1043
1075
  const declaredMetadata: AnyObj = gateClaimExpectationId
1044
- ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1076
+ ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(gateClaimRecordedAt ? { recorded_at: gateClaimRecordedAt } : {}), ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1045
1077
  : claimMetadata;
1046
1078
  const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(declaredMetadata ? { metadata: declaredMetadata } : {}) };
1047
1079
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
@@ -1059,7 +1091,9 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1059
1091
  if (!criterion.id) continue;
1060
1092
  const subjectId = `${slug}/${criterion.id}`;
1061
1093
  const fieldOrBehavior = String(criterion.description ?? criterion.id);
1062
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
1094
+ const criterionIdentityVersion = criterion.identity_version === 2 ? 2 : 1;
1095
+ const criterionVerifiedAt = criterionIdentityVersion === 2 && typeof criterion.verified_at === "string" ? criterion.verified_at : null;
1096
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", criterionVerifiedAt ? `${fieldOrBehavior}::verified::${criterionVerifiedAt}` : fieldOrBehavior);
1063
1097
  const legacyClaimType = "workflow.acceptance.criterion";
1064
1098
  const policy = ensurePolicy(legacyClaimType, "high", []);
1065
1099
  const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
@@ -1075,12 +1109,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1075
1109
  if (declared) {
1076
1110
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
1077
1111
  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 } : {}) } };
1112
+ 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 : [], ...(criterionIdentityVersion === 2 ? { identity_version: 2 } : {}), ...(criterionVerifiedAt ? { verified_at: criterionVerifiedAt } : {}) }, ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
1079
1113
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
1080
1114
  claims.push({ ...declaredClaimObj, status: declaredStatus });
1081
1115
  } else {
1082
1116
  // 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" } };
1117
+ 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
1118
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1085
1119
  claims.push({ ...claimObj, status: derivedStatus });
1086
1120
  }
@@ -1098,11 +1132,26 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1098
1132
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
1099
1133
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
1100
1134
  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 } : {}) };
1135
+ const critiqueIdentityVersion = c.identity_version === 2 ? 2 : 1;
1136
+ const critMeta: AnyObj = {
1137
+ origin: "critique",
1138
+ reviewer: critiqueReviewer,
1139
+ reviewed_at: critiqueReviewedAt,
1140
+ ...(critiqueIdentityVersion === 2 ? { identity_version: 2 } : {}),
1141
+ findings: Array.isArray(c.findings) ? c.findings : [],
1142
+ lanes: Array.isArray(c.lanes) ? c.lanes : [],
1143
+ review_target: c.review_target && typeof c.review_target === "object" ? c.review_target : { artifacts: [] },
1144
+ // Keep the legacy field for consumers that still render a flat artifact list.
1145
+ artifact_refs: Array.isArray(c.artifact_refs) ? c.artifact_refs : [],
1146
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
1147
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
1148
+ };
1102
1149
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
1103
1150
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
1104
1151
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
1105
- const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
1152
+ const claimIdSalt = critiqueIdentityVersion === 2
1153
+ ? `${fieldOrBehavior}::reviewed::${critiqueReviewedAt}${supersededBy ? `::superseded::${supersededBy}` : ""}`
1154
+ : (supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior);
1106
1155
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
1107
1156
  const legacyClaimType = "workflow.critique.review";
1108
1157
  const policy = ensurePolicy(legacyClaimType, "medium", []);
@@ -1115,17 +1164,14 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1115
1164
  claimEvents.push(evt);
1116
1165
  }
1117
1166
 
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 };
1167
+ // Critique is intentionally report-only. It must never inherit a Flow gate expectation,
1168
+ // even while a run is positioned at a gate-bearing step.
1169
+ 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
1170
  if (supersededBy) {
1125
1171
  // History: status is "superseded" directly (no verification event); excluded from evaluation.
1126
1172
  claims.push({ ...claimObj, status: "superseded" });
1127
1173
  } 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>[] });
1174
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
1129
1175
  claims.push({ ...claimObj, status: derivedStatus });
1130
1176
  }
1131
1177
  }
@@ -1153,7 +1199,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
1153
1199
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
1154
1200
  * @param critiques Critique objects (same as buildTrustBundle)
1155
1201
  */
1156
- export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
1202
+ 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
1203
  try {
1158
1204
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
1159
1205
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -1173,14 +1219,20 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1173
1219
  const _flowAgentsDir = path.dirname(dir);
1174
1220
  const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
1175
1221
  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);
1222
+ if (exactFlowContext) {
1223
+ // The context was read from this session's persisted flow_run. Navigation pointers may
1224
+ // lag or point at another run and must not override an explicit session mutation.
1225
+ _scopedFlowAgentsDir = _flowAgentsDir;
1226
+ } else {
1227
+ try {
1228
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
1229
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
1230
+ if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
1231
+ _scopedFlowAgentsDir = _flowAgentsDir;
1232
+ }
1233
+ } catch { /* current.json absent or unreadable — no scoping */ }
1234
+ }
1235
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey, exactFlowContext);
1184
1236
  if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
1185
1237
  const result = await validateTrustBundle(bundle);
1186
1238
  if (result.available && !result.valid) {
@@ -1188,7 +1240,6 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
1188
1240
  return { written: false, errors: result.errors };
1189
1241
  }
1190
1242
  writeJson(path.join(dir, "trust.bundle"), bundle);
1191
- await syncBuilderFlowSessionIfPresent(dir);
1192
1243
  return { written: true, errors: [] };
1193
1244
  } catch (err) {
1194
1245
  const message = err instanceof Error ? err.message : String(err);
@@ -1766,6 +1817,9 @@ function initSidecars(
1766
1817
  ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1767
1818
  ...(branch ? { branch } : {}),
1768
1819
  ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1820
+ ...(existingState.flow_run && typeof existingState.flow_run === "object" && !Array.isArray(existingState.flow_run)
1821
+ ? { flow_run: existingState.flow_run }
1822
+ : {}),
1769
1823
  artifact_paths: relArtifacts(dir),
1770
1824
  next_action: nextActionPayload,
1771
1825
  });
@@ -1819,7 +1873,7 @@ function enforceEnsureSessionOwnership(
1819
1873
  dir: string,
1820
1874
  resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1821
1875
  workItemRef?: string,
1822
- ): { assignmentFile: string; actorKey: string } | null {
1876
+ ): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null {
1823
1877
  if (p.flags.has("skip-ownership-guard")) {
1824
1878
  process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1825
1879
  return null;
@@ -1854,15 +1908,41 @@ function enforceEnsureSessionOwnership(
1854
1908
 
1855
1909
  type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
1856
1910
  let effective: EffectiveResult;
1911
+ let providerEvidenceBytes: Buffer | undefined;
1857
1912
 
1858
1913
  const effectiveStateJsonFlag = opt(p, "effective-state-json");
1859
1914
  if (effectiveStateJsonFlag) {
1860
- const parsed = loadJsonInputFile(effectiveStateJsonFlag) as AnyObj;
1915
+ providerEvidenceBytes = effectiveStateJsonFlag === "-"
1916
+ ? fs.readFileSync(0)
1917
+ : readRegularFileNoFollow(path.resolve(effectiveStateJsonFlag), "ensure-session --effective-state-json");
1918
+ const parsed = JSON.parse(providerEvidenceBytes.toString("utf8")) as AnyObj;
1861
1919
  const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
1920
+ const assignment = parsed && typeof parsed === "object" ? (parsed.assignment as AnyObj | undefined) : undefined;
1921
+ const record = assignment && typeof assignment.record === "object" && assignment.record !== null ? assignment.record as AnyObj : undefined;
1862
1922
  const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1863
1923
  if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1864
1924
  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
1925
  }
1926
+ if (workItemRef && (parsed.role !== "AssignmentStatus"
1927
+ || parsed.provider !== assignmentProviderKind
1928
+ || assignment?.provider !== assignmentProviderKind
1929
+ || assignment?.subject_id !== slug
1930
+ || record?.role !== "AssignmentClaimRecord"
1931
+ || record?.status !== "claimed"
1932
+ || record?.subject_id !== slug
1933
+ || record?.work_item_ref !== workItemRef
1934
+ || record?.actor_key !== resolution.branchActorKey
1935
+ || assignment?.assignee !== resolution.branchActorKey
1936
+ || !record.actor || typeof record.actor !== "object"
1937
+ || record.actor.runtime !== resolution.actorStruct.runtime
1938
+ || record.actor.session_id !== resolution.actorStruct.session_id
1939
+ || record.actor.host !== resolution.actorStruct.host
1940
+ || (record.actor.human ?? null) !== (resolution.actorStruct.human ?? null)
1941
+ || candidate.effective_state !== "held"
1942
+ || candidate.reason !== "self_is_holder"
1943
+ || candidate.holder?.actor !== resolution.branchActorKey)) {
1944
+ 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");
1945
+ }
1866
1946
  effective = candidate as EffectiveResult;
1867
1947
  } else if (assignmentProviderKind === "local-file") {
1868
1948
  // Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
@@ -1898,10 +1978,8 @@ function enforceEnsureSessionOwnership(
1898
1978
  return null;
1899
1979
  }
1900
1980
 
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;
1981
+ const selectedWorkEvidence = (): { assignmentFile: string; actorKey: string; providerEvidenceFile?: string; providerEvidenceBytes?: Buffer } | null => {
1982
+ if (!workItemRef) return null;
1905
1983
  const assignment = readLocalAssignmentStatus(root, slug);
1906
1984
  const record = assignment.record;
1907
1985
  if (!record
@@ -1914,6 +1992,8 @@ function enforceEnsureSessionOwnership(
1914
1992
  return {
1915
1993
  assignmentFile: assignmentFilePath(root, slug),
1916
1994
  actorKey: resolution.branchActorKey,
1995
+ ...(effectiveStateJsonFlag ? { providerEvidenceFile: path.resolve(effectiveStateJsonFlag) } : {}),
1996
+ ...(providerEvidenceBytes ? { providerEvidenceBytes } : {}),
1917
1997
  };
1918
1998
  };
1919
1999
 
@@ -1929,7 +2009,22 @@ function enforceEnsureSessionOwnership(
1929
2009
  // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1930
2010
  // effective_state computation instead of silently using a different identity.
1931
2011
  const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1932
- if (isSelf) return selectedWorkEvidence();
2012
+ if (isSelf) {
2013
+ if (assignmentProviderKind !== "local-file") {
2014
+ const local = readLocalAssignmentStatus(root, slug).record;
2015
+ if (!local || local.status !== "claimed") {
2016
+ performLocalClaim(root, slug, resolution.actorStruct, {
2017
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
2018
+ branch: resolveBranchForClaim(),
2019
+ artifactDir: path.relative(root, dir) || ".",
2020
+ reason: `provider-confirmed ${assignmentProviderKind} ownership mirror`,
2021
+ actorKey: resolution.branchActorKey,
2022
+ ...(workItemRef ? { workItemRef } : {}),
2023
+ });
2024
+ }
2025
+ }
2026
+ return selectedWorkEvidence();
2027
+ }
1933
2028
  const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1934
2029
  const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1935
2030
  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 +2075,9 @@ function enforceEnsureSessionOwnership(
1980
2075
  return selectedWorkEvidence();
1981
2076
  }
1982
2077
  case "free": {
2078
+ if (assignmentProviderKind !== "local-file") {
2079
+ die(`ensure-session refused: provider ${JSON.stringify(assignmentProviderKind)} has not confirmed this actor as the Work Item holder`);
2080
+ }
1983
2081
  performLocalClaim(root, slug, resolution.actorStruct, {
1984
2082
  ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1985
2083
  branch: resolveBranchForClaim(),
@@ -2032,11 +2130,11 @@ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string)
2032
2130
  return { flowId, stepId: explicitStep || firstStep, firstStep };
2033
2131
  }
2034
2132
 
2035
- function assertCanonicalBuilderArtifactRoot(root: string): void {
2133
+ function assertCanonicalBuilderArtifactRoot(root: string, flowId: "builder.build" | "builder.shape"): void {
2036
2134
  const kontouraiRoot = path.dirname(root);
2037
2135
  const projectRoot = path.dirname(kontouraiRoot);
2038
2136
  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");
2137
+ die(`ensure-session --flow-id ${flowId} requires --artifact-root <project>/.kontourai/flow-agents`);
2040
2138
  }
2041
2139
 
2042
2140
  const statIfPresent = (candidate: string): fs.Stats | null => {
@@ -2049,7 +2147,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2049
2147
  };
2050
2148
  const projectStat = statIfPresent(projectRoot);
2051
2149
  if (projectStat && (projectStat.isSymbolicLink() || !projectStat.isDirectory())) {
2052
- die(`ensure-session --flow-id builder.build requires a non-symlink project root: ${projectRoot}`);
2150
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink project root: ${projectRoot}`);
2053
2151
  }
2054
2152
  const realProjectRoot = projectStat ? fs.realpathSync(projectRoot) : projectRoot;
2055
2153
  for (const [candidate, expected, label] of [
@@ -2059,7 +2157,7 @@ function assertCanonicalBuilderArtifactRoot(root: string): void {
2059
2157
  const stat = statIfPresent(candidate);
2060
2158
  if (!stat) continue;
2061
2159
  if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(candidate) !== expected) {
2062
- die(`ensure-session --flow-id builder.build requires a non-symlink ${label}: ${candidate}`);
2160
+ die(`ensure-session --flow-id ${flowId} requires a non-symlink ${label}: ${candidate}`);
2063
2161
  }
2064
2162
  }
2065
2163
  }
@@ -2070,17 +2168,17 @@ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
2070
2168
  const dir = sessionDirFor(root, slug);
2071
2169
  assertSafeSessionDirectory(root, dir);
2072
2170
  const entry = resolveEnsureSessionEntry(p, dir);
2073
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2171
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2074
2172
  sessionWorkItem(p, slug, dir);
2075
2173
  }
2076
2174
 
2077
- async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2175
+ async function ensureSession(p: ReturnType<typeof parseArgs>, allowCanonicalFlowMutation: boolean): Promise<number> {
2078
2176
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2079
2177
  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
2178
  const dir = sessionDirFor(root, slug);
2081
2179
  assertSafeSessionDirectory(root, dir);
2082
2180
  const entry = resolveEnsureSessionEntry(p, dir);
2083
- if (entry.flowId === "builder.build") assertCanonicalBuilderArtifactRoot(root);
2181
+ if (entry.flowId === "builder.build" || entry.flowId === "builder.shape") assertCanonicalBuilderArtifactRoot(root, entry.flowId);
2084
2182
  const workItem = sessionWorkItem(p, slug, dir);
2085
2183
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
2086
2184
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
@@ -2090,8 +2188,18 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2090
2188
  const selectionWorkItemRef = entry.flowId === "builder.build" && assignmentSubjectMatchesWorkItem(slug, workItem.ref)
2091
2189
  ? workItem.ref
2092
2190
  : undefined;
2093
- const selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2191
+ let selectedWorkEvidence = enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution, selectionWorkItemRef);
2094
2192
  ensureSafeDirectory(root, dir);
2193
+ if (selectedWorkEvidence?.providerEvidenceBytes) {
2194
+ const staged = path.join(dir, "assignment-provider-state.json");
2195
+ if (fs.existsSync(staged)) {
2196
+ if (!readRegularFileNoFollow(staged, "ensure-session provider state evidence destination").equals(selectedWorkEvidence.providerEvidenceBytes)) die("ensure-session provider state evidence conflicts with the existing immutable snapshot");
2197
+ } else {
2198
+ fs.writeFileSync(staged, selectedWorkEvidence.providerEvidenceBytes, { flag: "wx", mode: 0o600 });
2199
+ }
2200
+ fs.chmodSync(staged, 0o400);
2201
+ selectedWorkEvidence = { ...selectedWorkEvidence, providerEvidenceFile: staged };
2202
+ }
2095
2203
  const timestamp = opt(p, "timestamp", now());
2096
2204
  if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
2097
2205
  writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
@@ -2114,9 +2222,11 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2114
2222
  const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
2115
2223
  const startCommand = pinnedFlowAgentsCommand(flowAgentsPackageVersion(), [
2116
2224
  "workflow", "start",
2117
- "--flow", "builder.build",
2118
- "--work-item", workItem.ref,
2225
+ "--flow", entry.flowId,
2226
+ ...(entry.flowId === "builder.shape" ? [] : ["--work-item", workItem.ref]),
2119
2227
  ...(workItem.ref === `local:${slug}` ? ["--task-slug", slug] : []),
2228
+ "--assignment-provider", opt(p, "assignment-provider", "local-file"),
2229
+ ...(selectedWorkEvidence?.providerEvidenceFile ? ["--effective-state-json", selectedWorkEvidence.providerEvidenceFile] : []),
2120
2230
  "--artifact-root", root,
2121
2231
  "--title", opt(p, "title", slug),
2122
2232
  "--summary", opt(p, "summary", "Workflow session is durable."),
@@ -2159,9 +2269,9 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2159
2269
  ? persistedCurrent.active_step_id
2160
2270
  : entry.stepId;
2161
2271
  writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2162
- if (entry.flowId === "builder.build") {
2272
+ if (allowCanonicalFlowMutation && (entry.flowId === "builder.build" || entry.flowId === "builder.shape")) {
2163
2273
  try {
2164
- const started = await startBuilderFlowSession({ sessionDir: dir });
2274
+ const started = await startBuilderFlowSession({ sessionDir: dir, flowId: entry.flowId });
2165
2275
  if (started.run.state.current_step === "pull-work"
2166
2276
  && selectedWorkEvidence
2167
2277
  && assignmentSubjectMatchesWorkItem(slug, workItem.ref)) {
@@ -2176,8 +2286,24 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2176
2286
  }
2177
2287
  const assignmentContent = fs.readFileSync(selectedWorkEvidence.assignmentFile);
2178
2288
  const assignmentDigest = createHash("sha256").update(assignmentContent).digest("hex");
2289
+ const providerEvidenceBytes = selectedWorkEvidence.providerEvidenceFile
2290
+ ? readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence")
2291
+ : null;
2292
+ if (providerEvidenceBytes && selectedWorkEvidence.providerEvidenceBytes && !providerEvidenceBytes.equals(selectedWorkEvidence.providerEvidenceBytes)) {
2293
+ die("ensure-session refused to emit selected-work evidence: provider evidence changed after ownership validation");
2294
+ }
2295
+ const providerEvidenceDigest = providerEvidenceBytes ? createHash("sha256").update(providerEvidenceBytes).digest("hex") : null;
2179
2296
  const projectRoot = path.dirname(path.dirname(root));
2180
2297
  const evidenceFile = path.relative(projectRoot, selectedWorkEvidence.assignmentFile);
2298
+ const pullWorkReport = path.join(dir, `${slug}--pull-work.md`);
2299
+ let reportIsValid = false;
2300
+ try {
2301
+ const reportStat = fs.lstatSync(pullWorkReport);
2302
+ reportIsValid = !reportStat.isSymbolicLink() && reportStat.isFile() && fs.readFileSync(pullWorkReport, "utf8").includes(workItem.ref);
2303
+ } catch { /* handled by the stable contract error below */ }
2304
+ if (!reportIsValid) {
2305
+ die(`ensure-session refused to emit selected-work evidence: expected concrete pull-work selection report ${pullWorkReport} naming ${JSON.stringify(workItem.ref)}`);
2306
+ }
2181
2307
  await recordGateClaim(parseArgs([
2182
2308
  "record-gate-claim",
2183
2309
  dir,
@@ -2190,8 +2316,25 @@ async function ensureSession(p: ReturnType<typeof parseArgs>): Promise<number> {
2190
2316
  file: evidenceFile,
2191
2317
  summary: `Durable local assignment record for the selected Work Item and active actor; sha256:${assignmentDigest}.`,
2192
2318
  }),
2319
+ ...(selectedWorkEvidence.providerEvidenceFile ? [
2320
+ "--evidence-ref-json", JSON.stringify({
2321
+ kind: "artifact",
2322
+ file: path.relative(projectRoot, selectedWorkEvidence.providerEvidenceFile),
2323
+ summary: `Provider assignment state confirming ownership before the local runtime lease was mirrored; sha256:${providerEvidenceDigest}.`,
2324
+ }),
2325
+ ] : []),
2326
+ "--evidence-ref-json", JSON.stringify({
2327
+ kind: "artifact",
2328
+ file: path.relative(projectRoot, pullWorkReport),
2329
+ summary: `Concrete pull-work selection report for ${workItem.ref}.`,
2330
+ }),
2193
2331
  "--timestamp", timestamp,
2194
2332
  ]));
2333
+ if (selectedWorkEvidence.providerEvidenceFile && selectedWorkEvidence.providerEvidenceBytes
2334
+ && !readRegularFileNoFollow(selectedWorkEvidence.providerEvidenceFile, "selected-work provider evidence").equals(selectedWorkEvidence.providerEvidenceBytes)) {
2335
+ die("ensure-session refused to synchronize selected-work evidence: provider evidence changed during claim recording");
2336
+ }
2337
+ await syncBuilderFlowSession({ sessionDir: dir });
2195
2338
  });
2196
2339
  }
2197
2340
  } catch (error) {
@@ -2303,6 +2446,373 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
2303
2446
  return validateEvidenceRef({ ...ref as AnyObj }, label);
2304
2447
  });
2305
2448
  }
2449
+
2450
+ function canonicalProjectRootForSession(dir: string): string {
2451
+ const artifactRoot = path.dirname(dir);
2452
+ const kontouraiRoot = path.dirname(artifactRoot);
2453
+ if (path.basename(artifactRoot) !== "flow-agents" || path.basename(kontouraiRoot) !== ".kontourai") die("gate evidence requires a canonical .kontourai/flow-agents session");
2454
+ const projectRoot = path.dirname(kontouraiRoot);
2455
+ const stat = fs.lstatSync(projectRoot);
2456
+ if (stat.isSymbolicLink() || !stat.isDirectory()) die("gate evidence project root must be a non-symlink directory");
2457
+ return projectRoot;
2458
+ }
2459
+
2460
+ function pathIsWithinRoot(candidate: string, root: string): boolean {
2461
+ const relative = path.relative(root, candidate);
2462
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
2463
+ }
2464
+
2465
+ function validateLocalEvidenceFile(projectRoot: string, raw: string, label: string): string {
2466
+ const candidate = path.resolve(projectRoot, raw);
2467
+ if (!pathIsWithinRoot(candidate, projectRoot)) die(`${label} must remain inside the canonical project root`);
2468
+ let stat: fs.Stats;
2469
+ try { stat = fs.lstatSync(candidate); } catch { die(`${label} does not exist`); }
2470
+ if (stat.isSymbolicLink() || !stat.isFile()) die(`${label} must be a non-symlink regular file`);
2471
+ if (!pathIsWithinRoot(fs.realpathSync(candidate), fs.realpathSync(projectRoot))) die(`${label} escapes the canonical project root`);
2472
+ return path.relative(projectRoot, candidate);
2473
+ }
2474
+
2475
+ function globMatches(pattern: string, relative: string): boolean {
2476
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "::GLOBSTAR::").replaceAll("*", "[^/]*").replaceAll("::GLOBSTAR::", ".*");
2477
+ return new RegExp(`^${escaped}$`).test(relative);
2478
+ }
2479
+
2480
+ type GateProducer = { id: string; artifactPatterns: string[]; selfProducedTrustSlices: string[] };
2481
+
2482
+ function expectedGateProducer(flowId: string, stepId: string, expectationId: string): GateProducer {
2483
+ const manifest = loadJson(path.join(flowAgentsPackageRoot(), "kits", "builder", "kit.json"));
2484
+ const actions = Array.isArray(manifest.flow_step_actions) ? manifest.flow_step_actions as AnyObj[] : [];
2485
+ const action = actions.find((candidate) => candidate.flow_id === flowId && candidate.step_id === stepId);
2486
+ if (!action) die(`record-gate-claim cannot derive a producer for unknown Flow step ${flowId}/${stepId}`);
2487
+ const skills = Array.isArray(action.skills) ? action.skills.filter((value: unknown): value is string => typeof value === "string") : [];
2488
+ const roles = Array.isArray(manifest.skill_roles) ? manifest.skill_roles as AnyObj[] : [];
2489
+ const owners = roles.filter((role) => typeof role.skill_id === "string"
2490
+ && Array.isArray(role.step_ids) && role.step_ids.includes(stepId)
2491
+ && Array.isArray(role.expectation_ids) && role.expectation_ids.includes(expectationId)
2492
+ && skills.includes(role.skill_id.replace(/^builder\./, "")));
2493
+ if (owners.length === 0) {
2494
+ const operations = Array.isArray(action.operations) ? action.operations.filter((value: unknown): value is string => typeof value === "string") : [];
2495
+ const operationExpectations = Array.isArray(action.expectation_ids) ? action.expectation_ids : [];
2496
+ if (operations.length === 1 && operationExpectations.includes(expectationId)) {
2497
+ const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter((value: unknown): value is string => typeof value === "string") : [];
2498
+ return {
2499
+ id: `operation:${operations[0]}`,
2500
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2501
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2502
+ };
2503
+ }
2504
+ }
2505
+ if (owners.length !== 1) die(`record-gate-claim cannot derive exactly one producer for ${flowId}/${stepId}/${expectationId}`);
2506
+ const owner = owners[0]!;
2507
+ const artifacts = (Array.isArray(owner.artifacts) ? owner.artifacts : []).filter((value): value is string => typeof value === "string" && value !== "ephemeral decision record");
2508
+ return {
2509
+ id: owner.skill_id,
2510
+ artifactPatterns: artifacts.filter((value) => !value.includes("#")),
2511
+ selfProducedTrustSlices: artifacts.filter((value) => value.startsWith("trust.bundle#")).map((value) => value.slice("trust.bundle#".length)),
2512
+ };
2513
+ }
2514
+
2515
+ function validateReviewableGateEvidence(dir: string, slug: string, refs: AnyObj[], producer: GateProducer, label: string): void {
2516
+ if (refs.length === 0) die(`${label} requires at least one reviewable --evidence-ref-json`);
2517
+ const projectRoot = canonicalProjectRootForSession(dir);
2518
+ let declaredProducerArtifactFound = producer.artifactPatterns.length === 0;
2519
+ let localOrCommandEvidenceFound = false;
2520
+ for (const [index, ref] of refs.entries()) {
2521
+ if (ref.kind === "command" && hasNonEmptyString(ref.excerpt)) localOrCommandEvidenceFound = true;
2522
+ if (typeof ref.file !== "string") continue;
2523
+ const relative = validateLocalEvidenceFile(projectRoot, ref.file, `${label} evidence ref ${index}`);
2524
+ ref.file = relative;
2525
+ localOrCommandEvidenceFound = true;
2526
+ if (ref.kind === "artifact" && producer.artifactPatterns.length > 0) {
2527
+ const patterns = producer.artifactPatterns.map((pattern) => {
2528
+ const expanded = pattern.replaceAll("<slug>", slug);
2529
+ return expanded.startsWith(".kontourai/") ? expanded : `.kontourai/flow-agents/${slug}/${expanded}`;
2530
+ });
2531
+ if (patterns.some((pattern) => globMatches(pattern, relative))) declaredProducerArtifactFound = true;
2532
+ }
2533
+ }
2534
+ if (!declaredProducerArtifactFound) die(`${label} requires a declared durable artifact from producer ${producer.id}`);
2535
+ if (producer.selfProducedTrustSlices.length > 0 && !localOrCommandEvidenceFound) {
2536
+ 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`);
2537
+ }
2538
+ }
2539
+
2540
+ function commandFromEvidenceRef(ref: AnyObj): string {
2541
+ return typeof ref.excerpt === "string" ? ref.excerpt.trim() : (typeof ref.url === "string" ? ref.url.trim() : "");
2542
+ }
2543
+
2544
+ function hasTestIntent(name: string): boolean {
2545
+ return /(?:^|[-_.\/])(test|tests|check|checks|verify|verification|spec|specs|eval|evals)(?:$|[-_.\/])/i.test(name) || /(?:test|check|verify|spec|eval)/i.test(path.basename(name));
2546
+ }
2547
+
2548
+ function resolvesExplicitTestTarget(projectRoot: string, token: string): boolean {
2549
+ if (!hasTestIntent(token)) return false;
2550
+ try {
2551
+ if (/[*?\[]/.test(token)) return fs.globSync(token, { cwd: projectRoot }).length > 0;
2552
+ const target = path.resolve(projectRoot, token);
2553
+ return pathIsWithinRoot(target, projectRoot) && fs.lstatSync(target).isFile();
2554
+ } catch { return false; }
2555
+ }
2556
+
2557
+ /** Validate test-evidence command shape without executing it. */
2558
+ type TestExecutionProof = {
2559
+ kind: "local-process-exit";
2560
+ runner: string;
2561
+ static_test_units: number;
2562
+ };
2563
+
2564
+ function staticTestUnits(file: string, executable: string): number {
2565
+ try {
2566
+ const content = fs.readFileSync(file, "utf8").slice(0, 256 * 1024);
2567
+ const hashComments = !["cargo", "go"].includes(executable);
2568
+ const active = content.split("\n")
2569
+ .map((line) => hashComments ? line.replace(/\s+#.*$/, "") : line.replace(/\s+\/\/.*$/, ""))
2570
+ .filter((line) => hashComments ? !line.trimStart().startsWith("#") : !line.trimStart().startsWith("//"))
2571
+ .join("\n");
2572
+ if (["bash", "sh", "zsh"].includes(executable)
2573
+ && !/(?:^|\n)\s*set\s+(?:-[^\n\s]*e[^\n\s]*|-o\s+errexit)(?:\s|$)/.test(active)) return 0;
2574
+ 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) ?? [];
2575
+ return assertions.length;
2576
+ } catch {
2577
+ return 0;
2578
+ }
2579
+ }
2580
+
2581
+ /**
2582
+ * Produce evidence from the locally executed command and statically reviewable
2583
+ * test units. Runner stdout is deliberately excluded: any executable can print
2584
+ * a Vitest/Jest-looking success summary, but it cannot turn a non-test script
2585
+ * into a supported test workflow or supply this locally-created proof.
2586
+ */
2587
+ export function testExecutionProof(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): TestExecutionProof | null {
2588
+ const normalized = command.trim().replace(/\s+/g, " ");
2589
+ if (!normalized || /[`$()]/.test(normalized)) return null;
2590
+ if (/[;&|]/.test(normalized)) {
2591
+ if (!packageScriptBody || normalized.includes("||") || /[;|]/.test(normalized)) return null;
2592
+ const segments = normalized.split(/\s*&&\s*/).filter(Boolean);
2593
+ return segments.length > 1 ? segments.map((segment) => testExecutionProof(segment, projectRoot, new Set(seenScripts), false)).find(Boolean) ?? null : null;
2594
+ }
2595
+ if (/^(?:true|:|\/usr\/bin\/true)$/i.test(normalized)) return null;
2596
+ if (/^(?:echo|printf)(?:\s|$)/i.test(normalized)) return null;
2597
+ if (/(?:^|\s)(?:--version|-v|--help|-h)(?:\s|$)/.test(normalized)) return null;
2598
+ const tokens = normalized.split(" ").filter(Boolean);
2599
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0] ?? "")) tokens.shift();
2600
+ const executable = tokens[0] ?? "";
2601
+ const executableName = path.basename(executable);
2602
+ if (["npm", "pnpm", "yarn", "bun"].includes(executableName)) {
2603
+ // `bun test` is Bun's test runner; package scripts use `bun run <script>`.
2604
+ if (executableName === "bun" && tokens[1] === "test") {
2605
+ const target = tokens.slice(2).find((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token));
2606
+ const units = target ? staticTestUnits(path.resolve(projectRoot, target), "bun") : 0;
2607
+ return units > 0 ? { kind: "local-process-exit", runner: "bun test", static_test_units: units } : null;
2608
+ }
2609
+ const script = tokens[1] === "run" || tokens[1] === "run-script" ? tokens[2] : tokens[1];
2610
+ if (!script || !hasTestIntent(script) || seenScripts.has(script)) return null;
2611
+ try {
2612
+ const pkg = loadJson(path.join(projectRoot, "package.json"));
2613
+ const scriptCommand = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts[script] : undefined;
2614
+ if (typeof scriptCommand !== "string") return null;
2615
+ seenScripts.add(script);
2616
+ return testExecutionProof(scriptCommand, projectRoot, seenScripts, true);
2617
+ } catch { return null; }
2618
+ }
2619
+ if (["vitest", "jest", "mocha", "ava", "pytest", "rspec", "phpunit"].includes(executableName)
2620
+ && !tokens.some((token) => /pass.?with.?no.?tests/i.test(token))) {
2621
+ if (executable !== executableName) return null;
2622
+ const targets = tokens.slice(1)
2623
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2624
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2625
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2626
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2627
+ }
2628
+ if (executableName === "cargo" && tokens[1] === "test") {
2629
+ const files = [...fs.globSync("tests/**/*.rs", { cwd: projectRoot }), ...fs.globSync("src/**/*.rs", { cwd: projectRoot })];
2630
+ const units = [...new Set(files)].reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "cargo"), 0);
2631
+ return units > 0 ? { kind: "local-process-exit", runner: "cargo test", static_test_units: units } : null;
2632
+ }
2633
+ if (executableName === "go" && tokens[1] === "test") {
2634
+ const files = fs.globSync("**/*_test.go", { cwd: projectRoot, exclude: ["vendor/**", ".git/**"] });
2635
+ const units = files.reduce((total, file) => total + staticTestUnits(path.resolve(projectRoot, file), "go"), 0);
2636
+ return units > 0 ? { kind: "local-process-exit", runner: "go test", static_test_units: units } : null;
2637
+ }
2638
+ if (executableName === "npx" && tokens[1] && ["vitest", "jest", "mocha", "ava", "pytest"].includes(path.basename(tokens[1]))) {
2639
+ const runner = path.basename(tokens[1]);
2640
+ const targets = tokens.slice(2)
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), runner), 0);
2644
+ return units > 0 ? { kind: "local-process-exit", runner: `npx ${runner}`, static_test_units: units } : null;
2645
+ }
2646
+ if (executableName === "node" && tokens.includes("--test")) {
2647
+ const testFlag = tokens.indexOf("--test");
2648
+ const targets = tokens.slice(testFlag + 1)
2649
+ .filter((token) => !token.startsWith("-") && resolvesExplicitTestTarget(projectRoot, token))
2650
+ .flatMap((token) => /[*?\[]/.test(token) ? fs.globSync(token, { cwd: projectRoot }) : [token]);
2651
+ const units = targets.reduce((total, target) => total + staticTestUnits(path.resolve(projectRoot, target), executableName), 0);
2652
+ return units > 0 ? { kind: "local-process-exit", runner: "node --test", static_test_units: units } : null;
2653
+ }
2654
+ const scriptPath = ["bash", "sh", "zsh", "tsx", "ts-node"].includes(executableName) ? tokens[1] : executable;
2655
+ if (!scriptPath || ["-c", "-lc", "-e"].includes(scriptPath) || !hasTestIntent(scriptPath)) return null;
2656
+ try {
2657
+ const resolved = path.resolve(projectRoot, scriptPath);
2658
+ const stat = fs.lstatSync(resolved);
2659
+ if (stat.isSymbolicLink() || !stat.isFile() || !pathIsWithinRoot(fs.realpathSync(resolved), fs.realpathSync(projectRoot))) return null;
2660
+ const units = staticTestUnits(resolved, executableName);
2661
+ return units > 0 ? { kind: "local-process-exit", runner: executableName, static_test_units: units } : null;
2662
+ } catch { return null; }
2663
+ }
2664
+
2665
+ /** Validate test-evidence command shape without executing it. */
2666
+ export function isMeaningfulTestCommand(command: string, projectRoot: string, seenScripts = new Set<string>(), packageScriptBody = false): boolean {
2667
+ return testExecutionProof(command, projectRoot, seenScripts, packageScriptBody) !== null;
2668
+ }
2669
+
2670
+ export function observedExecutedTestCount(output: string): number {
2671
+ const counts: number[] = [];
2672
+ for (const pattern of [
2673
+ /^\s*(?:#|ℹ)\s*tests\s+(\d+)\s*$/gim,
2674
+ /^\s*1\.\.(\d+)(?:\s|$)/gim,
2675
+ /\b(\d+)\s+passed\b/gim,
2676
+ ]) {
2677
+ for (const match of output.matchAll(pattern)) counts.push(Number(match[1]));
2678
+ }
2679
+ const explicitPasses = output.match(/^\s*(?:---\s+PASS:|ok\s+\d+\s+-)/gm)?.length ?? 0;
2680
+ if (explicitPasses > 0) counts.push(explicitPasses);
2681
+ const goPackages = output.match(/^ok\s+\S+(?:\s|$)/gm)?.length ?? 0;
2682
+ if (goPackages > 0) counts.push(goPackages);
2683
+ return Math.max(0, ...counts.filter((count) => Number.isSafeInteger(count) && count > 0));
2684
+ }
2685
+
2686
+ export function inferExecutedTestCount(command: string, projectRoot: string, output: string, seenScripts = new Set<string>()): number {
2687
+ const proof = testExecutionProof(command, projectRoot, seenScripts);
2688
+ if (!proof) return 0;
2689
+ const observed = observedExecutedTestCount(output);
2690
+ return observed > 0 ? Math.min(proof.static_test_units, observed) : 0;
2691
+ }
2692
+
2693
+ type ObservedCommand = { command: string; exit_code: number; output_sha256: string; test_count?: number; execution_proof?: TestExecutionProof };
2694
+
2695
+ async function normalizeObservedCommands(commands: string[], projectRoot: string, requireTestIntent: boolean, expectedStatus: string): Promise<ObservedCommand[]> {
2696
+ if (commands.length === 0) die("record-gate-claim requires at least one --command for observed command evidence");
2697
+ if (new Set(commands).size !== commands.length) die("record-gate-claim --command values must be unique");
2698
+ for (const command of commands) {
2699
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2700
+ if (!isRunnableCommandText(command)) die(`record-gate-claim --command "${command}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2701
+ 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");
2702
+ }
2703
+ // Passing test evidence is always executed exactly once by this canonical
2704
+ // writer. Caller-supplied observations remain available for non-test
2705
+ // attestations but can never stand in for locally observed test execution.
2706
+ const observed = await Promise.all(commands.map(async (command) => {
2707
+ const result = await runObservedCommand(command, projectRoot);
2708
+ const proof = requireTestIntent ? testExecutionProof(command, projectRoot) : null;
2709
+ return { command, exit_code: result.exit_code, output_sha256: result.output_sha256, ...(proof ? { test_count: inferExecutedTestCount(command, projectRoot, result.output), execution_proof: proof } : {}) };
2710
+ }));
2711
+ if (observed.length !== commands.length) die("record-gate-claim requires exactly one --observed-command-json for every --command");
2712
+ const byCommand = new Map<string, ObservedCommand>();
2713
+ for (const entry of observed) {
2714
+ 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");
2715
+ if (!commands.includes(entry.command)) die("--observed-command-json command must exactly match one supplied --command");
2716
+ if (expectedStatus === "pass" && entry.exit_code !== 0) die(`record-gate-claim passing evidence command failed (exit ${entry.exit_code}): ${entry.command}`);
2717
+ if (expectedStatus === "fail" && entry.exit_code === 0) die(`record-gate-claim failing evidence command unexpectedly passed: ${entry.command}`);
2718
+ 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}`);
2719
+ if (byCommand.has(entry.command)) die("--observed-command-json command values must be unique");
2720
+ byCommand.set(entry.command, entry as ObservedCommand);
2721
+ }
2722
+ if (commands.some((command) => !byCommand.has(command))) die("every --command requires a corresponding --observed-command-json");
2723
+ return commands.map((command) => byCommand.get(command)!);
2724
+ }
2725
+
2726
+ function requireObservedCommandRefs(refs: AnyObj[], observedCommands: ReadonlySet<string>, label: string, requireAll = false): void {
2727
+ const commandRefs = refs.filter((ref) => ref.kind === "command");
2728
+ if (commandRefs.length === 0) die(`${label} requires a command evidence ref matching a successful observed command`);
2729
+ for (const ref of commandRefs) {
2730
+ if (!observedCommands.has(commandFromEvidenceRef(ref))) die(`${label} command evidence ref must exactly match a successful --observed-command-json command`);
2731
+ }
2732
+ if (requireAll) {
2733
+ const referenced = new Set(commandRefs.map(commandFromEvidenceRef));
2734
+ if ([...observedCommands].some((command) => !referenced.has(command))) die(`${label} requires a top-level command evidence ref for every successful observed command`);
2735
+ }
2736
+ }
2737
+
2738
+ function completePassingCriteria(existing: AnyObj[], raw: string[], observedCommands: ReadonlySet<string>, verifiedAt: string): AnyObj[] {
2739
+ if (raw.length === 0) die("record-gate-claim requires --criterion-json for a passing tests-evidence claim");
2740
+ const incoming = raw.map((value) => parseJson(value, "--criterion-json"));
2741
+ const expectedById = new Map<string, AnyObj>();
2742
+ for (const criterion of existing) {
2743
+ const id = String(criterion.id ?? "");
2744
+ if (id.length > 0) expectedById.set(id, criterion);
2745
+ }
2746
+ const expectedIds = [...expectedById.keys()];
2747
+ const ids = incoming.map((criterion) => typeof criterion.id === "string" ? criterion.id : "");
2748
+ if (new Set(ids).size !== ids.length || ids.length !== expectedIds.length || ids.some((id) => !expectedIds.includes(id))) {
2749
+ die(`--criterion-json must cover every declared acceptance criterion exactly once (expected: ${expectedIds.join(", ") || "none"}; received: ${ids.join(", ") || "none"})`);
2750
+ }
2751
+ return incoming.map((criterion, index) => {
2752
+ if (Object.keys(criterion).some((key) => !["id", "status", "evidence_refs"].includes(key))) die(`criterion ${ids[index]} may update only id, status, and evidence_refs`);
2753
+ if (criterion.status !== "pass") die(`criterion ${ids[index]} must have status pass for a passing tests-evidence claim`);
2754
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `criterion ${ids[index]} evidence_refs`);
2755
+ if (refs.length === 0) die(`criterion ${ids[index]} requires reviewable evidence_refs`);
2756
+ requireObservedCommandRefs(refs, observedCommands, `criterion ${ids[index]}`);
2757
+ return { ...expectedById.get(ids[index])!, status: "pass", evidence_refs: refs, identity_version: 2, verified_at: verifiedAt };
2758
+ });
2759
+ }
2760
+
2761
+ const critiqueStatuses = new Set(["pass", "fail", "not_verified"]);
2762
+ const safeCritiqueId = /^[a-z][a-z0-9_-]*$/;
2763
+
2764
+ function normalizeCritiqueLanes(raw: string[]): AnyObj[] {
2765
+ if (raw.length === 0) die("record-critique requires at least one --lane-json");
2766
+ const lanes = raw.map((value, index) => {
2767
+ const lane = parseJson(value, "--lane-json");
2768
+ const keys = Object.keys(lane);
2769
+ if (keys.some((key) => !["id", "status", "summary", "evidence_refs"].includes(key))) die(`--lane-json ${index} contains unsupported fields`);
2770
+ if (!safeCritiqueId.test(String(lane.id ?? ""))) die(`--lane-json ${index} id must be a unique safe identifier`);
2771
+ if (!critiqueStatuses.has(String(lane.status ?? ""))) die(`--lane-json ${index} status must be one of: pass, fail, not_verified`);
2772
+ if (!hasNonEmptyString(lane.summary)) die(`--lane-json ${index} summary must be non-empty`);
2773
+ const evidenceRefs = normalizeEvidenceRefs(lane.evidence_refs, `--lane-json ${index} evidence_refs`);
2774
+ if (evidenceRefs.length === 0) die(`--lane-json ${index} requires structured reviewable evidence_refs`);
2775
+ return { id: lane.id, status: lane.status, summary: lane.summary, evidence_refs: evidenceRefs };
2776
+ });
2777
+ if (new Set(lanes.map((lane) => lane.id)).size !== lanes.length) die("--lane-json ids must be unique");
2778
+ return lanes;
2779
+ }
2780
+
2781
+ function reviewTargetArtifacts(dir: string, rawPaths: string[], label: string): AnyObj[] {
2782
+ const projectRoot = canonicalProjectRootForSession(dir);
2783
+ const fallback = path.join(dir, `${taskSlugFor(dir)}--deliver.md`);
2784
+ const paths = rawPaths.length > 0 ? rawPaths : (fs.existsSync(fallback) ? [fallback] : []);
2785
+ const files = paths.map((raw, index) => validateLocalEvidenceFile(projectRoot, raw, `${label} artifact ${index}`));
2786
+ if (new Set(files).size !== files.length) die(`${label} artifact refs must be unique`);
2787
+ return files.map((file) => ({ file, sha256: createHash("sha256").update(fs.readFileSync(path.join(projectRoot, file))).digest("hex") }));
2788
+ }
2789
+
2790
+ function reviewTargetArtifactsMatch(dir: string, reviewTarget: unknown): boolean {
2791
+ try {
2792
+ if (!reviewTarget || typeof reviewTarget !== "object" || Array.isArray(reviewTarget)) return false;
2793
+ const artifacts = (reviewTarget as AnyObj).artifacts;
2794
+ if (!Array.isArray(artifacts) || artifacts.length === 0) return false;
2795
+ const projectRoot = canonicalProjectRootForSession(dir);
2796
+ const files = new Set<string>();
2797
+ return artifacts.every((artifact) => {
2798
+ if (!artifact || typeof artifact !== "object") return false;
2799
+ const file = (artifact as AnyObj).file;
2800
+ const sha256 = (artifact as AnyObj).sha256;
2801
+ if (!hasNonEmptyString(file) || !/^[a-f0-9]{64}$/i.test(String(sha256)) || files.has(file)) return false;
2802
+ files.add(file);
2803
+ const relative = validateLocalEvidenceFile(projectRoot, file, "critique review_target artifact");
2804
+ const digest = createHash("sha256").update(fs.readFileSync(path.join(projectRoot, relative))).digest("hex");
2805
+ return digest === sha256;
2806
+ });
2807
+ } catch { return false; }
2808
+ }
2809
+
2810
+ function critiqueIsCleanAndCurrent(dir: string, critique: AnyObj): boolean {
2811
+ if (critique.verdict !== "pass") return false;
2812
+ if (!Array.isArray(critique.lanes) || critique.lanes.length === 0 || critique.lanes.some((lane: AnyObj) => lane.status !== "pass")) return false;
2813
+ if (Array.isArray(critique.findings) && critique.findings.some((finding: AnyObj) => finding.status === "open")) return false;
2814
+ return reviewTargetArtifactsMatch(dir, critique.review_target);
2815
+ }
2306
2816
  // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2307
2817
  // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2308
2818
  // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
@@ -2604,6 +3114,17 @@ function checksFromBundle(dir: string): AnyObj[] {
2604
3114
  const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
2605
3115
  return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2606
3116
  };
3117
+ const observedCommandsOf = (claim: AnyObj): ObservedCommand[] | undefined => {
3118
+ const md = claim.metadata as AnyObj;
3119
+ const observed = md && typeof md === "object" ? md.observed_commands : undefined;
3120
+ return Array.isArray(observed) ? observed.filter((entry: AnyObj) => typeof entry?.command === "string" && typeof entry?.exit_code === "number" && typeof entry?.output_sha256 === "string") : undefined;
3121
+ };
3122
+ const applyProducerStamp = (check: AnyObj, claim: AnyObj): void => {
3123
+ const md = claim.metadata as AnyObj;
3124
+ if (md && typeof md.expected_producer === "string") check._producer = md.expected_producer;
3125
+ if (md && typeof md.recorded_by === "string") check._recorded_by = md.recorded_by;
3126
+ if (md && Array.isArray(md.self_produced_trust_slices)) check._producer_self_produced_trust_slices = md.self_produced_trust_slices;
3127
+ };
2607
3128
  // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2608
3129
  // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2609
3130
  // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
@@ -2620,6 +3141,8 @@ function checksFromBundle(dir: string): AnyObj[] {
2620
3141
  if (typeof gc.claim_type === "string") check._gate_claim_declared_type = gc.claim_type;
2621
3142
  if (typeof gc.subject_type === "string") check._gate_claim_declared_subject = gc.subject_type;
2622
3143
  if (typeof gc.step_id === "string") check._gate_claim_declared_step_id = gc.step_id;
3144
+ if (typeof gc.recorded_at === "string") check._gate_claim_recorded_at = gc.recorded_at;
3145
+ if (gc.identity_version === 2) check._gate_claim_identity_version = 2;
2623
3146
  if (typeof gc.route_reason === "string") check._gate_claim_route_reason = gc.route_reason;
2624
3147
  };
2625
3148
  // #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
@@ -2677,6 +3200,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2677
3200
  Object.assign(check, refsOf(claim));
2678
3201
  const outputSha256 = outputSha256Of(claim);
2679
3202
  if (outputSha256) check._output_sha256 = outputSha256;
3203
+ const observedCommands = observedCommandsOf(claim);
3204
+ if (observedCommands) check._observed_commands = observedCommands;
3205
+ applyProducerStamp(check, claim);
2680
3206
  applyGateClaimStamp(check, claim);
2681
3207
  applyGateClaimShapeUnstamped(check, claim);
2682
3208
  checks.push(check);
@@ -2694,6 +3220,9 @@ function checksFromBundle(dir: string): AnyObj[] {
2694
3220
  Object.assign(check, refsOf(claim));
2695
3221
  const outputSha256 = outputSha256Of(claim);
2696
3222
  if (outputSha256) check._output_sha256 = outputSha256;
3223
+ const observedCommands = observedCommandsOf(claim);
3224
+ if (observedCommands) check._observed_commands = observedCommands;
3225
+ applyProducerStamp(check, claim);
2697
3226
  applyGateClaimStamp(check, claim);
2698
3227
  applyGateClaimShapeUnstamped(check, claim);
2699
3228
  checks.push(check);
@@ -2728,9 +3257,18 @@ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
2728
3257
  */
2729
3258
  function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
2730
3259
  const acceptance = loadJson(path.join(dir, "acceptance.json"));
3260
+ const bundledCriteria = criteriaFromBundle(dir);
3261
+ const acceptedCriteria = Array.isArray(acceptance.criteria) ? acceptance.criteria as AnyObj[] : [];
3262
+ const contractSignature = (criteria: AnyObj[]): string => JSON.stringify(criteria.map((criterion) => ({
3263
+ id: criterion.id ?? null,
3264
+ description: criterion.description ?? null,
3265
+ })));
3266
+ const criteria = acceptedCriteria.length > 0 && contractSignature(acceptedCriteria) !== contractSignature(bundledCriteria)
3267
+ ? acceptedCriteria
3268
+ : (bundledCriteria.length > 0 ? bundledCriteria : acceptedCriteria);
2731
3269
  return {
2732
3270
  checks: checksFromBundle(dir),
2733
- criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
3271
+ criteria,
2734
3272
  critiques: critiquesFromBundle(dir),
2735
3273
  };
2736
3274
  }
@@ -2761,14 +3299,38 @@ function critiquesFromBundle(dir: string): AnyObj[] {
2761
3299
  id: String(c.subjectId || "").split("/").pop() || c.id,
2762
3300
  verdict: c.value ?? "not_verified",
2763
3301
  summary: c.fieldOrBehavior || "",
2764
- findings: [],
3302
+ findings: Array.isArray(md.findings) ? md.findings : [],
3303
+ lanes: Array.isArray(md.lanes) ? md.lanes : [],
3304
+ review_target: md.review_target && typeof md.review_target === "object" && !Array.isArray(md.review_target) ? md.review_target : { artifacts: [] },
2765
3305
  reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
2766
3306
  reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
2767
- artifact_refs: [],
3307
+ ...(md.identity_version === 2 ? { identity_version: 2 } : {}),
3308
+ artifact_refs: Array.isArray(md.artifact_refs) ? md.artifact_refs : [],
2768
3309
  ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
2769
3310
  };
2770
3311
  });
2771
3312
  }
3313
+
3314
+ function criteriaFromBundle(dir: string): AnyObj[] {
3315
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
3316
+ if (!Array.isArray(bundle.claims)) return [];
3317
+ for (const claim of bundle.claims) requireStampedClaim(claim, dir);
3318
+ return bundle.claims
3319
+ .filter((claim: AnyObj) => claim && claimOrigin(claim) === "acceptance")
3320
+ .map((claim: AnyObj) => {
3321
+ const md = claim.metadata && typeof claim.metadata === "object" && !Array.isArray(claim.metadata) ? claim.metadata as AnyObj : {};
3322
+ const saved = md.criterion && typeof md.criterion === "object" && !Array.isArray(md.criterion) ? md.criterion as AnyObj : {};
3323
+ return {
3324
+ id: typeof saved.id === "string" ? saved.id : String(claim.subjectId || "").split("/").pop(),
3325
+ description: typeof saved.description === "string" ? saved.description : (claim.fieldOrBehavior || ""),
3326
+ status: typeof saved.status === "string" ? saved.status : (claim.value ?? "not_verified"),
3327
+ evidence_refs: Array.isArray(saved.evidence_refs) ? saved.evidence_refs : [],
3328
+ ...(typeof saved.verified_at === "string" ? { verified_at: saved.verified_at } : {}),
3329
+ ...(saved.identity_version === 2 ? { identity_version: 2 } : {}),
3330
+ };
3331
+ })
3332
+ .filter((criterion: AnyObj) => typeof criterion.id === "string" && criterion.id.length > 0);
3333
+ }
2772
3334
  // ─────────────────────────────────────────────────────────────────────────────
2773
3335
  /**
2774
3336
  * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
@@ -2820,20 +3382,18 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
2820
3382
  if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
2821
3383
  validateAcceptanceEvidenceRefs(dir, p);
2822
3384
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
2823
- const ts = opt(p, "timestamp", now());
3385
+ const ts = opt(p, "timestamp", new Date().toISOString());
2824
3386
  // #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
2825
3387
  // record-evidence previously never called checksFromBundle(dir), so it dropped every check
2826
3388
  // recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
2827
3389
  // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2828
3390
  // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2829
3391
  // 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
3392
  const _mergedChecks = mergeChecksById(_existingState.checks, checks);
2833
3393
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
2834
3394
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2835
3395
  // whenever it ran after a critique existed.
2836
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
3396
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2837
3397
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
2838
3398
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
2839
3399
  return 0;
@@ -3029,7 +3589,7 @@ function diagnostic(dir: string, code: string, summary: string): never {
3029
3589
  async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number> {
3030
3590
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3031
3591
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
3032
- const ts = opt(p, "timestamp", now());
3592
+ const ts = opt(p, "timestamp", new Date().toISOString());
3033
3593
  const statusVal = opt(p, "status");
3034
3594
  if (!["pass", "fail", "not_verified"].includes(statusVal)) die("--status must be one of: pass, fail, not_verified");
3035
3595
  const summary = opt(p, "summary") || die("--summary is required");
@@ -3038,14 +3598,22 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3038
3598
  if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
3039
3599
  if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
3040
3600
 
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.
3601
+ // Prefer the exact session's canonical Flow projection. Actor/global current pointers are
3602
+ // ambient navigation state and may legitimately point at another run or lag this run.
3603
+ // Legacy sessions without flow_run retain the per-actor current-pointer fallback.
3046
3604
  const flowAgentsDir = path.dirname(dir);
3047
3605
  const gateClaimActorKey = resolveReadActorKey(p);
3048
- const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3606
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3607
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3608
+ ? sidecarState.flow_run as AnyObj
3609
+ : null;
3610
+ const exactFlowId = projectedRun && typeof projectedRun.definition_id === "string" ? projectedRun.definition_id : null;
3611
+ const exactStepId = projectedRun && typeof projectedRun.current_step === "string" ? projectedRun.current_step : null;
3612
+ const exactFlowContext = exactFlowId && exactStepId ? { flowId: exactFlowId, stepId: exactStepId } : undefined;
3613
+ const activeStep = exactFlowContext
3614
+ ? resolveFlowStep(exactFlowContext.flowId, exactFlowContext.stepId, findRepoRootFromDir(path.dirname(flowAgentsDir)))
3615
+ : resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
3616
+ if (exactFlowContext && !activeStep) die(`record-gate-claim cannot resolve exact session Flow step ${exactFlowContext.flowId}/${exactFlowContext.stepId}`);
3049
3617
  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
3618
  if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
3051
3619
  die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
@@ -3066,30 +3634,54 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3066
3634
  }
3067
3635
 
3068
3636
  const { claimType, subjectType } = targetExpectation.bundle_claim;
3637
+ const gateCommands = opts(p, "command");
3638
+ const gateCommand = gateCommands[0] ?? "";
3639
+ const mustRunTests = targetExpectation.id === "tests-evidence" && statusVal === "pass";
3640
+ const observedCommandRaw = opts(p, "observed-command-json");
3641
+ if (mustRunTests && gateCommands.length === 0) die("record-gate-claim requires at least one --command for a passing tests-evidence claim");
3642
+ const projectRoot = gateCommands.length > 0 ? canonicalProjectRootForSession(dir) : null;
3643
+ const observedCommands = gateCommands.length > 0
3644
+ ? await normalizeObservedCommands(gateCommands, projectRoot!, mustRunTests, statusVal)
3645
+ : [];
3646
+ const observedCommandNames = new Set(observedCommands.map((entry) => entry.command));
3647
+ let outputSha256: string | null = null;
3648
+ if (!mustRunTests && gateCommands.length > 1) die("record-gate-claim accepts repeatable --command only for passing tests-evidence claims");
3649
+ if (gateCommands.length === 0 && observedCommandRaw.length > 0) die("--observed-command-json requires --command");
3650
+ if (!mustRunTests && gateCommand && observedCommandRaw.length === 0) {
3651
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
3652
+ if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
3653
+ }
3654
+ if (observedCommands.length > 0) outputSha256 = observedCommands[0]!.output_sha256;
3069
3655
 
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.
3656
+ // Build a gate-targeted check that matchExpectsEntry maps to the declared claim tuple.
3657
+ // Command-backed checks retain their executable evidence classification.
3074
3658
  const checkId = expectationId || targetExpectation.id;
3075
3659
  // Build a minimal "external" check. Include _gate_claim_expectation_id so that
3076
3660
  // matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
3077
3661
  // normalizeCheck preserves extra underscore-prefixed fields without stripping them.
3078
3662
  const check: AnyObj = {
3079
3663
  id: `gate-claim-${checkId}`,
3080
- kind: "external",
3664
+ kind: gateCommands.length > 0 ? "command" : "external",
3081
3665
  status: statusVal,
3082
3666
  summary,
3083
3667
  _gate_claim_expectation_id: targetExpectation.id,
3668
+ _gate_claim_identity_version: 2,
3669
+ _gate_claim_recorded_at: ts,
3084
3670
  ...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
3085
3671
  };
3086
3672
 
3087
3673
  // Include structured evidence refs if provided
3088
3674
  const evidenceRefs: AnyObj[] = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
3675
+ const producer = expectedGateProducer(exactFlowContext?.flowId ?? activeStep.flowId, activeStep.stepId, targetExpectation.id);
3676
+ if (statusVal === "pass") validateReviewableGateEvidence(dir, slug, evidenceRefs, producer, `gate claim ${targetExpectation.id}`);
3677
+ if (mustRunTests) requireObservedCommandRefs(evidenceRefs, observedCommandNames, "a passing tests-evidence claim", true);
3089
3678
 
3090
3679
  if (evidenceRefs.length > 0) {
3091
3680
  check.artifact_refs = evidenceRefs;
3092
3681
  }
3682
+ check._producer = producer.id;
3683
+ check._recorded_by = gateClaimActorKey;
3684
+ if (producer.selfProducedTrustSlices.length > 0) check._producer_self_produced_trust_slices = producer.selfProducedTrustSlices;
3093
3685
 
3094
3686
  // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
3095
3687
  // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
@@ -3097,14 +3689,11 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3097
3689
  // prose itself), which it would otherwise execute as a shell command under
3098
3690
  // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
3099
3691
  // 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
- }
3692
+ if (gateCommand) check.command = gateCommand;
3693
+ if (observedCommands.length > 0) check._observed_commands = observedCommands;
3106
3694
 
3107
3695
  const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
3696
+ if (outputSha256) checkNormalized._output_sha256 = outputSha256;
3108
3697
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
3109
3698
  const gateWaiver = parseWaiver(p, ts);
3110
3699
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
@@ -3116,8 +3705,16 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
3116
3705
  // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
3117
3706
  // gate claim against a different expectation is additive.
3118
3707
  const _existingState = readBundleState(dir);
3708
+ const criteria = mustRunTests ? completePassingCriteria(_existingState.criteria, opts(p, "criterion-json"), observedCommandNames, ts) : _existingState.criteria;
3709
+ if (mustRunTests) {
3710
+ const liveCritiques = _existingState.critiques.filter((critique) => !critique.superseded_by);
3711
+ if (liveCritiques.length === 0 || liveCritiques.some((critique) => !critiqueIsCleanAndCurrent(dir, critique))) {
3712
+ die("a passing tests-evidence claim requires a current clean critique first");
3713
+ }
3714
+ for (const criterion of criteria) validateReviewableGateEvidence(dir, slug, criterion.evidence_refs, producer, `criterion ${criterion.id}`);
3715
+ }
3119
3716
  const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
3120
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
3717
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, criteria, _existingState.critiques, gateClaimActorKey, exactFlowContext));
3121
3718
  return 0;
3122
3719
  }
3123
3720
 
@@ -3299,12 +3896,47 @@ export function normalizeFinding(raw: AnyObj): AnyObj {
3299
3896
  async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number> {
3300
3897
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3301
3898
  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"))) };
3899
+ const verdict = opt(p, "verdict");
3900
+ if (!critiqueStatuses.has(verdict)) die("record-critique requires --verdict pass, fail, or not_verified");
3901
+ const summary = opt(p, "summary");
3902
+ if (!hasNonEmptyString(summary)) die("record-critique requires --summary");
3903
+ const lanes = normalizeCritiqueLanes(opts(p, "lane-json"));
3904
+ const reviewArtifacts = reviewTargetArtifacts(dir, opts(p, "artifact-ref"), "record-critique review_target");
3905
+ if (verdict === "pass" && (lanes.some((lane) => lane.status !== "pass") || reviewArtifacts.length === 0)) {
3906
+ die("a passing critique requires every lane to pass and at least one local reviewed --artifact-ref");
3907
+ }
3908
+ const sidecarState = loadJson(path.join(dir, "state.json"));
3909
+ const projectedRun = sidecarState.flow_run && typeof sidecarState.flow_run === "object" && !Array.isArray(sidecarState.flow_run)
3910
+ ? sidecarState.flow_run as AnyObj
3911
+ : null;
3912
+ const exactFlowContext = projectedRun
3913
+ && typeof projectedRun.definition_id === "string"
3914
+ && typeof projectedRun.current_step === "string"
3915
+ ? { flowId: projectedRun.definition_id, stepId: projectedRun.current_step }
3916
+ : undefined;
3917
+ // trust.bundle is the sole critique source. critique.json is unsupported historical residue.
3918
+ const _critiqueState = readBundleState(dir);
3919
+ const bundleCritiques = _critiqueState.critiques;
3920
+ const critiqueId = opt(p, "id", "review");
3921
+ if (!safeCritiqueId.test(critiqueId)) die("record-critique --id must be a safe identifier");
3922
+ const critique = {
3923
+ id: critiqueId,
3924
+ reviewer: opt(p, "reviewer", "tool-code-reviewer"),
3925
+ reviewed_at: opt(p, "timestamp", new Date().toISOString()),
3926
+ identity_version: 2,
3927
+ verdict,
3928
+ summary,
3929
+ lanes,
3930
+ review_target: {
3931
+ artifacts: reviewArtifacts,
3932
+ workspace_snapshot: captureReviewWorkspaceSnapshot(
3933
+ canonicalProjectRootForSession(dir),
3934
+ reviewArtifacts.map((artifact) => ({ file: String(artifact.file), sha256: String(artifact.sha256) })),
3935
+ ),
3936
+ },
3937
+ artifact_refs: reviewArtifacts.map((artifact) => artifact.file),
3938
+ findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))),
3939
+ };
3308
3940
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
3309
3941
  // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
3310
3942
  // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
@@ -3329,8 +3961,7 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3329
3961
  // checksFromBundle + acceptance.json read inline — this is the exact pattern readBundleState
3330
3962
  // already exists to share; recordLearning uses it directly (see below) and recordCritique
3331
3963
  // 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));
3964
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques, undefined, exactFlowContext));
3334
3965
  return 0;
3335
3966
  }
3336
3967
  function frontmatter(text: string, key: string): string {
@@ -3353,7 +3984,22 @@ async function importCritique(p: ReturnType<typeof parseArgs>): Promise<number>
3353
3984
  const title = m.groups?.title ?? "finding";
3354
3985
  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
3986
  }
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 };
3987
+ const importedArtifact = path.relative(canonicalProjectRootForSession(dir), review);
3988
+ const parsed = {
3989
+ ...p,
3990
+ positional: [dir],
3991
+ opts: {
3992
+ ...p.opts,
3993
+ id: [slugify(path.basename(review).replace(/\.md$/, ""), "review")],
3994
+ reviewer: ["tool-code-reviewer"],
3995
+ verdict: [verdict],
3996
+ summary: [`Imported critique from ${path.basename(review)}`],
3997
+ "artifact-ref": [importedArtifact],
3998
+ "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." }] })],
3999
+ "finding-json": findings.map((f) => JSON.stringify(f)),
4000
+ },
4001
+ flags: p.flags,
4002
+ };
3357
4003
  const result = await recordCritique(parsed);
3358
4004
  if (verdict !== "pass") die("required critique must pass");
3359
4005
  return result;
@@ -4774,10 +5420,7 @@ function evidenceClean(dir: string): boolean {
4774
5420
  });
4775
5421
  }
4776
5422
  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).
5423
+ // trust.bundle is the sole critique artifact. Legacy critique.json must not influence gates.
4781
5424
  const bundle = loadJson(path.join(dir, "trust.bundle"));
4782
5425
  if (Array.isArray(bundle.claims)) {
4783
5426
  for (const c of bundle.claims) requireStampedClaim(c, dir);
@@ -4788,14 +5431,11 @@ function critiqueClean(dir: string): boolean {
4788
5431
  return claimOrigin(c) === "critique";
4789
5432
  });
4790
5433
  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
- });
5434
+ return critiquesFromBundle(dir)
5435
+ .filter((critique) => !critique.superseded_by)
5436
+ .every((critique) => critiqueIsCleanAndCurrent(dir, critique));
4795
5437
  }
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)))));
5438
+ return false;
4799
5439
  }
4800
5440
  function assertExistingLearningValid(dir: string): void {
4801
5441
  const file = path.join(dir, "learning.json");
@@ -4840,8 +5480,7 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
4840
5480
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
4841
5481
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
4842
5482
  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");
5483
+ 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
5484
  }
4846
5485
  }
4847
5486
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
@@ -5161,7 +5800,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
5161
5800
  // Locate trust.bundle — required per SKILL.md contract
5162
5801
  const bundlePath = path.join(dir, "trust.bundle");
5163
5802
  if (!fs.existsSync(bundlePath)) {
5164
- process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
5803
+ process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Record the required trust bundle before running gate review.\n`);
5165
5804
  return 1;
5166
5805
  }
5167
5806
 
@@ -5908,7 +6547,11 @@ Available claim ids:
5908
6547
  // ─────────────────────────────────────────────────────────────────────────────
5909
6548
 
5910
6549
 
5911
- export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
6550
+ export function mainFromPublicWorkflow(argv: string[]): Promise<number> {
6551
+ return main(argv, PUBLIC_WORKFLOW_AUTHORITY);
6552
+ }
6553
+
6554
+ export async function main(argv: string[] = process.argv.slice(2), authority?: symbol): Promise<number> {
5912
6555
  const _rawArgv = argv;
5913
6556
  // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5914
6557
  // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
@@ -5948,7 +6591,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
5948
6591
  : 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
6592
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
5950
6593
  switch (p.command) {
5951
- case "ensure-session": return ensureSession(p);
6594
+ case "ensure-session": return ensureSession(p, authority === PUBLIC_WORKFLOW_AUTHORITY);
5952
6595
  case "current": return current(p);
5953
6596
  case "record-agent-event": return recordAgentEvent(p);
5954
6597
  case "init-plan": return initPlan(p);