@kontourai/flow-agents 1.4.0 → 2.0.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 (180) hide show
  1. package/.github/CODEOWNERS +29 -0
  2. package/.github/actions/trust-verify/action.yml +145 -0
  3. package/.github/workflows/ci.yml +11 -4
  4. package/.github/workflows/kit-gates-demo.yml +2 -2
  5. package/.github/workflows/publish-npm.yml +10 -2
  6. package/.github/workflows/release-please.yml +1 -1
  7. package/.github/workflows/trust-reconcile.yml +113 -0
  8. package/AGENTS.md +13 -0
  9. package/CHANGELOG.md +95 -0
  10. package/CONTRIBUTING.md +4 -4
  11. package/README.md +1 -0
  12. package/agents/tool-planner.json +1 -1
  13. package/build/src/cli/init.js +242 -20
  14. package/build/src/cli/validate-workflow-artifacts.js +19 -2
  15. package/build/src/cli/verify.d.ts +1 -0
  16. package/build/src/cli/verify.js +90 -0
  17. package/build/src/cli/workflow-sidecar.d.ts +300 -8
  18. package/build/src/cli/workflow-sidecar.js +1934 -83
  19. package/build/src/cli.js +2 -3
  20. package/build/src/lib/flow-resolver.d.ts +82 -0
  21. package/build/src/lib/flow-resolver.js +237 -0
  22. package/build/src/tools/build-universal-bundles.js +34 -22
  23. package/build/src/tools/generate-context-map.js +3 -16
  24. package/build/src/tools/validate-source-tree.d.ts +1 -1
  25. package/build/src/tools/validate-source-tree.js +42 -162
  26. package/context/contracts/artifact-contract.md +10 -0
  27. package/context/contracts/delivery-contract.md +1 -0
  28. package/context/contracts/review-contract.md +1 -0
  29. package/context/contracts/verification-contract.md +2 -0
  30. package/context/gate-awareness.md +39 -0
  31. package/context/scripts/hooks/stop-goal-fit.js +632 -70
  32. package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
  33. package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
  34. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  35. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  36. package/docs/adr/0007-skill-audit.md +1 -1
  37. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
  38. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
  39. package/docs/adr/0011-mcp-posture.md +100 -0
  40. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
  41. package/docs/adr/0013-context-lifecycle.md +151 -0
  42. package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
  43. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
  44. package/docs/adr/0016-three-hard-boundary-model.md +71 -0
  45. package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
  46. package/docs/agent-system-guidebook.md +5 -12
  47. package/docs/context-map.md +4 -10
  48. package/docs/index.md +3 -2
  49. package/docs/integrations/framework-adapter.md +19 -6
  50. package/docs/integrations/index.md +2 -2
  51. package/docs/north-star.md +4 -4
  52. package/docs/operating-layers.md +3 -3
  53. package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
  54. package/docs/repository-structure.md +2 -2
  55. package/docs/skills-map.md +1 -0
  56. package/docs/spec/runtime-hook-surface.md +62 -9
  57. package/docs/standards-register.md +3 -3
  58. package/docs/survey-utterance-check.md +1 -1
  59. package/docs/trust-anchor-adoption.md +197 -0
  60. package/docs/verifiable-trust.md +95 -0
  61. package/docs/veritas-integration.md +2 -2
  62. package/docs/workflow-usage-guide.md +69 -0
  63. package/evals/acceptance/DEMO-false-completion.md +144 -0
  64. package/evals/acceptance/demo-cast.sh +92 -0
  65. package/evals/acceptance/demo-false-completion.sh +72 -0
  66. package/evals/acceptance/demo-real-evidence.sh +104 -0
  67. package/evals/acceptance/demo.tape +29 -0
  68. package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
  69. package/evals/acceptance/prove-capture-teeth.sh +114 -0
  70. package/evals/acceptance/prove-teeth.sh +105 -0
  71. package/evals/ci/antigaming-suite.sh +54 -0
  72. package/evals/ci/run-baseline.sh +2 -0
  73. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
  74. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
  75. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
  76. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
  77. package/evals/integration/test_builder_step_producers.sh +379 -0
  78. package/evals/integration/test_bundle_install.sh +35 -71
  79. package/evals/integration/test_bundle_lifecycle.sh +39 -2
  80. package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
  81. package/evals/integration/test_checkpoint_signing.sh +489 -0
  82. package/evals/integration/test_claim_lookup.sh +352 -0
  83. package/evals/integration/test_command_log_integrity.sh +275 -0
  84. package/evals/integration/test_context_map.sh +0 -2
  85. package/evals/integration/test_dual_emit_flow_step.sh +278 -0
  86. package/evals/integration/test_enforcer_expects_driven.sh +281 -0
  87. package/evals/integration/test_evidence_capture_hook.sh +185 -0
  88. package/evals/integration/test_flow_kit_repository.sh +2 -0
  89. package/evals/integration/test_flowdef_session_activation.sh +273 -0
  90. package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
  91. package/evals/integration/test_gate_bypass_chain.sh +448 -0
  92. package/evals/integration/test_gate_lockdown.sh +1137 -0
  93. package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
  94. package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
  95. package/evals/integration/test_goal_fit_hook.sh +69 -4
  96. package/evals/integration/test_goal_fit_rederive.sh +263 -0
  97. package/evals/integration/test_install_merge.sh +1176 -0
  98. package/evals/integration/test_mint_attestation.sh +373 -0
  99. package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
  100. package/evals/integration/test_publish_delivery.sh +269 -0
  101. package/evals/integration/test_reconcile_soundness.sh +528 -0
  102. package/evals/integration/test_resolvefirststep_security.sh +208 -0
  103. package/evals/integration/test_session_resume_roundtrip.sh +286 -0
  104. package/evals/integration/test_trust_checkpoint.sh +325 -0
  105. package/evals/integration/test_trust_reconcile.sh +293 -0
  106. package/evals/integration/test_verify_cli.sh +208 -0
  107. package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
  108. package/evals/lib/node.sh +0 -6
  109. package/evals/run.sh +45 -0
  110. package/evals/static/test_workflow_skills.sh +6 -13
  111. package/install.sh +0 -7
  112. package/integrations/strands-ts/README.md +25 -15
  113. package/integrations/veritas/flow-agents.adapter.json +1 -2
  114. package/kits/builder/flows/build.flow.json +59 -12
  115. package/kits/builder/kit.json +85 -15
  116. package/kits/builder/skills/continue-work/SKILL.md +116 -0
  117. package/kits/builder/skills/deliver/SKILL.md +36 -6
  118. package/kits/builder/skills/design-probe/SKILL.md +28 -0
  119. package/kits/builder/skills/execute-plan/SKILL.md +9 -1
  120. package/kits/builder/skills/gate-review/SKILL.md +234 -0
  121. package/kits/builder/skills/learning-review/SKILL.md +30 -0
  122. package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
  123. package/kits/builder/skills/plan-work/SKILL.md +13 -1
  124. package/kits/builder/skills/pull-work/SKILL.md +19 -0
  125. package/kits/knowledge/adapters/default-store/index.js +38 -0
  126. package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
  127. package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
  128. package/kits/knowledge/docs/store-contract.md +314 -0
  129. package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
  130. package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
  131. package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
  132. package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
  133. package/kits/knowledge/evals/entities/suite.test.js +40 -0
  134. package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
  135. package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
  136. package/kits/knowledge/evals/retirement/suite.test.js +145 -0
  137. package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
  138. package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
  139. package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
  140. package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
  141. package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
  142. package/kits/knowledge/kit.json +51 -1
  143. package/package.json +4 -4
  144. package/packaging/conformance/README.md +10 -2
  145. package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
  146. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
  147. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
  148. package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
  149. package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
  150. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
  151. package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
  152. package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
  153. package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
  154. package/packaging/conformance/run-conformance.js +1 -1
  155. package/scripts/README.md +2 -1
  156. package/scripts/build-universal-bundles.js +0 -1
  157. package/scripts/ci/mint-attestation.js +221 -0
  158. package/scripts/ci/trust-reconcile.js +545 -0
  159. package/scripts/hooks/config-protection.js +423 -1
  160. package/scripts/hooks/evidence-capture.js +348 -0
  161. package/scripts/hooks/lib/liveness-read.js +113 -0
  162. package/scripts/hooks/run-hook.js +6 -1
  163. package/scripts/hooks/stop-goal-fit.js +1471 -79
  164. package/scripts/hooks/workflow-steering.js +135 -5
  165. package/scripts/install-codex-home.sh +39 -0
  166. package/scripts/install-merge.js +330 -0
  167. package/src/cli/init.ts +218 -20
  168. package/src/cli/validate-workflow-artifacts.ts +18 -2
  169. package/src/cli/verify.ts +100 -0
  170. package/src/cli/workflow-sidecar.ts +2064 -77
  171. package/src/cli.ts +2 -3
  172. package/src/lib/flow-resolver.ts +284 -0
  173. package/src/tools/build-universal-bundles.ts +34 -21
  174. package/src/tools/generate-context-map.ts +3 -17
  175. package/src/tools/validate-source-tree.ts +44 -104
  176. package/build/src/tools/filter-installed-packs.d.ts +0 -2
  177. package/build/src/tools/filter-installed-packs.js +0 -135
  178. package/packaging/packs.json +0 -49
  179. package/scripts/filter-installed-packs.js +0 -2
  180. package/src/tools/filter-installed-packs.ts +0 -132
@@ -2,8 +2,11 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
5
6
  import { createRequire } from "node:module";
6
7
  import { fileURLToPath } from "node:url";
8
+ // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
9
+ import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap } from "../lib/flow-resolver.js";
7
10
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
8
11
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
9
12
  export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
@@ -21,9 +24,64 @@ export function appendJsonl(file, payload) {
21
24
  }
22
25
  function die(message) { throw new Error(message); }
23
26
  function slugify(value, fallback) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; }
24
- // Optional Hachure trust-bundle validation. No-ops gracefully when hachure is not installed.
25
- // Install hachure (^0.4.0) as an optional dependency to enable schema validation.
26
- function tryLoadHachureValidator() {
27
+ /** Derives a deterministic, filesystem-safe slug from a canonical work-item ref like `kontourai/flow-agents#161`.
28
+ * Format: `<owner>-<repo>-<id>` e.g. `kontourai-flow-agents-161`.
29
+ * Reuses slugify() for normalization. Validates that the id is a numeric GitHub issue number. */
30
+ function workItemSlug(ref) {
31
+ const hashIdx = ref.indexOf("#");
32
+ if (hashIdx < 0 || hashIdx === ref.length - 1)
33
+ die("--work-item must be in owner/repo#id format");
34
+ const repoPath = ref.slice(0, hashIdx);
35
+ const id = ref.slice(hashIdx + 1);
36
+ if (!/^\d+$/.test(id))
37
+ die("--work-item id must be a numeric issue number");
38
+ const parts = repoPath.split("/");
39
+ if (parts.length !== 2 || !parts[0] || !parts[1])
40
+ die("--work-item repo must be owner/repo format");
41
+ const [owner, repo] = parts;
42
+ return slugify(`${owner}-${repo}-${id}`, "work-item");
43
+ }
44
+ /**
45
+ * Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
46
+ * (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
47
+ * Returns `{ valid, errors, available }`. When @kontourai/surface is unavailable,
48
+ * `available` is false and `valid` is true (fail-open) so callers can choose to treat
49
+ * unvalidated bundles as acceptable or gate on `available`. Surface is REQUIRED for
50
+ * bundle writes per ADR 0010 Phase 4c — `assertBundleWritten` enforces this on the
51
+ * write path. Surface's validator is equivalent-or-stronger than the prior hachure
52
+ * JSON-Schema validator: it validates the same structural constraints plus cross-reference
53
+ * integrity (evidence/event → claim references) that the JSON schema did not enforce.
54
+ */
55
+ export async function validateTrustBundle(bundle) {
56
+ // Use the already-loaded surface module when available (zero-cost re-entry after first load).
57
+ // When called standalone (fresh process, surface not yet loaded), attempt a one-shot import.
58
+ let surfaceValidate;
59
+ if (_surfaceModule !== undefined) {
60
+ // Module has been attempted: use cached result (null = unavailable).
61
+ surfaceValidate = _surfaceModule?.validateTrustBundle ?? undefined;
62
+ }
63
+ else {
64
+ // Not yet attempted — load now for standalone callers (e.g. library consumers, tests).
65
+ const m = await tryLoadSurface();
66
+ surfaceValidate = m?.validateTrustBundle ?? undefined;
67
+ }
68
+ if (!surfaceValidate)
69
+ return { valid: true, errors: [], available: false };
70
+ try {
71
+ surfaceValidate(bundle);
72
+ return { valid: true, errors: [], available: true };
73
+ }
74
+ catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ return { valid: false, errors: [message], available: true };
77
+ }
78
+ }
79
+ // Validate a single InquiryRecord against the hachure inquiry-record.schema.json.
80
+ // Uses a separate AJV instance compiled against that schema (not the trust-bundle schema).
81
+ let _hachureInquiryRecordValidator;
82
+ function getHachureInquiryRecordValidator() {
83
+ if (_hachureInquiryRecordValidator !== undefined)
84
+ return _hachureInquiryRecordValidator;
27
85
  try {
28
86
  const _require = createRequire(import.meta.url);
29
87
  const hachureDir = path.dirname(_require.resolve("hachure"));
@@ -35,18 +93,20 @@ function tryLoadHachureValidator() {
35
93
  continue;
36
94
  schemas[file] = JSON.parse(fs.readFileSync(path.join(schemasDir, file), "utf8"));
37
95
  }
96
+ const inquiryRecordSchema = schemas["inquiry-record.schema.json"];
97
+ if (!inquiryRecordSchema) {
98
+ _hachureInquiryRecordValidator = null;
99
+ return null;
100
+ }
38
101
  const ajv = new Ajv({ strict: false, allErrors: true });
39
102
  for (const [filename, schema] of Object.entries(schemas)) {
40
- if (filename === "trust-bundle.schema.json")
103
+ if (filename === "inquiry-record.schema.json")
41
104
  continue;
42
105
  ajv.addSchema(schema, filename);
43
106
  }
44
- const trustBundleSchema = schemas["trust-bundle.schema.json"];
45
- if (!trustBundleSchema)
46
- return null;
47
- const validate = ajv.compile(trustBundleSchema);
48
- return (bundle) => {
49
- const valid = validate(bundle);
107
+ const validate = ajv.compile(inquiryRecordSchema);
108
+ _hachureInquiryRecordValidator = (record) => {
109
+ const valid = validate(record);
50
110
  if (valid)
51
111
  return { valid: true, errors: [] };
52
112
  const errors = (validate.errors ?? []).map((err) => {
@@ -55,31 +115,412 @@ function tryLoadHachureValidator() {
55
115
  });
56
116
  return { valid: false, errors };
57
117
  };
118
+ return _hachureInquiryRecordValidator;
58
119
  }
59
120
  catch {
121
+ _hachureInquiryRecordValidator = null;
60
122
  return null;
61
123
  }
62
124
  }
63
- let _hachureValidator;
64
- function getHachureValidator() {
65
- if (_hachureValidator === undefined)
66
- _hachureValidator = tryLoadHachureValidator();
67
- return _hachureValidator;
68
- }
69
125
  /**
70
- * Validate a Hachure trust.bundle against the canonical trust-bundle schema.
71
- * Returns `{ valid, errors, available }`. When the optional `hachure` dependency
72
- * is not installed, validation is unavailable and this returns
73
- * `{ valid: true, errors: [], available: false }` (fail-open) so callers can
74
- * choose to treat unvalidated bundles as acceptable or gate on `available`.
75
- * This is the same validator the sidecar writer uses for trust-backed evidence.
126
+ * Validate a record against the canonical hachure inquiry-record.schema.json
127
+ * (https://kontourai.io/schemas/surface/inquiry-record.schema.json).
128
+ * Returns `{ valid, errors, available }`. Fail-open when hachure is not installed.
76
129
  */
77
- export function validateTrustBundle(bundle) {
78
- const validate = getHachureValidator();
130
+ export function validateInquiryRecord(record) {
131
+ const validate = getHachureInquiryRecordValidator();
79
132
  if (!validate)
80
133
  return { valid: true, errors: [], available: false };
81
- return { ...validate(bundle), available: true };
134
+ return { ...validate(record), available: true };
135
+ }
136
+ let _surfaceModule; // undefined = not tried yet; null = unavailable
137
+ async function tryLoadSurface() {
138
+ // Test/diagnostic seam: simulate a degraded environment where Surface is unavailable,
139
+ // to exercise the fail-loud (no silent data loss) path without disturbing node_modules.
140
+ if (process.env.FLOW_AGENTS_SURFACE_UNAVAILABLE === "1")
141
+ return null;
142
+ if (_surfaceModule !== undefined)
143
+ return _surfaceModule;
144
+ try {
145
+ const m = await import("@kontourai/surface");
146
+ _surfaceModule = m;
147
+ return _surfaceModule;
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ process.stderr.write(`[trust-bundle] @kontourai/surface unavailable — bundle write skipped: ${message}\n`);
152
+ _surfaceModule = null;
153
+ return null;
154
+ }
155
+ }
156
+ /** Map a workflow check status to the Surface VerificationEvent status. */
157
+ function checkStatusToEventStatus(status) {
158
+ if (status === "pass")
159
+ return "verified";
160
+ if (status === "fail")
161
+ return "disputed";
162
+ if (status === "skip")
163
+ return "assumed";
164
+ return null; // not_verified / unknown → no event → Surface returns "unknown"
165
+ }
166
+ /** Map an acceptance criterion status to the Surface VerificationEvent status. */
167
+ function criterionStatusToEventStatus(status) {
168
+ if (status === "pass")
169
+ return "verified";
170
+ if (status === "fail")
171
+ return "disputed";
172
+ if (status === "accepted_gap")
173
+ return "assumed";
174
+ return null; // pending / not_verified → no event → Surface returns "unknown"
175
+ }
176
+ /** Map a critique verdict to the Surface VerificationEvent status. */
177
+ function critiqueToEventStatus(verdict, findings) {
178
+ if (verdict === "fail")
179
+ return "disputed";
180
+ const hasOpenFinding = Array.isArray(findings) && findings.some((f) => f.status === "open");
181
+ if (verdict === "pass" && hasOpenFinding)
182
+ return "disputed";
183
+ if (verdict === "pass")
184
+ return "verified";
185
+ if (verdict === "comment")
186
+ return "assumed";
187
+ return null; // not_verified or unknown → no event → Surface returns "unknown"
188
+ }
189
+ /**
190
+ * Build a Hachure trust.bundle from raw check/criterion/critique inputs.
191
+ * trust.bundle is the PRIMARY artifact (ADR 0010 Phase 4a producer inversion).
192
+ * Callers pass raw inputs directly — not bespoke-sidecar-shaped objects.
193
+ * Derives claim statuses using @kontourai/surface's canonical versioned function.
194
+ * Returns null when Surface is unavailable (caller skips the bundle write).
195
+ * @param slug Task slug (used as subjectId prefix)
196
+ * @param timestamp ISO-8601 timestamp for createdAt / updatedAt / observedAt
197
+ * @param checks Normalized check objects (from record-evidence --check-json / --surface-trust-json)
198
+ * @param criteria Acceptance criteria objects (from acceptance.json .criteria array)
199
+ * @param critiques Critique objects (from critique.json .critiques array)
200
+ * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
201
+ */
202
+ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir) {
203
+ const surface = await tryLoadSurface();
204
+ if (!surface)
205
+ return null;
206
+ const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
207
+ // ADR 0016 Abstraction A (P-b): resolve active flow step for dual-emit.
208
+ // When flowAgentsDir is provided AND current.json carries active_flow_id/active_step_id,
209
+ // each produced claim gets a DECLARED primary claim (kit-typed) plus a legacy shadow
210
+ // (workflow.* type, claimId suffix "-legacy") for backward compatibility. When null,
211
+ // only the existing workflow.* claims are produced (zero behavior change).
212
+ const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir) : null;
213
+ const claims = [];
214
+ const evidenceItems = [];
215
+ const events = [];
216
+ const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
217
+ // One VerificationPolicy per distinct claimType, so status is policy-governed
218
+ // (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
219
+ const policies = new Map();
220
+ const ensurePolicy = (claimType, impactLevel, requiredEvidence) => {
221
+ let p = policies.get(claimType);
222
+ if (!p) {
223
+ p = { id: `policy:${claimType}`, claimType, requiredEvidence, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
224
+ policies.set(claimType, p);
225
+ }
226
+ return p;
227
+ };
228
+ // Index the deterministic capture log by normalized command (a single FAIL wins),
229
+ // so a claimed-pass check whose command actually FAILED becomes authoritative here.
230
+ const captureByCommand = new Map();
231
+ for (const entry of Array.isArray(commandLog) ? commandLog : []) {
232
+ if (!entry || typeof entry.command !== "string")
233
+ continue;
234
+ const key = entry.command.replace(/\s+/g, " ").trim();
235
+ if (!key)
236
+ continue;
237
+ const failed = entry.observedResult === "fail" || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
238
+ const prev = captureByCommand.get(key);
239
+ captureByCommand.set(key, { observedResult: failed || (prev && prev.observedResult === "fail") ? "fail" : "pass", exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null) });
240
+ }
241
+ // ─── P-b dual-emit helper ──────────────────────────────────────────────────
242
+ // Semantic matching table (ADR 0016 Abstraction A P-b):
243
+ // check (non-policy kind) → expects[] entry where claimType does NOT contain
244
+ // "acceptance" AND subjectType is NOT "decision". Preference: subjectType=
245
+ // "flow-step". Fallback: first non-decision, non-acceptance entry.
246
+ // check (kind=policy) → expects[] entry whose claimType contains
247
+ // "compliance" or "policy". Fallback: same as non-policy.
248
+ // acceptance criterion → expects[] entry whose subjectType is "flow-step"
249
+ // OR claimType contains "tests" OR "compliance". Fallback: first entry.
250
+ // critique → expects[] entry whose claimType contains "policy"
251
+ // OR "compliance" AND subjectType is "artifact". Fallback: last entry.
252
+ //
253
+ // The DECLARED claim is primary (kit-typed claimType + subjectType).
254
+ // The legacy claim uses the existing workflow.* claimType (suffix "-legacy") as
255
+ // a backward-compat shadow. Both cite the same evidence. Status is derived by
256
+ // Surface from that evidence (never hand-set).
257
+ //
258
+ // Per-gate producibility (ADR 0016 P-d):
259
+ // (a) Already handled via subjectType=flow-step preference:
260
+ // builder.verify.tests (verify-gate, subjectType=flow-step)
261
+ // builder.verify.policy-compliance (verify-gate, kind=policy match)
262
+ // (b) Producible via fallback (non-decision, non-acceptance, first match):
263
+ // builder.plan.implementation (plan-gate, subjectType=artifact)
264
+ // builder.execute.scope (execute-gate, subjectType=change)
265
+ // builder.merge-ready.readiness (merge-ready-gate, subjectType=change)
266
+ // builder.merge-ready-ci.readiness (merge-ready-ci-gate, subjectType=pull-request)
267
+ // (c) No natural producer — required:false in build.flow.json (ADR 0016 P-d plan):
268
+ // builder.pull-work.selected (pull-work-gate, subjectType=work-item)
269
+ // builder.design-probe.pickup-readiness (design-probe-gate, subjectType=work-item)
270
+ // builder.design-probe.decisions (design-probe-gate, subjectType=decision)
271
+ // builder.pr-open.pull-request (pr-open-gate, subjectType=pull-request)
272
+ // builder.learn.decisions (learn-gate, subjectType=decision)
273
+ // builder.learn.evidence (learn-gate, subjectType=release)
274
+ // For category (c): record-gate-claim subcommand allows skills to target a specific
275
+ // expects[] entry by --expectation <id>, bypassing this semantic match entirely.
276
+ function matchExpectsEntry(kind, checkKindVal, expectationId) {
277
+ if (!activeStep || activeStep.gateExpects.length === 0)
278
+ return null;
279
+ const expects = activeStep.gateExpects;
280
+ if (kind === "check") {
281
+ // ADR 0016 P-d Increment 2: when an explicit expectation id is given (from record-gate-claim
282
+ // --expectation), bypass heuristics and do exact lookup. This ensures multi-expects[] gates
283
+ // (learn-gate: decision + release; design-probe-gate: work-item + decision) produce the
284
+ // correct declared claimType rather than the heuristic-selected one.
285
+ if (expectationId) {
286
+ const exact = expects.find((e) => e.id === expectationId);
287
+ if (exact)
288
+ return { claimType: exact.bundle_claim.claimType, subjectType: exact.bundle_claim.subjectType };
289
+ }
290
+ const isPolicy = checkKindVal === "policy";
291
+ if (isPolicy) {
292
+ const match = expects.find((e) => {
293
+ const ct = e.bundle_claim.claimType.toLowerCase();
294
+ return ct.includes("compliance") || ct.includes("policy");
295
+ });
296
+ if (match)
297
+ return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
298
+ }
299
+ // Non-policy: prefer flow-step subjectType, exclude decision/acceptance entries
300
+ const preferred = expects.find((e) => {
301
+ const ct = e.bundle_claim.claimType.toLowerCase();
302
+ return e.bundle_claim.subjectType !== "decision" && !ct.includes("acceptance") && e.bundle_claim.subjectType === "flow-step";
303
+ });
304
+ if (preferred)
305
+ return { claimType: preferred.bundle_claim.claimType, subjectType: preferred.bundle_claim.subjectType };
306
+ const fallback = expects.find((e) => {
307
+ const ct = e.bundle_claim.claimType.toLowerCase();
308
+ return e.bundle_claim.subjectType !== "decision" && !ct.includes("acceptance");
309
+ });
310
+ if (fallback)
311
+ return { claimType: fallback.bundle_claim.claimType, subjectType: fallback.bundle_claim.subjectType };
312
+ return null;
313
+ }
314
+ if (kind === "acceptance") {
315
+ const match = expects.find((e) => {
316
+ const ct = e.bundle_claim.claimType.toLowerCase();
317
+ return e.bundle_claim.subjectType === "flow-step" || ct.includes("tests") || ct.includes("compliance");
318
+ });
319
+ if (match)
320
+ return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
321
+ return { claimType: expects[0].bundle_claim.claimType, subjectType: expects[0].bundle_claim.subjectType };
322
+ }
323
+ if (kind === "critique") {
324
+ const match = expects.find((e) => {
325
+ const ct = e.bundle_claim.claimType.toLowerCase();
326
+ return e.bundle_claim.subjectType === "artifact" && (ct.includes("policy") || ct.includes("compliance"));
327
+ });
328
+ if (match)
329
+ return { claimType: match.bundle_claim.claimType, subjectType: match.bundle_claim.subjectType };
330
+ const last = expects[expects.length - 1];
331
+ return { claimType: last.bundle_claim.claimType, subjectType: last.bundle_claim.subjectType };
332
+ }
333
+ return null;
334
+ }
335
+ // ────────────────────────────────────────────────────────────────────────────
336
+ // Evidence checks → claims + evidence items + events. Capture is authoritative.
337
+ for (const check of Array.isArray(checks) ? checks : []) {
338
+ if (!check.id)
339
+ continue;
340
+ const subjectId = `${slug}/${check.id}`;
341
+ const fieldOrBehavior = String(check.summary ?? check.id);
342
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
343
+ const evId = `ev:${claimId}`;
344
+ const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
345
+ const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
346
+ const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
347
+ const captured = cmd ? captureByCommand.get(cmd) : undefined;
348
+ const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
349
+ const evStatus = checkStatusToEventStatus(effectiveStatus);
350
+ const claimEvents = [];
351
+ if (evStatus) {
352
+ const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
353
+ events.push(evt);
354
+ claimEvents.push(evt);
355
+ }
356
+ const evItem = { id: evId, claimId, evidenceType: "test_output", method: "validation", sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
357
+ if (captured) {
358
+ evItem.sourceRef = `${slug}/command-log.jsonl`;
359
+ evItem.collectedBy = "flow-agents/evidence-capture";
360
+ evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
361
+ }
362
+ evidenceItems.push(evItem);
363
+ // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
364
+ // When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
365
+ const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
366
+ if (declared) {
367
+ // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
368
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", ["test_output"]);
369
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
370
+ const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
371
+ claims.push({ ...declaredClaimObj, status: declaredStatus });
372
+ }
373
+ else {
374
+ // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
375
+ const claimObj = { id: claimId, subjectType: "workflow-check", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
376
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
377
+ claims.push({ ...claimObj, status: derivedStatus });
378
+ }
379
+ }
380
+ // Acceptance criteria → claims + events
381
+ for (const criterion of Array.isArray(criteria) ? criteria : []) {
382
+ if (!criterion.id)
383
+ continue;
384
+ const subjectId = `${slug}/${criterion.id}`;
385
+ const fieldOrBehavior = String(criterion.description ?? criterion.id);
386
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
387
+ const legacyClaimType = "workflow.acceptance.criterion";
388
+ const policy = ensurePolicy(legacyClaimType, "high", []);
389
+ const evStatus = criterionStatusToEventStatus(String(criterion.status ?? ""));
390
+ const claimEvents = [];
391
+ if (evStatus) {
392
+ const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
393
+ events.push(evt);
394
+ claimEvents.push(evt);
395
+ }
396
+ // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
397
+ const declared = matchExpectsEntry("acceptance");
398
+ if (declared) {
399
+ // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
400
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
401
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
402
+ const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
403
+ claims.push({ ...declaredClaimObj, status: declaredStatus });
404
+ }
405
+ else {
406
+ // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
407
+ const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
408
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
409
+ claims.push({ ...claimObj, status: derivedStatus });
410
+ }
411
+ }
412
+ // Critique entries → claims + events
413
+ for (const c of Array.isArray(critiques) ? critiques : []) {
414
+ if (!c.id)
415
+ continue;
416
+ const subjectId = `${slug}/${c.id}`;
417
+ const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
418
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
419
+ const legacyClaimType = "workflow.critique.review";
420
+ const policy = ensurePolicy(legacyClaimType, "medium", []);
421
+ const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
422
+ const claimEvents = [];
423
+ if (evStatus) {
424
+ const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
425
+ events.push(evt);
426
+ claimEvents.push(evt);
427
+ }
428
+ // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
429
+ const declared = matchExpectsEntry("critique");
430
+ if (declared) {
431
+ // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
432
+ const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
433
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
434
+ const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
435
+ claims.push({ ...declaredClaimObj, status: declaredStatus });
436
+ }
437
+ else {
438
+ // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
439
+ const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
440
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
441
+ claims.push({ ...claimObj, status: derivedStatus });
442
+ }
443
+ }
444
+ return {
445
+ schemaVersion: 3,
446
+ source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
447
+ claims,
448
+ evidence: evidenceItems,
449
+ policies: [...policies.values()],
450
+ events,
451
+ };
82
452
  }
453
+ /**
454
+ * Fail-open wrapper: builds (via Surface), validates, and writes a trust.bundle.
455
+ * Accepts raw check/criterion/critique inputs directly (ADR 0010 Phase 4a).
456
+ * trust.bundle is written as the PRIMARY artifact; bespoke sidecars are the
457
+ * caller's responsibility to emit as back-compat projections AFTER this call.
458
+ * ANY error is caught and logged to stderr — this function NEVER throws and
459
+ * NEVER affects the exit code of its caller.
460
+ * Returns { written: false } if Surface is unavailable (fail-open; does NOT
461
+ * fall back to hand-rolled status derivation).
462
+ * @param checks Normalized check objects (same as buildTrustBundle)
463
+ * @param criteria Acceptance criteria objects (same as buildTrustBundle)
464
+ * @param critiques Critique objects (same as buildTrustBundle)
465
+ */
466
+ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques) {
467
+ try {
468
+ // Fold the deterministic capture log (PostToolUse evidence-capture) into the
469
+ // bundle so capture is authoritative over claimed status. Best-effort read.
470
+ let commandLog = [];
471
+ try {
472
+ const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
473
+ commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try {
474
+ return JSON.parse(l);
475
+ }
476
+ catch {
477
+ return null;
478
+ } }).filter((x) => x !== null);
479
+ }
480
+ catch { /* no capture log — fine */ }
481
+ // ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
482
+ // points to this session (scoped active-flow guard). If current.json.artifact_dir
483
+ // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
484
+ const _flowAgentsDir = path.dirname(dir);
485
+ let _scopedFlowAgentsDir = undefined;
486
+ try {
487
+ const _currentRaw = JSON.parse(fs.readFileSync(path.join(_flowAgentsDir, "current.json"), "utf8"));
488
+ const _artDir = typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
489
+ if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
490
+ _scopedFlowAgentsDir = _flowAgentsDir;
491
+ }
492
+ }
493
+ catch { /* current.json absent or unreadable — no scoping */ }
494
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
495
+ if (!bundle)
496
+ return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
497
+ const result = await validateTrustBundle(bundle);
498
+ if (result.available && !result.valid) {
499
+ process.stderr.write(`[trust-bundle] schema validation failed: ${result.errors.join("; ")}\n`);
500
+ return { written: false, errors: result.errors };
501
+ }
502
+ writeJson(path.join(dir, "trust.bundle"), bundle);
503
+ return { written: true, errors: [] };
504
+ }
505
+ catch (err) {
506
+ const message = err instanceof Error ? err.message : String(err);
507
+ process.stderr.write(`[trust-bundle] write failed: ${message}\n`);
508
+ return { written: false, errors: [message] };
509
+ }
510
+ }
511
+ // Phase 4c safety: the trust.bundle is the ONLY store (bespoke sidecars retired), so a
512
+ // fail-open write = SILENT DATA LOSS. Data-persisting writers must fail loudly when the
513
+ // bundle was not written (Surface unavailable, validation, or I/O) instead of exiting 0
514
+ // and dropping the record. (Was masked as a "flaky" concurrent-critique test.)
515
+ function assertBundleWritten(result) {
516
+ if (result.written)
517
+ return;
518
+ const reason = result.errors.length
519
+ ? result.errors.join("; ")
520
+ : "@kontourai/surface is unavailable — it is REQUIRED to persist the trust.bundle (bundle-only workspace, ADR 0010 Phase 4c). Install it (>= 1.2) and retry.";
521
+ die(`trust.bundle was NOT written — the record was not persisted: ${reason}`);
522
+ }
523
+ // ─────────────────────────────────────────────────────────────────────────────
83
524
  function safeRepoIdentifier(value) {
84
525
  const trimmed = value.trim().replace(/\.git$/, "");
85
526
  if (!trimmed || trimmed.length > 120)
@@ -212,7 +653,7 @@ async function withLock(dir, create, command, body) {
212
653
  if (create)
213
654
  fs.mkdirSync(dir, { recursive: true });
214
655
  if (!fs.existsSync(dir))
215
- return body();
656
+ return await body();
216
657
  const lockDir = path.join(dir, ".workflow-sidecar.lockdir");
217
658
  const staleMs = Number(process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_STALE_LOCK_MS ?? 5 * 60 * 1000);
218
659
  const deadline = Date.now() + 30000;
@@ -247,7 +688,7 @@ async function withLock(dir, create, command, body) {
247
688
  const delay = process.env.FLOW_AGENTS_WORKFLOW_SIDECAR_LOCK_DELAY;
248
689
  if (delay)
249
690
  await new Promise((resolve) => setTimeout(resolve, Number(delay) * 1000));
250
- return body();
691
+ return await body();
251
692
  }
252
693
  finally {
253
694
  fs.rmSync(lockDir, { recursive: true, force: true });
@@ -320,7 +761,63 @@ function validateAgentId(agent) {
320
761
  die("--agent-id must be a conservative slug");
321
762
  return agent;
322
763
  }
323
- function writeCurrent(root, dir, timestamp, owner, source) {
764
+ /**
765
+ * Find the repository root by walking upward from a starting directory to locate
766
+ * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
767
+ * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
768
+ * internal helper.
769
+ *
770
+ * ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
771
+ * derive repoRoot for resolvePhaseMap calls.
772
+ */
773
+ function findRepoRootFromDir(startDir) {
774
+ let dir = startDir;
775
+ for (let i = 0; i < 16; i++) {
776
+ if (fs.existsSync(path.join(dir, "kits")))
777
+ return dir;
778
+ const parent = path.dirname(dir);
779
+ if (parent === dir)
780
+ break;
781
+ dir = parent;
782
+ }
783
+ return process.cwd();
784
+ }
785
+ /**
786
+ * Resolve the first step id from a FlowDefinition's steps[] list.
787
+ * Returns null when the flow cannot be loaded or has no steps.
788
+ * Used by ensure-session to default active_step_id when --flow-id is
789
+ * provided without --step-id (Q2 decision, P-d Increment 1).
790
+ */
791
+ function resolveFirstStep(flowId, repoRoot) {
792
+ if (!flowId)
793
+ return null;
794
+ const dotIdx = flowId.indexOf(".");
795
+ if (dotIdx < 1)
796
+ return null;
797
+ const kitId = flowId.slice(0, dotIdx);
798
+ const flowName = flowId.slice(dotIdx + 1);
799
+ if (!kitId || !flowName)
800
+ return null;
801
+ // Use resolveFlowFilePath for SLUG_RE validation + path-containment check — the same
802
+ // defense used by resolveFlowStep and resolvePhaseMap (single implementation, DRY).
803
+ // Returns null for any traversal attempt (e.g. flowName="../../secret") so the
804
+ // caller gets a clean null return matching the existing null-contract.
805
+ const flowFilePath = resolveFlowFilePath(kitId, flowName, flowId, repoRoot);
806
+ if (!flowFilePath)
807
+ return null;
808
+ try {
809
+ const raw = fs.readFileSync(flowFilePath, "utf8");
810
+ const flowDef = JSON.parse(raw);
811
+ if (!flowDef || !Array.isArray(flowDef.steps) || flowDef.steps.length === 0)
812
+ return null;
813
+ const first = flowDef.steps[0];
814
+ return (first && typeof first.id === "string" && first.id !== "done") ? first.id : null;
815
+ }
816
+ catch {
817
+ return null;
818
+ }
819
+ }
820
+ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
324
821
  writeJson(path.join(root, "current.json"), {
325
822
  schema_version: "1.0",
326
823
  active_slug: path.basename(dir),
@@ -329,6 +826,11 @@ function writeCurrent(root, dir, timestamp, owner, source) {
329
826
  owner,
330
827
  source,
331
828
  active_agents: [],
829
+ // ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
830
+ // and enforcer. Both fields are optional and backward-compatible — sessions without a
831
+ // FlowDefinition omit them and fall through to the workflow.* claim type path.
832
+ ...(flowId ? { active_flow_id: flowId } : {}),
833
+ ...(stepId ? { active_step_id: stepId } : {}),
332
834
  });
333
835
  }
334
836
  function loadCurrent(root) {
@@ -373,7 +875,7 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
373
875
  }
374
876
  function ensureSession(p) {
375
877
  const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
376
- const slug = opt(p, "task-slug") || die("--task-slug is required");
878
+ 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)"));
377
879
  const dir = sessionDirFor(root, slug);
378
880
  fs.mkdirSync(dir, { recursive: true });
379
881
  const timestamp = opt(p, "timestamp", now());
@@ -385,7 +887,22 @@ function ensureSession(p) {
385
887
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
386
888
  initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action", "Continue."), timestamp, md);
387
889
  }
388
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session");
890
+ // ADR 0016 Abstraction A (P-a): optional --flow-id / --step-id flags persist FlowDefinition
891
+ // routing keys into current.json for the producer (P-b) and enforcer (P-c) to consume.
892
+ // When absent, behavior is unchanged — the workflow.* claim type path is used as before.
893
+ // P-d Increment 1 (Q2 decision): when --flow-id is given without --step-id, default
894
+ // active_step_id to the FIRST step in the FlowDefinition's steps[] list. This ensures
895
+ // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
896
+ // before the first advance-state call.
897
+ const flowId = opt(p, "flow-id");
898
+ let stepId = opt(p, "step-id");
899
+ if (flowId && !stepId) {
900
+ const repoRoot = findRepoRootFromDir(dir);
901
+ const firstStep = resolveFirstStep(flowId, repoRoot);
902
+ if (firstStep)
903
+ stepId = firstStep;
904
+ }
905
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
389
906
  console.log(dir);
390
907
  return 0;
391
908
  }
@@ -421,6 +938,7 @@ function initPlan(p) {
421
938
  const dir = artifactDirFrom(artifact);
422
939
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
423
940
  initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action"), opt(p, "timestamp", now()), read(artifact));
941
+ livenessLifecycle(dir, slug, "claim", opt(p, "timestamp", now()));
424
942
  return 0;
425
943
  }
426
944
  function parseJson(value, label) {
@@ -505,7 +1023,10 @@ export function normalizeCheck(raw) {
505
1023
  function normalizeSurfaceRefs(refs) {
506
1024
  if (!Array.isArray(refs))
507
1025
  die("surface_trust_refs must be an array");
508
- const hachureValidate = getHachureValidator();
1026
+ // Use the cached @kontourai/surface module for advisory inline validation of referenced
1027
+ // trust.bundle files. Fail-open when surface is not yet loaded (surface loads on first
1028
+ // bundle write via tryLoadSurface; normalizeSurfaceRefs may run before that).
1029
+ const surfaceValidateFn = _surfaceModule?.validateTrustBundle ?? null;
509
1030
  return refs.map((ref) => {
510
1031
  const keys = JSON.stringify(ref).match(/"([^"]+)":/g) ?? [];
511
1032
  for (const key of keys.map((k) => k.slice(1, -2)))
@@ -515,19 +1036,21 @@ function normalizeSurfaceRefs(refs) {
515
1036
  // trust.bundle is the canonical Hachure-aligned artifact kind; TrustReport/Trust Snapshot are legacy aliases
516
1037
  if (!["trust.bundle", "TrustReport", "Trust Snapshot"].includes(out.artifact_kind))
517
1038
  die("artifact_kind must be one of: trust.bundle, TrustReport, Trust Snapshot");
518
- // When hachure is installed, validate the referenced trust artifact if it is a local file
519
- if (hachureValidate && out.artifact_ref && typeof out.artifact_ref === "string" && fs.existsSync(out.artifact_ref)) {
1039
+ // When surface is loaded, validate the referenced trust artifact if it is a local file.
1040
+ // Advisory: surface's throw-based validator wraps into a fail-loud error on schema failure.
1041
+ if (surfaceValidateFn && out.artifact_ref && typeof out.artifact_ref === "string" && fs.existsSync(out.artifact_ref)) {
520
1042
  try {
521
1043
  const bundle = JSON.parse(fs.readFileSync(out.artifact_ref, "utf8"));
522
- const result = hachureValidate(bundle);
523
- if (!result.valid) {
524
- const errorSummary = result.errors.slice(0, 3).join("; ");
525
- die(`trust.bundle artifact at ${out.artifact_ref} failed Hachure schema validation: ${errorSummary}`);
526
- }
1044
+ surfaceValidateFn(bundle);
527
1045
  }
528
1046
  catch (err) {
529
- if (err instanceof Error && err.message.includes("failed Hachure schema validation"))
530
- throw err;
1047
+ if (err instanceof Error) {
1048
+ // Re-throw schema validation failures (surface throws on invalid); swallow read/parse errors.
1049
+ const msg = err.message;
1050
+ const isSchemaError = !msg.startsWith("ENOENT") && !msg.startsWith("SyntaxError") && !msg.toLowerCase().startsWith("unexpected");
1051
+ if (isSchemaError)
1052
+ die(`trust.bundle artifact at ${out.artifact_ref} failed schema validation: ${msg}`);
1053
+ }
531
1054
  // File read or parse errors are not re-thrown: the artifact_ref validation path is advisory
532
1055
  }
533
1056
  }
@@ -570,17 +1093,6 @@ function surfaceCheckFromArtifact(file, index) {
570
1093
  }
571
1094
  return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
572
1095
  }
573
- function updateAcceptance(dir, verdict) {
574
- const file = path.join(dir, "acceptance.json");
575
- if (!fs.existsSync(file))
576
- return;
577
- const data = loadJson(file);
578
- const status = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
579
- if (Array.isArray(data.criteria))
580
- data.criteria = data.criteria.map((c) => ({ ...c, status }));
581
- data.goal_fit = { ...(data.goal_fit ?? {}), status, summary: verdict === "pass" ? "Evidence passed." : "Evidence requires follow-up." };
582
- writeJson(file, data);
583
- }
584
1096
  function validateAcceptanceEvidenceRefs(dir) {
585
1097
  const file = path.join(dir, "acceptance.json");
586
1098
  if (!fs.existsSync(file))
@@ -596,7 +1108,117 @@ function validateAcceptanceEvidenceRefs(dir) {
596
1108
  export function writeState(dir, slug, status, phase, timestamp, summary, next = "continue") {
597
1109
  writeJson(path.join(dir, "state.json"), { ...loadJson(path.join(dir, "state.json")), ...sidecarBase(slug), status, phase, updated_at: timestamp, artifact_paths: relArtifacts(dir), next_action: { status: next, summary } });
598
1110
  }
599
- function recordEvidence(p) {
1111
+ // ─── Phase 4c: bundle-only helpers ───────────────────────────────────────────
1112
+ // After 4c, evidence.json and critique.json are no longer written.
1113
+ // Extract checks and critiques from the existing trust.bundle for callers that
1114
+ // need to rebuild the bundle (e.g. record-critique, record-learning).
1115
+ // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1116
+ // claimTypes from the active flow step for the session at `dir`. When no active
1117
+ // flow is present (workflow.* sessions), returns an empty set so every existing
1118
+ // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1119
+ // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1120
+ // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1121
+ // to include declared claims alongside the legacy workflow.* ones.
1122
+ //
1123
+ // Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
1124
+ // session via artifact_dir. If current.json points to a different session than `dir`
1125
+ // (e.g. another session was the last to call advance-state --flow-definition), we
1126
+ // return an empty set so declared-type predicates are NOT applied to the wrong session.
1127
+ // This prevents a cross-session active_flow_id from broadening claim filters for
1128
+ // unrelated sessions (which would cause spurious evidence/critique check behavior).
1129
+ function declaredClaimTypesFor(dir) {
1130
+ const flowAgentsDir = path.dirname(dir);
1131
+ // Verify that current.json points to `dir` before reading active flow step.
1132
+ // If it points to a different session, return empty set (zero behavior change).
1133
+ const currentFile = path.join(flowAgentsDir, "current.json");
1134
+ try {
1135
+ const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
1136
+ const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1137
+ if (!artDir)
1138
+ return new Set();
1139
+ const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1140
+ if (path.resolve(dir) !== resolvedCurrent)
1141
+ return new Set();
1142
+ }
1143
+ catch {
1144
+ return new Set();
1145
+ }
1146
+ const activeStep = resolveActiveFlowStep(flowAgentsDir);
1147
+ if (!activeStep || activeStep.gateExpects.length === 0)
1148
+ return new Set();
1149
+ return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1150
+ }
1151
+ function checksFromBundle(dir, declaredClaimTypes = new Set()) {
1152
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
1153
+ if (!Array.isArray(bundle.evidence))
1154
+ return [];
1155
+ const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
1156
+ const claimById = new Map();
1157
+ for (const c of allClaims)
1158
+ if (c && c.id)
1159
+ claimById.set(c.id, c);
1160
+ const seen = new Set();
1161
+ const checks = [];
1162
+ for (const ev of bundle.evidence) {
1163
+ if (!ev || !ev.claimId)
1164
+ continue;
1165
+ const claim = claimById.get(ev.claimId);
1166
+ if (!claim)
1167
+ continue;
1168
+ const ct = String(claim.claimType || "");
1169
+ // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1170
+ if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1171
+ continue;
1172
+ if (seen.has(ev.claimId))
1173
+ continue;
1174
+ seen.add(ev.claimId);
1175
+ const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1176
+ const status = claim.value ?? "not_verified";
1177
+ const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1178
+ if (ev.execution && typeof ev.execution.label === "string")
1179
+ check.command = ev.execution.label;
1180
+ if (ev.evidenceType)
1181
+ check.evidenceType = ev.evidenceType;
1182
+ checks.push(check);
1183
+ }
1184
+ // Also include check claims that have no evidence item (surface_trust_refs style)
1185
+ for (const claim of allClaims) {
1186
+ if (!claim)
1187
+ continue;
1188
+ const ct = String(claim.claimType || "");
1189
+ // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1190
+ if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1191
+ continue;
1192
+ if (seen.has(claim.id))
1193
+ continue;
1194
+ seen.add(claim.id);
1195
+ const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1196
+ checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1197
+ }
1198
+ return checks;
1199
+ }
1200
+ function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
1201
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
1202
+ if (!Array.isArray(bundle.claims))
1203
+ return [];
1204
+ // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1205
+ // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1206
+ // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1207
+ // as critiques when declaredClaimTypes includes all gate expects[] types.
1208
+ const evidenceClaimIds = new Set(Array.isArray(bundle.evidence) ? bundle.evidence.map((e) => e?.claimId).filter((id) => typeof id === "string") : []);
1209
+ const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1210
+ return critiqueClaims.map((c) => ({
1211
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1212
+ verdict: c.value ?? "not_verified",
1213
+ summary: c.fieldOrBehavior || "",
1214
+ findings: [],
1215
+ reviewer: "tool-code-reviewer",
1216
+ reviewed_at: c.updatedAt || c.createdAt || now(),
1217
+ artifact_refs: [],
1218
+ }));
1219
+ }
1220
+ // ─────────────────────────────────────────────────────────────────────────────
1221
+ async function recordEvidence(p) {
600
1222
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
601
1223
  const verdict = opt(p, "verdict") || die("--verdict is required");
602
1224
  if (!verdicts.has(verdict))
@@ -606,11 +1228,15 @@ function recordEvidence(p) {
606
1228
  if (!checks.length && opts(p, "surface-trust-json").length === 0)
607
1229
  die("record-evidence requires at least one --check-json or --surface-trust-json");
608
1230
  validateAcceptanceEvidenceRefs(dir);
609
- const payload = { ...sidecarBase(slug), verdict, checks, not_verified_gaps: opts(p, "gap") };
610
- writeJson(path.join(dir, "evidence.json"), payload);
611
- updateAcceptance(dir, verdict);
1231
+ // Phase 4c: bundle is the sole verification artifact stop writing evidence.json and acceptance.json update.
1232
+ const ts = opt(p, "timestamp", now());
1233
+ const _existingAcceptance = loadJson(path.join(dir, "acceptance.json"));
1234
+ const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1235
+ const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1236
+ const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
1237
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
612
1238
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
613
- writeState(dir, slug, stateStatus, "verification", opt(p, "timestamp", now()), "Evidence recorded.");
1239
+ writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
614
1240
  return 0;
615
1241
  }
616
1242
  function diagnostic(dir, code, summary) {
@@ -618,7 +1244,90 @@ function diagnostic(dir, code, summary) {
618
1244
  appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
619
1245
  die(`${code}: ${summary}`);
620
1246
  }
621
- function advanceState(p) {
1247
+ /**
1248
+ * record-gate-claim — Generic gate-claim producer for skills (ADR 0016 P-d Increment 1).
1249
+ *
1250
+ * Allows a skill to record a claim that satisfies a SPECIFIC gate expectation at the
1251
+ * active step. The caller passes:
1252
+ * --status <pass|fail|not_verified> (required)
1253
+ * --summary <text> (required)
1254
+ * --expectation <id> (optional; auto-resolved when the gate has one entry)
1255
+ * --evidence-json <json> (optional; structured evidence refs)
1256
+ *
1257
+ * The producer emits a check of kind="external" targeting the gate expectation's declared
1258
+ * claimType + subjectType from the active step's expects[]. This populates the trust.bundle
1259
+ * with a correctly-typed claim derived by Surface, suitable for gate enforcement.
1260
+ *
1261
+ * When the gate has exactly ONE expects[] entry, --expectation is optional (auto-resolve).
1262
+ * When the gate has multiple entries, --expectation <id> is required.
1263
+ *
1264
+ * This is what Increment 2's 6 skills will call to satisfy the category (c) gates
1265
+ * (pull-work.selected, design-probe.*, pr-open.pull-request, learn.*) once producers are added.
1266
+ *
1267
+ * Error cases:
1268
+ * - No active flow/step in current.json → die with actionable message
1269
+ * - --expectation not found in expects[] → die
1270
+ * - Multiple expects[] entries and --expectation omitted → die
1271
+ * - Surface unavailable → assertBundleWritten fails loud (no silent data loss)
1272
+ */
1273
+ async function recordGateClaim(p) {
1274
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1275
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1276
+ const ts = opt(p, "timestamp", now());
1277
+ const statusVal = opt(p, "status");
1278
+ if (!["pass", "fail", "not_verified"].includes(statusVal))
1279
+ die("--status must be one of: pass, fail, not_verified");
1280
+ const summary = opt(p, "summary") || die("--summary is required");
1281
+ const expectationId = opt(p, "expectation");
1282
+ // Resolve the active flow step from current.json
1283
+ const flowAgentsDir = path.dirname(dir);
1284
+ const activeStep = resolveActiveFlowStep(flowAgentsDir);
1285
+ if (!activeStep)
1286
+ die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
1287
+ const expects = activeStep.gateExpects;
1288
+ if (expects.length === 0)
1289
+ die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
1290
+ // Resolve the target expects entry
1291
+ let targetExpectation;
1292
+ if (expectationId) {
1293
+ targetExpectation = expects.find((e) => e.id === expectationId);
1294
+ if (!targetExpectation)
1295
+ die(`record-gate-claim: --expectation "${expectationId}" not found in gate "${activeStep.gateId}" expects[]. Available: ${expects.map((e) => e.id).join(", ")}`);
1296
+ }
1297
+ else if (expects.length === 1) {
1298
+ targetExpectation = expects[0];
1299
+ }
1300
+ else {
1301
+ die(`record-gate-claim: gate "${activeStep.gateId}" has ${expects.length} expects[] entries; --expectation <id> is required. Available: ${expects.map((e) => e.id).join(", ")}`);
1302
+ }
1303
+ const { claimType, subjectType } = targetExpectation.bundle_claim;
1304
+ // Build a synthetic external check that will be matched by matchExpectsEntry to produce
1305
+ // a correctly-typed claim. We use kind="external" so it routes through the non-policy,
1306
+ // non-flow-step fallback path. The subjectType on the resulting claim comes from the
1307
+ // expects[] entry via matchExpectsEntry.
1308
+ const checkId = expectationId || targetExpectation.id;
1309
+ // Build a minimal "external" check. Include _gate_claim_expectation_id so that
1310
+ // matchExpectsEntry can do an exact lookup for multi-expects[] gates (ADR 0016 P-d Increment 2).
1311
+ // normalizeCheck preserves extra underscore-prefixed fields without stripping them.
1312
+ const check = {
1313
+ id: `gate-claim-${checkId}`,
1314
+ kind: "external",
1315
+ status: statusVal,
1316
+ summary,
1317
+ _gate_claim_expectation_id: targetExpectation.id,
1318
+ };
1319
+ // Include structured evidence refs if provided
1320
+ const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
1321
+ if (evidenceRefs.length > 0) {
1322
+ check.artifact_refs = evidenceRefs;
1323
+ }
1324
+ const checkNormalized = normalizeCheck(check);
1325
+ // Log the targeted gate expectation for transparency (goes to stderr only)
1326
+ process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
1327
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
1328
+ return 0;
1329
+ }
1330
+ async function advanceState(p) {
622
1331
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
623
1332
  const status = opt(p, "status");
624
1333
  const phase = opt(p, "phase");
@@ -650,6 +1359,26 @@ function advanceState(p) {
650
1359
  const timestamp = opt(p, "timestamp", now());
651
1360
  writeState(dir, slug, status, phase, timestamp, opt(p, "summary"));
652
1361
  writeJson(path.join(dir, "handoff.json"), { ...loadJson(path.join(dir, "handoff.json")), ...sidecarBase(slug), summary: opt(p, "summary"), current_state_ref: "state.json", next_steps: [opt(p, "next-action")].filter(Boolean), blockers: [], warnings: [] });
1362
+ // ADR 0016 Abstraction A (P-d, Increment 1): when --flow-definition is provided,
1363
+ // resolve the phase→step mapping from the FlowDefinition and write active_step_id
1364
+ // into current.json. This is the single setter — no skill needs to call ensure-session
1365
+ // --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
1366
+ if (flow) {
1367
+ const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
1368
+ const repoRoot = findRepoRootFromDir(dir);
1369
+ const phaseMap = resolvePhaseMap(flow, repoRoot);
1370
+ const stepId = phaseMap?.[phase] ?? undefined;
1371
+ if (stepId) {
1372
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId);
1373
+ }
1374
+ }
1375
+ livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
1376
+ // Trust checkpoint: when advancing to a terminal delivered status, seal the checkpoint.
1377
+ if (status === "delivered") {
1378
+ await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
1379
+ // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1380
+ await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
1381
+ }
653
1382
  return 0;
654
1383
  }
655
1384
  export function normalizeFinding(raw) {
@@ -657,22 +1386,23 @@ export function normalizeFinding(raw) {
657
1386
  die("file_refs must be an array");
658
1387
  return raw;
659
1388
  }
660
- function critiqueStatus(critiques, required) {
661
- if (!required && critiques.length === 0)
662
- return "not_required";
663
- if (critiques.some((c) => c.verdict === "fail" || (Array.isArray(c.findings) && c.findings.some((f) => f.status === "open"))))
664
- return "fail";
665
- return "pass";
666
- }
667
- function recordCritique(p) {
1389
+ async function recordCritique(p) {
668
1390
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
669
1391
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
670
- const existing = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1392
+ // Phase 4c: accumulate existing critiques from trust.bundle (critique.json no longer written).
1393
+ // Fall back to critique.json for legacy sessions that still have it on disk.
1394
+ const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1395
+ const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1396
+ const _dctCritique = declaredClaimTypesFor(dir);
1397
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
671
1398
  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"))) };
672
- const critiques = [...(Array.isArray(existing.critiques) ? existing.critiques : []), critique];
1399
+ const critiques = [...bundleCritiques, critique];
673
1400
  if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
674
1401
  die("required critique must pass");
675
- writeJson(path.join(dir, "critique.json"), { ...sidecarBase(slug), status: critiqueStatus(critiques, true), required: true, updated_at: critique.reviewed_at, critiques });
1402
+ // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1403
+ const _critiqueEvChecks = checksFromBundle(dir, _dctCritique);
1404
+ const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1405
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
676
1406
  return 0;
677
1407
  }
678
1408
  function frontmatter(text, key) {
@@ -683,7 +1413,7 @@ function frontmatter(text, key) {
683
1413
  return "";
684
1414
  return new RegExp(`^${key}:\\s*(.+)$`, "m").exec(text.slice(0, end))?.[1]?.trim() ?? "";
685
1415
  }
686
- function importCritique(p) {
1416
+ async function importCritique(p) {
687
1417
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
688
1418
  const review = p.positional[1] || die("review artifact is required");
689
1419
  const text = read(review);
@@ -699,12 +1429,12 @@ function importCritique(p) {
699
1429
  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] });
700
1430
  }
701
1431
  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 };
702
- const result = recordCritique(parsed);
1432
+ const result = await recordCritique(parsed);
703
1433
  if (verdict !== "pass")
704
1434
  die("required critique must pass");
705
1435
  return result;
706
1436
  }
707
- function recordRelease(p) {
1437
+ async function recordRelease(p) {
708
1438
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
709
1439
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
710
1440
  const decision = opt(p, "decision");
@@ -717,6 +1447,264 @@ function recordRelease(p) {
717
1447
  const stateSummary = opt(p, "summary").trim() || `Release readiness recorded for ${decision}.`;
718
1448
  writeJson(path.join(dir, "release.json"), payload);
719
1449
  writeState(dir, slug, "delivered", "release", payload.updated_at, stateSummary);
1450
+ // Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
1451
+ await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
1452
+ // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1453
+ await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
1454
+ return 0;
1455
+ }
1456
+ // ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
1457
+ // Per-run frozen snapshot of verified trust state at completion. Written to
1458
+ // trust.checkpoint.json alongside the other workflow sidecars.
1459
+ // Surface owns the DerivationCheckpoint shape; flow-agents wraps it in an
1460
+ // ENVELOPE that adds per-run context surface does not carry.
1461
+ //
1462
+ // Envelope shape:
1463
+ // {
1464
+ // schema_version: "1.0",
1465
+ // slug: string,
1466
+ // work_item: string | null,
1467
+ // status: string,
1468
+ // phase: string,
1469
+ // sealed_at: ISO-8601,
1470
+ // commit_sha: string | null,
1471
+ // checkpoint: DerivationCheckpoint ← surface owns this
1472
+ // }
1473
+ //
1474
+ // Idempotent: re-running advance-state / record-release to the same terminal
1475
+ // status overwrites with the latest snapshot.
1476
+ // Fail-open: if no trust.bundle exists, or Surface is unavailable, the write
1477
+ // is skipped gracefully (no error surfaced to the caller).
1478
+ /** Derive the current git HEAD sha — null if unavailable (not in a repo, git absent). */
1479
+ function resolveCommitSha() {
1480
+ try {
1481
+ return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
1482
+ }
1483
+ catch {
1484
+ return null;
1485
+ }
1486
+ }
1487
+ /**
1488
+ * Build and write trust.checkpoint.json for a completed run.
1489
+ * Skips silently when:
1490
+ * - trust.bundle is absent (no evidence recorded yet)
1491
+ * - Surface is unavailable (checkpointFromReport not found)
1492
+ * The caller wraps this in .catch() so it never breaks the parent command.
1493
+ *
1494
+ * Increment B1 — checkpoint signing at the release boundary:
1495
+ * After the checkpoint is written, attempts Sigstore keyless signing (OIDC).
1496
+ * - CI/OIDC available: writes trust.checkpoint.sig.json (cosign-verifiable DSSE envelope)
1497
+ * and writes attestation:{status:"signed",...} to trust.checkpoint.attestation.json.
1498
+ * - Local (no OIDC): writes trust.checkpoint.intoto.json (unsigned in-toto statement)
1499
+ * and writes attestation:{status:"unsigned",...} to trust.checkpoint.attestation.json.
1500
+ * Signing is ALWAYS fail-open — a signing failure never breaks the seal.
1501
+ */
1502
+ export async function sealTrustCheckpoint(dir, slug, sealedAt, status, phase) {
1503
+ const bundlePath = path.join(dir, "trust.bundle");
1504
+ if (!fs.existsSync(bundlePath))
1505
+ return; // no bundle — skip gracefully
1506
+ const surface = await tryLoadSurface();
1507
+ if (!surface || typeof surface.checkpointFromReport !== "function" || typeof surface.buildTrustReport !== "function")
1508
+ return; // Surface unavailable
1509
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
1510
+ const report = surface.buildTrustReport(bundle);
1511
+ const checkpoint = surface.checkpointFromReport(report);
1512
+ // Derive work_item from state.json if present (best-effort)
1513
+ let workItem = null;
1514
+ try {
1515
+ const stateRaw = loadJson(path.join(dir, "state.json"));
1516
+ if (typeof stateRaw.work_item === "string")
1517
+ workItem = stateRaw.work_item;
1518
+ }
1519
+ catch { /* ignored */ }
1520
+ const checkpointPath = path.join(dir, "trust.checkpoint.json");
1521
+ const envelope = {
1522
+ schema_version: "1.0",
1523
+ slug,
1524
+ work_item: workItem,
1525
+ status,
1526
+ phase,
1527
+ sealed_at: sealedAt,
1528
+ commit_sha: resolveCommitSha(),
1529
+ checkpoint,
1530
+ };
1531
+ writeJson(checkpointPath, envelope);
1532
+ // ─── Increment B1: sign the checkpoint at the release boundary ───────────────
1533
+ // Additive: if surface lacks in-toto/sigstore primitives, skip silently.
1534
+ // The .catch() at the call site already guards the parent command; this inner
1535
+ // catch is defense-in-depth so signing never propagates an error upward.
1536
+ await signCheckpointAttestation(dir, surface, bundle, checkpointPath).catch((err) => {
1537
+ process.stderr.write(`[checkpoint-signing] signing skipped due to error: ${err instanceof Error ? err.message : String(err)}\n`);
1538
+ });
1539
+ }
1540
+ /**
1541
+ * Increment B1 — Sign the trust checkpoint with in-toto/Sigstore.
1542
+ *
1543
+ * Called from sealTrustCheckpoint AFTER trust.checkpoint.json is written.
1544
+ * Computes the sha256 digest of the checkpoint file, builds an in-toto Statement
1545
+ * (predicate = trust bundle), and attempts Sigstore keyless signing.
1546
+ *
1547
+ * - Signed (CI/OIDC): writes trust.checkpoint.sig.json (DSSE envelope, cosign-verifiable).
1548
+ * - Unsigned (local): writes trust.checkpoint.intoto.json (unsigned statement).
1549
+ * - Always writes: trust.checkpoint.attestation.json with attestation:{status,path,...}.
1550
+ * trust.checkpoint.json is NOT modified after its digest is computed.
1551
+ *
1552
+ * NEVER throws — all errors are caught and surfaced as stderr warnings.
1553
+ * Skips silently when Surface's toInTotoStatement / signStatementWithSigstore are absent.
1554
+ *
1555
+ * @param dir Session artifact directory.
1556
+ * @param surface Loaded Surface module (may or may not have in-toto/sigstore exports).
1557
+ * @param bundle Parsed trust.bundle (becomes the in-toto predicate).
1558
+ * @param checkpointPath Absolute path to the already-written trust.checkpoint.json.
1559
+ */
1560
+ async function signCheckpointAttestation(dir, surface, bundle, checkpointPath) {
1561
+ // Guard: both primitives must be present (consumed from Surface, never reimplemented).
1562
+ if (typeof surface.toInTotoStatement !== "function" || typeof surface.signStatementWithSigstore !== "function") {
1563
+ process.stderr.write("[checkpoint-signing] Surface in-toto/sigstore primitives unavailable — skipping attestation\n");
1564
+ return;
1565
+ }
1566
+ // Step A: compute sha256 digest of trust.checkpoint.json (the SUBJECT).
1567
+ // The checkpoint is self-evidencing — its digest is the external anchor.
1568
+ const checkpointBytes = fs.readFileSync(checkpointPath);
1569
+ const sha256hex = createHash("sha256").update(checkpointBytes).digest("hex");
1570
+ // Step B: build the in-toto Statement.
1571
+ // subject = the checkpoint file (what we are attesting TO)
1572
+ // predicate = the trust bundle (what the checkpoint CONTAINS)
1573
+ const subjects = [{ name: "trust.checkpoint.json", digest: { sha256: sha256hex } }];
1574
+ const statement = surface.toInTotoStatement(bundle, { subjects });
1575
+ // Step C: attempt Sigstore keyless signing (PRIMARY path).
1576
+ // signStatementWithSigstore returns null when no ambient OIDC credential is available
1577
+ // (local development, no ACTIONS_ID_TOKEN_REQUEST_URL). This is the expected local case.
1578
+ let signed = null;
1579
+ try {
1580
+ signed = await surface.signStatementWithSigstore(statement);
1581
+ }
1582
+ catch (err) {
1583
+ // signStatementWithSigstore may throw on unexpected failures (network error, config error);
1584
+ // treat as fail-open: fall through to the unsigned path.
1585
+ process.stderr.write(`[checkpoint-signing] signStatementWithSigstore threw: ${err instanceof Error ? err.message : String(err)}\n`);
1586
+ signed = null;
1587
+ }
1588
+ let attestation;
1589
+ if (signed) {
1590
+ // CI/OIDC path: write the cosign-verifiable DSSE envelope.
1591
+ const sigPath = path.join(dir, "trust.checkpoint.sig.json");
1592
+ writeJson(sigPath, signed.envelope);
1593
+ const keyid = signed.envelope.signatures[0]?.keyid ?? "";
1594
+ attestation = {
1595
+ status: "signed",
1596
+ path: "trust.checkpoint.sig.json",
1597
+ keyid,
1598
+ };
1599
+ process.stderr.write(`[checkpoint-signing] checkpoint signed with Sigstore — envelope written to ${sigPath}\n`);
1600
+ }
1601
+ else {
1602
+ // Local/unsigned path: write the unsigned in-toto statement for audit purposes.
1603
+ const unsignedPath = path.join(dir, "trust.checkpoint.intoto.json");
1604
+ writeJson(unsignedPath, statement);
1605
+ attestation = {
1606
+ status: "unsigned",
1607
+ path: "trust.checkpoint.intoto.json",
1608
+ reason: "no ambient signing identity",
1609
+ };
1610
+ process.stderr.write("[checkpoint-signing] no ambient OIDC identity — unsigned in-toto statement written (expected locally)\n");
1611
+ }
1612
+ // Step D: write the attestation record to a SEPARATE companion file.
1613
+ // trust.checkpoint.json is NOT modified — it must remain byte-identical to what was signed.
1614
+ // The companion file carries the pointer/status; the subject-digest binding in the
1615
+ // in-toto statement ties it back to the checkpoint without breaking the digest.
1616
+ const attestationPath = path.join(dir, "trust.checkpoint.attestation.json");
1617
+ writeJson(attestationPath, attestation);
1618
+ }
1619
+ /**
1620
+ * seal-checkpoint <dir> [--timestamp <iso>]
1621
+ *
1622
+ * Explicit seal of the trust checkpoint for the given artifact dir.
1623
+ * Equivalent to the seal that fires automatically at record-release / advance-state
1624
+ * to delivered. Useful for the deliver skill or a human to seal explicitly without
1625
+ * re-running advance-state.
1626
+ *
1627
+ * Usage: workflow-sidecar seal-checkpoint <artifactDir> [--timestamp <iso>]
1628
+ */
1629
+ async function sealCheckpoint(p) {
1630
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1631
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1632
+ const timestamp = opt(p, "timestamp", now());
1633
+ const stateRaw = loadJson(path.join(dir, "state.json"));
1634
+ const status = typeof stateRaw.status === "string" ? stateRaw.status : "delivered";
1635
+ const phase = typeof stateRaw.phase === "string" ? stateRaw.phase : "release";
1636
+ const bundlePath = path.join(dir, "trust.bundle");
1637
+ if (!fs.existsSync(bundlePath)) {
1638
+ process.stderr.write(`[seal-checkpoint] no trust.bundle at ${bundlePath} — skipping (nothing to seal)
1639
+ `);
1640
+ return 0;
1641
+ }
1642
+ await sealTrustCheckpoint(dir, slug, timestamp, status, phase);
1643
+ const checkpointPath = path.join(dir, "trust.checkpoint.json");
1644
+ if (fs.existsSync(checkpointPath)) {
1645
+ console.log(checkpointPath);
1646
+ }
1647
+ else {
1648
+ process.stderr.write(`[seal-checkpoint] checkpoint was not written — @kontourai/surface may be unavailable
1649
+ `);
1650
+ }
1651
+ return 0;
1652
+ }
1653
+ // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
1654
+ // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
1655
+ // session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
1656
+ // path so the CI trust-reconcile job can reconcile it against fresh CI results.
1657
+ //
1658
+ // Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
1659
+ // Idempotent: overwrites on re-delivery.
1660
+ // Called automatically from recordRelease and advanceState→delivered (best-effort).
1661
+ // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
1662
+ /**
1663
+ * Publish the session's trust artifacts to the committed delivery/ path.
1664
+ *
1665
+ * Copies trust.bundle, trust.checkpoint.json, and (if present)
1666
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
1667
+ * session artifact dir to <repoRoot>/delivery/.
1668
+ *
1669
+ * Fail-soft: if trust.bundle is absent, returns without throwing.
1670
+ * Idempotent: overwrites on re-delivery.
1671
+ */
1672
+ export async function publishDelivery(dir, repoRoot) {
1673
+ const bundleSrc = path.join(dir, "trust.bundle");
1674
+ if (!fs.existsSync(bundleSrc))
1675
+ return; // no bundle — skip gracefully
1676
+ const deliveryDir = path.join(repoRoot, "delivery");
1677
+ fs.mkdirSync(deliveryDir, { recursive: true });
1678
+ // Required: trust.bundle (the CI anchor)
1679
+ fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
1680
+ // Optional companions: checkpoint + signing artifacts
1681
+ const companions = [
1682
+ "trust.checkpoint.json",
1683
+ "trust.checkpoint.intoto.json",
1684
+ "trust.checkpoint.sig.json",
1685
+ ];
1686
+ for (const filename of companions) {
1687
+ const src = path.join(dir, filename);
1688
+ if (fs.existsSync(src)) {
1689
+ fs.copyFileSync(src, path.join(deliveryDir, filename));
1690
+ }
1691
+ }
1692
+ process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
1693
+ }
1694
+ /**
1695
+ * publish-delivery <artifact-dir> [--repo-root <path>]
1696
+ *
1697
+ * Explicit publish of the session trust bundle to the committed delivery/ path.
1698
+ * Equivalent to the publish that fires automatically at record-release /
1699
+ * advance-state to delivered. Useful for the deliver skill or a human to
1700
+ * publish explicitly.
1701
+ *
1702
+ * Usage: workflow-sidecar publish-delivery <artifactDir> [--repo-root <path>]
1703
+ */
1704
+ async function publishDeliveryCmd(p) {
1705
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1706
+ const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
1707
+ await publishDelivery(dir, repoRoot);
720
1708
  return 0;
721
1709
  }
722
1710
  export function validateLearningCorrection(record) {
@@ -774,7 +1762,7 @@ export function normalizeLearning(raw, timestamp) {
774
1762
  validateLearningCorrection(raw);
775
1763
  return { recorded_at: timestamp, ...raw };
776
1764
  }
777
- function recordLearning(p) {
1765
+ async function recordLearning(p) {
778
1766
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
779
1767
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
780
1768
  const timestamp = opt(p, "timestamp", now());
@@ -786,9 +1774,35 @@ function recordLearning(p) {
786
1774
  die("learning status learned requires every record to include correction.needed");
787
1775
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
788
1776
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
1777
+ // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
1778
+ // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
1779
+ const _dctLearning = declaredClaimTypesFor(dir);
1780
+ const _learningChecks = checksFromBundle(dir, _dctLearning);
1781
+ const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1782
+ const _learningCritiques = critiquesFromBundle(dir, _dctLearning);
1783
+ assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
789
1784
  return 0;
790
1785
  }
791
- function evidenceClean(dir) {
1786
+ function evidenceClean(dir, declaredClaimTypes = new Set()) {
1787
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
1788
+ // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
1789
+ // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
1790
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
1791
+ if (Array.isArray(bundle.claims)) {
1792
+ const checkClaims = bundle.claims.filter((c) => {
1793
+ if (!c)
1794
+ return false;
1795
+ const ct = String(c.claimType || "");
1796
+ return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
1797
+ });
1798
+ if (checkClaims.length === 0)
1799
+ return false;
1800
+ return checkClaims.every((c) => {
1801
+ const v = String(c.value || "");
1802
+ return v === "pass" || v === "skip";
1803
+ });
1804
+ }
1805
+ // Legacy fallback: evidence.json
792
1806
  const e = loadJson(path.join(dir, "evidence.json"), {});
793
1807
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
794
1808
  if (!(c.status === "pass" || c.status === "skip"))
@@ -796,7 +1810,21 @@ function evidenceClean(dir) {
796
1810
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
797
1811
  });
798
1812
  }
799
- function critiqueClean(dir) {
1813
+ function critiqueClean(dir, declaredClaimTypes = new Set()) {
1814
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
1815
+ // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
1816
+ // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
1817
+ const bundle = loadJson(path.join(dir, "trust.bundle"));
1818
+ if (Array.isArray(bundle.claims)) {
1819
+ const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
1820
+ if (critiqueClaims.length === 0)
1821
+ return false; // no critique written yet
1822
+ return critiqueClaims.every((c) => {
1823
+ const v = String(c.value || "");
1824
+ return v !== "fail" && c.status !== "disputed" && c.status !== "rejected";
1825
+ });
1826
+ }
1827
+ // Legacy fallback: critique.json
800
1828
  const c = loadJson(path.join(dir, "critique.json"), {});
801
1829
  return c.status === "pass" && Array.isArray(c.critiques) && c.critiques.every((x) => x.verdict !== "fail" && (!Array.isArray(x.findings) || x.findings.every((f) => f.status !== "open" && (f.file_refs === undefined || Array.isArray(f.file_refs)))));
802
1830
  }
@@ -819,7 +1847,7 @@ function assertExistingLearningValid(dir) {
819
1847
  die("learning status learned requires every record to include correction.needed");
820
1848
  }
821
1849
  }
822
- function dogfoodPass(p) {
1850
+ async function dogfoodPass(p) {
823
1851
  const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
824
1852
  const dir = path.resolve(opt(p, "artifact-dir") || currentDir(root) || "");
825
1853
  requireArtifactDirUnderRoot(dir, root);
@@ -829,9 +1857,16 @@ function dogfoodPass(p) {
829
1857
  const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
830
1858
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
831
1859
  die("clean evidence requires all non-skipped checks to pass");
832
- if (fs.existsSync(path.join(dir, "evidence.json")) && !evidenceClean(dir))
1860
+ // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
1861
+ // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
1862
+ const _dctDogfood = declaredClaimTypesFor(dir);
1863
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
1864
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
1865
+ if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
1866
+ die("cannot mark clean without passing evidence");
1867
+ if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
833
1868
  die("cannot mark clean without passing evidence");
834
- if (!fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0)
1869
+ if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0)
835
1870
  die("cannot mark clean without passing evidence");
836
1871
  if (p.flags.has("require-critique") || opt(p, "release-decision")) {
837
1872
  const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
@@ -839,9 +1874,10 @@ function dogfoodPass(p) {
839
1874
  normalizeFinding(parseJson(value, "--finding-json"));
840
1875
  if (newCritiqueVerdict !== "pass")
841
1876
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
842
- if (!opt(p, "critique-id") && !critiqueClean(dir))
1877
+ if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood))
843
1878
  die("requires passing critique");
844
- if (fs.existsSync(path.join(dir, "critique.json")) && !critiqueClean(dir))
1879
+ // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
1880
+ if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
845
1881
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
846
1882
  }
847
1883
  }
@@ -851,11 +1887,11 @@ function dogfoodPass(p) {
851
1887
  if (opt(p, "learning-status") === "learned" && learningRecords.some((r) => r.correction === undefined))
852
1888
  die("learned status requires every learning record to include correction.needed");
853
1889
  if (opts(p, "check-json").length)
854
- recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
1890
+ await recordEvidence({ ...p, positional: [dir], opts: { ...p.opts, verdict: [verdict] }, flags: p.flags });
855
1891
  if (p.flags.has("require-critique") && opt(p, "critique-id"))
856
- recordCritique({ ...p, positional: [dir], opts: { ...p.opts, id: [opt(p, "critique-id")], verdict: [opt(p, "critique-verdict", "pass")], summary: [opt(p, "critique-summary", opt(p, "summary"))] }, flags: p.flags });
1892
+ await recordCritique({ ...p, positional: [dir], opts: { ...p.opts, id: [opt(p, "critique-id")], verdict: [opt(p, "critique-verdict", "pass")], summary: [opt(p, "critique-summary", opt(p, "summary"))] }, flags: p.flags });
857
1893
  if (learningRecords.length)
858
- recordLearning({ ...p, positional: [dir], opts: { ...p.opts, status: [opt(p, "learning-status", "learned")], "record-json": opts(p, "learning-record-json"), summary: [opt(p, "learning-summary", opt(p, "summary"))] }, flags: p.flags });
1894
+ await recordLearning({ ...p, positional: [dir], opts: { ...p.opts, status: [opt(p, "learning-status", "learned")], "record-json": opts(p, "learning-record-json"), summary: [opt(p, "learning-summary", opt(p, "summary"))] }, flags: p.flags });
859
1895
  if (opt(p, "release-decision")) {
860
1896
  recordRelease({ ...p, positional: [dir], opts: { ...p.opts, decision: [opt(p, "release-decision")], scope: [opt(p, "release-scope")], summary: [opt(p, "release-summary", opt(p, "summary"))], "gate-json": ['{"name":"merge","status":"pass","summary":"Dogfood release gate passed."}'], "evidence-ref": ["evidence.json"], "docs-json": [`{"status":"updated","summary":"Docs updated.","refs":["${opt(p, "release-doc-ref", "docs/workflow-usage-guide.md")}"]}`] }, flags: p.flags });
861
1897
  printJson({ release_decision: opt(p, "release-decision") });
@@ -868,14 +1904,821 @@ function dogfoodPass(p) {
868
1904
  writeJson(path.join(dir, "handoff.json"), handoff);
869
1905
  }
870
1906
  writeState(dir, taskSlugFor(dir, opt(p, "task-slug")), stateStatus, "verification", opt(p, "timestamp", now()), opt(p, "summary"), verdict === "pass" ? "continue" : "blocked");
1907
+ // Phase 4c: bundle was already written by recordEvidence/recordCritique above (if called).
1908
+ // If neither ran (e.g. verdict=fail with no check-json), re-build from bundle (no bespoke sidecars).
871
1909
  printJson({ state_status: stateStatus });
872
1910
  return 0;
873
1911
  }
1912
+ /**
1913
+ * Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
1914
+ * (written by scripts/hooks/stop-goal-fit.js when block mode fires).
1915
+ * The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
1916
+ * above the session artifact dir. Fail-open: returns { blocked: false } when
1917
+ * the file is absent or unreadable.
1918
+ *
1919
+ * @param artifactRoot The .flow-agents root dir (parent of session slug dir).
1920
+ */
1921
+ export function readGateBlockSignal(artifactRoot) {
1922
+ const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
1923
+ try {
1924
+ if (!fs.existsSync(streakFile))
1925
+ return { blocked: false, hash: null, count: 0 };
1926
+ const raw = JSON.parse(fs.readFileSync(streakFile, "utf8"));
1927
+ const count = Number(raw?.count ?? 0);
1928
+ const hash = typeof raw?.hash === "string" ? raw.hash : null;
1929
+ return { blocked: count >= 1, hash, count };
1930
+ }
1931
+ catch {
1932
+ return { blocked: false, hash: null, count: 0 };
1933
+ }
1934
+ }
1935
+ /**
1936
+ * Derive the gate-review calibration from a resolved InquiryRecord and the
1937
+ * block signal. Pure function — no I/O.
1938
+ *
1939
+ * Mapping (mirrors SKILL.md Bundle-Claim to Classification table):
1940
+ * outcome="matched", status="disputed"|"rejected", blocked=true → correct
1941
+ * outcome="matched", status="verified"|"assumed", blocked=true → false_block
1942
+ * outcome="matched", status="assumed", blocked=true → false_block
1943
+ * outcome="matched", status="stale"|"unknown", blocked=false → missed_block
1944
+ * outcome="matched", status="proposed", any → missed_block
1945
+ * outcome="unsupported" (absent claim), any → missed_block
1946
+ * outcome="derived", satisfied=true, any → correct/false_block by blocked flag
1947
+ * fallthrough → missed_block
1948
+ */
1949
+ export function deriveGateCalibration(outcome, answerStatus, blocked) {
1950
+ if (outcome === "unsupported")
1951
+ return "missed_block";
1952
+ if (outcome === "matched" || outcome === "derived") {
1953
+ const s = answerStatus ?? "unknown";
1954
+ if (blocked) {
1955
+ if (s === "disputed" || s === "rejected")
1956
+ return "correct";
1957
+ if (s === "verified" || s === "assumed")
1958
+ return "false_block";
1959
+ // stale/unknown/proposed while blocked — gate fired without solid evidence
1960
+ return "false_block";
1961
+ }
1962
+ else {
1963
+ // Not blocked
1964
+ if (s === "stale" || s === "unknown" || s === "proposed")
1965
+ return "missed_block";
1966
+ // verified/assumed and no block — correct (no block warranted, none issued)
1967
+ return "correct";
1968
+ }
1969
+ }
1970
+ return "missed_block";
1971
+ }
1972
+ /**
1973
+ * Compose the advisory proposed-fix string for a gate-review finding.
1974
+ * Pure function — no I/O.
1975
+ */
1976
+ export function gateAdvisoryFix(calibration, claimId, answerStatus) {
1977
+ const s = answerStatus ?? "unknown";
1978
+ if (calibration === "correct") {
1979
+ return `No gate change needed — block was warranted. Resolve the failure in claim \`${claimId}\` (status: \`${s}\`) and re-run gate-review to confirm the gate clears.`;
1980
+ }
1981
+ if (calibration === "false_block") {
1982
+ return `Investigate why the gate blocked when claim \`${claimId}\` has status \`${s}\`. Check whether stop-goal-fit evaluated a stale bundle snapshot or whether the block trigger was unrelated to bundle claims. If the block was spurious, add a freshness check to the gate evaluation loop.`;
1983
+ }
1984
+ // missed_block
1985
+ if (s === "stale") {
1986
+ return `Refresh the stale claim \`${claimId}\` by re-running the evidence capture step, then re-run gate-review to confirm the gate fires on updated data.`;
1987
+ }
1988
+ if (s === "absent") {
1989
+ return `Ensure \`workflow-sidecar record-evidence\` writes a bundle claim for \`${claimId}\` before \`stop-goal-fit\` evaluates. Currently no claim exists in the bundle — the gate has nothing to evaluate.`;
1990
+ }
1991
+ return `Ensure \`workflow-sidecar record-evidence\` writes a definitive event for claim \`${claimId}\` (currently \`${s}\`) before \`stop-goal-fit\` evaluates. The gate had no resolved evidence to act on.`;
1992
+ }
1993
+ /**
1994
+ * Build a schema-conformant InquiryRecord for the hachure inquiry-record.schema.json.
1995
+ * Strips Surface-internal fields (identityLinkIds, transitiveRuleIds) from
1996
+ * resolutionPath that are valid in the TS type but not in the JSON schema.
1997
+ * Sets answer.value to the gate-review value-add: { calibration, advisoryFix, gateFired, sessionSlug }.
1998
+ */
1999
+ function toSchemaInquiryRecord(raw, calibration, advisoryFix, blocked, slug) {
2000
+ const resolutionPath = { claimIds: raw.resolutionPath.claimIds };
2001
+ if (raw.resolutionPath.ruleId !== undefined)
2002
+ resolutionPath["ruleId"] = raw.resolutionPath.ruleId;
2003
+ if (raw.resolutionPath.ruleVersion !== undefined)
2004
+ resolutionPath["ruleVersion"] = raw.resolutionPath.ruleVersion;
2005
+ const record = {
2006
+ id: raw.id,
2007
+ inquiry: raw.inquiry,
2008
+ outcome: raw.outcome,
2009
+ resolutionPath,
2010
+ inputSnapshot: raw.inputSnapshot,
2011
+ statusFunctionVersion: raw.statusFunctionVersion,
2012
+ resolvedAt: raw.resolvedAt,
2013
+ };
2014
+ // answer carries the canonical trust status AND gate-review's value-add advisory fix.
2015
+ // answer.status = derived TrustStatus from the resolved claim (or "unknown" when absent).
2016
+ // answer.value = { calibration, advisoryFix, gateFired, sessionSlug } — gate-review advisory.
2017
+ const answerStatus = raw.answer?.status ?? "unknown";
2018
+ record["answer"] = {
2019
+ status: answerStatus,
2020
+ value: {
2021
+ calibration,
2022
+ advisoryFix,
2023
+ gateFired: blocked,
2024
+ sessionSlug: slug,
2025
+ },
2026
+ };
2027
+ return record;
2028
+ }
2029
+ /**
2030
+ * Build an array of canonical InquiryRecords for all gate-fire and missed-block
2031
+ * candidates in the bundle, using Surface's resolveInquiry. Returns null when
2032
+ * Surface is unavailable (caller skips the output file — no fork fallback).
2033
+ *
2034
+ * @param bundle Parsed trust.bundle (BundleFile shape)
2035
+ * @param blockSignal Result of readGateBlockSignal()
2036
+ * @param slug Task slug (used in inquiry ids and session_slug)
2037
+ * @param expectedCriterionIds Optional list of expected criterion IDs to check
2038
+ * for absent claims (missed_block detection).
2039
+ * @param surface Loaded Surface module (must have resolveInquiry)
2040
+ * @param now Optional timestamp override for deterministic tests
2041
+ */
2042
+ export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface, now) {
2043
+ const records = [];
2044
+ let idx = 0;
2045
+ const askedAt = (now ?? new Date()).toISOString();
2046
+ const bundleRecord = bundle;
2047
+ const claims = Array.isArray(bundle?.claims) ? bundle.claims : [];
2048
+ // Build a set of subjectIds already covered by bundle claims
2049
+ const claimSubjectIds = new Set(claims.map((c) => c.subjectId));
2050
+ // ── Step 1: resolve each bundle claim via resolveInquiry ──────────────────
2051
+ for (const claim of claims) {
2052
+ idx += 1;
2053
+ const inquiryId = `${slug}-gr-${idx}`;
2054
+ const inquiry = {
2055
+ id: inquiryId,
2056
+ question: `Was gate action on claim ${claim.id} (status: ${claim.status}) justified given the trust state?`,
2057
+ askedBy: "gate-review",
2058
+ askedAt,
2059
+ target: {
2060
+ subjectType: claim.subjectType,
2061
+ subjectId: claim.subjectId,
2062
+ fieldOrBehavior: claim.fieldOrBehavior,
2063
+ },
2064
+ metadata: { sessionSlug: slug, claimId: claim.id, blocked: blockSignal.blocked },
2065
+ };
2066
+ const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
2067
+ const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
2068
+ const advisoryFix = gateAdvisoryFix(calibration, claim.id, rawRecord.answer?.status ?? claim.status);
2069
+ records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
2070
+ }
2071
+ // ── Step 2: resolve absent expected criteria (missed_block candidates) ────
2072
+ for (const criterionId of expectedCriterionIds) {
2073
+ const subjectId = `${slug}/${criterionId}`;
2074
+ // Skip if there's already a bundle claim for this criterion
2075
+ if (claimSubjectIds.has(subjectId) || claimSubjectIds.has(criterionId))
2076
+ continue;
2077
+ idx += 1;
2078
+ const inquiryId = `${slug}-gr-${idx}`;
2079
+ const inquiry = {
2080
+ id: inquiryId,
2081
+ question: `Was acceptance criterion "${criterionId}" claimed in the trust.bundle before gate evaluation?`,
2082
+ askedBy: "gate-review",
2083
+ askedAt,
2084
+ target: {
2085
+ subjectType: "workflow-check",
2086
+ subjectId,
2087
+ fieldOrBehavior: criterionId,
2088
+ },
2089
+ metadata: { sessionSlug: slug, criterionId, blocked: blockSignal.blocked, expectedCriterion: true },
2090
+ };
2091
+ const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
2092
+ // outcome will be "unsupported" since no claim matches the absent criterion
2093
+ const calibration = deriveGateCalibration(rawRecord.outcome, rawRecord.answer?.status, blockSignal.blocked);
2094
+ const advisoryFix = gateAdvisoryFix(calibration, subjectId, "absent");
2095
+ records.push(toSchemaInquiryRecord(rawRecord, calibration, advisoryFix, blockSignal.blocked, slug));
2096
+ }
2097
+ // ── Step 3: if still empty (no claims, no expected criteria), emit one record
2098
+ if (records.length === 0) {
2099
+ idx += 1;
2100
+ const inquiryId = `${slug}-gr-${idx}`;
2101
+ const inquiry = {
2102
+ id: inquiryId,
2103
+ question: `Does the trust.bundle for session "${slug}" contain any claims for gate evaluation?`,
2104
+ askedBy: "gate-review",
2105
+ askedAt,
2106
+ // No target — natural-language-only inquiry → resolveInquiry returns "unsupported"
2107
+ metadata: { sessionSlug: slug, blocked: blockSignal.blocked, reason: "empty-bundle" },
2108
+ };
2109
+ const rawRecord = surface.resolveInquiry(bundleRecord, inquiry, { now });
2110
+ const advisoryFix = `Ensure \`workflow-sidecar record-evidence\` writes at least one claim to the trust.bundle for session \`${slug}\` before gate-review is invoked.`;
2111
+ records.push(toSchemaInquiryRecord(rawRecord, "missed_block", advisoryFix, blockSignal.blocked, slug));
2112
+ }
2113
+ return records;
2114
+ }
2115
+ /**
2116
+ * gate-review <artifact-dir>
2117
+ *
2118
+ * Reads the session's trust.bundle and the gate block signal, classifies each
2119
+ * gate fire or suspected miss using Surface's resolveInquiry, and emits
2120
+ * gate-review.inquiries.json as an array of canonical InquiryRecords.
2121
+ * ADVISORY ONLY — never modifies scripts/hooks/. Issue #119.
2122
+ *
2123
+ * The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
2124
+ * written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
2125
+ * lives one level above the session slug dir (the .flow-agents root).
2126
+ *
2127
+ * If @kontourai/surface is unavailable, logs a warning and returns 0
2128
+ * (fail-open — no bespoke fork fallback).
2129
+ */
2130
+ async function gateReview(p) {
2131
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
2132
+ if (!fs.existsSync(dir))
2133
+ die(`artifact directory does not exist: ${dir}`);
2134
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
2135
+ // Locate trust.bundle — required per SKILL.md contract
2136
+ const bundlePath = path.join(dir, "trust.bundle");
2137
+ if (!fs.existsSync(bundlePath)) {
2138
+ process.stderr.write(`[gate-review] trust.bundle absent at ${bundlePath} — NOT_VERIFIED. Build ADR 0010 Phase 1 first.\n`);
2139
+ return 1;
2140
+ }
2141
+ // Load Surface (ESM, fail-open)
2142
+ const surface = await tryLoadSurface();
2143
+ if (!surface || typeof surface.resolveInquiry !== "function") {
2144
+ process.stderr.write(`[gate-review] @kontourai/surface unavailable or missing resolveInquiry — gate-review skipped (no fork fallback)\n`);
2145
+ return 0;
2146
+ }
2147
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2148
+ // Read gate block signal from .flow-agents root (one level above session dir)
2149
+ const artifactRoot = path.dirname(dir);
2150
+ const blockSignal = readGateBlockSignal(artifactRoot);
2151
+ // Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
2152
+ // fallback = acceptance.json (back-compat for sessions without an up-to-date bundle).
2153
+ const criterionClaims = Array.isArray(bundle.claims)
2154
+ ? bundle.claims.filter((c) => c.claimType === "workflow.acceptance.criterion")
2155
+ : [];
2156
+ let expectedCriterionIds;
2157
+ if (criterionClaims.length > 0) {
2158
+ // Extract the final segment of subjectId (e.g. "slug/AC1" → "AC1")
2159
+ expectedCriterionIds = criterionClaims
2160
+ .map((c) => String(c.subjectId ?? "").split("/").pop() ?? "")
2161
+ .filter(Boolean);
2162
+ }
2163
+ else {
2164
+ // Fallback: read acceptance.json (back-compat for sessions without criterion claims)
2165
+ const acceptancePath = path.join(dir, "acceptance.json");
2166
+ const acceptance = fs.existsSync(acceptancePath) ? loadJson(acceptancePath) : null;
2167
+ expectedCriterionIds = Array.isArray(acceptance?.criteria)
2168
+ ? acceptance.criteria.map((c) => String(c.id ?? "")).filter(Boolean)
2169
+ : [];
2170
+ }
2171
+ const records = buildGateInquiryRecords(bundle, blockSignal, slug, expectedCriterionIds, surface);
2172
+ // Validate each record against the hachure inquiry-record.schema.json (fail-open)
2173
+ const validator = getHachureInquiryRecordValidator();
2174
+ let schemaValid = true;
2175
+ const validationErrors = [];
2176
+ for (const record of records) {
2177
+ if (validator) {
2178
+ const result = validator(record);
2179
+ if (!result.valid) {
2180
+ schemaValid = false;
2181
+ validationErrors.push(...result.errors.map((e) => `${record["id"] ?? "?"}: ${e}`));
2182
+ }
2183
+ }
2184
+ }
2185
+ if (!schemaValid) {
2186
+ process.stderr.write(`[gate-review] InquiryRecord schema validation errors:\n${validationErrors.join("\n")}\n`);
2187
+ }
2188
+ const outputPath = path.join(dir, "gate-review.inquiries.json");
2189
+ writeJson(outputPath, records);
2190
+ // Build summary counts by calibration
2191
+ const counts = {};
2192
+ for (const r of records) {
2193
+ const cal = r["answer"]?.["value"]?.["calibration"] ?? "unknown";
2194
+ counts[cal] = (counts[cal] ?? 0) + 1;
2195
+ }
2196
+ const summary = Object.entries(counts)
2197
+ .filter(([, n]) => n > 0)
2198
+ .map(([k, n]) => `${k}=${n}`)
2199
+ .join(", ");
2200
+ const schemaTag = validator ? (schemaValid ? " schema:valid" : " schema:INVALID") : " schema:unavailable";
2201
+ console.log(`gate-review: ${records.length} InquiryRecord(s) [${summary}]${schemaTag} → ${outputPath}`);
2202
+ return 0;
2203
+ }
2204
+ // ─────────────────────────────────────────────────────────────────────────────
2205
+ // ─── ADR 0010 Phase 3: project the local trust.bundle to the Surface Trust Panel ──
2206
+ // Surface owns derivation (buildTrustReport) AND rendering (the dependency-free
2207
+ // <surface-trust-panel> element). Flow Agents only assembles a standalone HTML
2208
+ // shell — no trust logic or rendering reimplemented (consume-never-fork).
2209
+ /** Locate Surface's self-contained, dependency-free panel element (ESM, no require). */
2210
+ function loadSurfacePanelJs() {
2211
+ let d = path.dirname(fileURLToPath(import.meta.url));
2212
+ for (let i = 0; i < 12; i += 1) {
2213
+ try {
2214
+ return fs.readFileSync(path.join(d, "node_modules/@kontourai/surface/dist/src/trust-panel/surface-trust-panel.js"), "utf8");
2215
+ }
2216
+ catch { /* walk up */ }
2217
+ const parent = path.dirname(d);
2218
+ if (parent === d)
2219
+ break;
2220
+ d = parent;
2221
+ }
2222
+ die("could not locate @kontourai/surface trust-panel element (dist/src/trust-panel/surface-trust-panel.js)");
2223
+ return "";
2224
+ }
2225
+ async function renderTrustPanel(p) {
2226
+ const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
2227
+ const dir = p.positional[0] ? artifactDirFrom(p.positional[0]) : currentDir(root);
2228
+ if (!dir)
2229
+ die("render-trust-panel requires a workflow dir or a recorded current session");
2230
+ let bundle = null;
2231
+ try {
2232
+ bundle = JSON.parse(fs.readFileSync(path.join(dir, "trust.bundle"), "utf8"));
2233
+ }
2234
+ catch {
2235
+ bundle = null;
2236
+ }
2237
+ if (!bundle)
2238
+ die(`no trust.bundle at ${path.join(dir, "trust.bundle")} — run record-evidence first`);
2239
+ const surface = (await import("@kontourai/surface"));
2240
+ if (typeof surface.buildTrustReport !== "function")
2241
+ die("@kontourai/surface buildTrustReport unavailable — cannot derive the trust report");
2242
+ const report = surface.buildTrustReport(bundle);
2243
+ // diffFreshness on resume: if a prior trust.checkpoint.json exists, surface the
2244
+ // fresh→stale transitions so the user sees what has gone stale since the last seal.
2245
+ const checkpointFile = path.join(dir, "trust.checkpoint.json");
2246
+ if (fs.existsSync(checkpointFile) && typeof surface.diffFreshness === "function") {
2247
+ try {
2248
+ const envelope = JSON.parse(fs.readFileSync(checkpointFile, "utf8"));
2249
+ const priorCheckpoint = envelope.checkpoint;
2250
+ if (priorCheckpoint && typeof priorCheckpoint === "object") {
2251
+ const transitions = surface.diffFreshness(priorCheckpoint, report);
2252
+ const staleTransitions = transitions.filter((t) => t["to"] === "stale");
2253
+ if (staleTransitions.length > 0) {
2254
+ const claimIds = staleTransitions.map((t) => String(t["claimId"] ?? "")).filter(Boolean);
2255
+ process.stderr.write(`[trust-checkpoint] ${staleTransitions.length} claim(s) went stale since the last checkpoint (sealed ${String(envelope.sealed_at ?? "unknown")}):\n${claimIds.map((id) => ` - ${id}`).join("\n")}\n`);
2256
+ }
2257
+ else {
2258
+ process.stderr.write(`[trust-checkpoint] 0 claims went stale since the last checkpoint (sealed ${String(envelope.sealed_at ?? "unknown")}).\n`);
2259
+ }
2260
+ }
2261
+ }
2262
+ catch {
2263
+ /* diffFreshness is advisory — never block the panel render */
2264
+ }
2265
+ }
2266
+ const panelJs = loadSurfacePanelJs();
2267
+ const heading = `Flow Agents trust — ${String(path.basename(dir)).replace(/[<>"&]/g, "")}`;
2268
+ const reportJson = JSON.stringify(report).replace(/</g, "\\u003c");
2269
+ const html = `<!doctype html>
2270
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${heading}</title></head>
2271
+ <body style="margin:0;padding:1.5rem;background:#f4f1e6">
2272
+ <script type="module">
2273
+ ${panelJs}
2274
+ </script>
2275
+ <surface-trust-panel heading="${heading}"></surface-trust-panel>
2276
+ <script id="trust-report" type="application/json">${reportJson}</script>
2277
+ <script type="module">document.querySelector("surface-trust-panel").report = JSON.parse(document.getElementById("trust-report").textContent);</script>
2278
+ </body></html>
2279
+ `;
2280
+ const out = opt(p, "out") || path.join(dir, "trust-panel.html");
2281
+ fs.writeFileSync(out, html);
2282
+ // Also emit the derived report as a first-class artifact — the universal input for
2283
+ // Surface's hosted Snapshot Viewer and a bare `<surface-trust-panel src=…>` (the HTML
2284
+ // above already embeds it). Suppress with --no-report.
2285
+ let reportOut = "";
2286
+ if (!p.flags.has("no-report")) {
2287
+ reportOut = opt(p, "report-out") || path.join(dir, "trust-report.json");
2288
+ fs.writeFileSync(reportOut, `${JSON.stringify(report, null, 2)}\n`);
2289
+ }
2290
+ console.log(out);
2291
+ if (reportOut)
2292
+ console.log(reportOut);
2293
+ return 0;
2294
+ }
2295
+ // ─────────────────────────────────────────────────────────────────────────────
2296
+ // ─── flow-agents#137 / ADR 0011: wire Surface's MCP to surface trust reports ──
2297
+ // Flow Agents produces the bundle; Surface's MCP projects it. `--mode print` is the
2298
+ // zero-write default (output the snippet). `enable`/`disable` edit a runtime JSON MCP
2299
+ // config (e.g. Claude Code `.mcp.json`) via a *conventional managed key* — idempotent,
2300
+ // reversible, and only ever our own entry (never auto-injected; opt-in only).
2301
+ const TRUST_MCP_SERVER = "flow-agents-surface-trust";
2302
+ function trustMcpRegistration() {
2303
+ // No static `--input` (a single file can't follow many per-task bundles or a moving
2304
+ // current); the skill passes the active task's bundle as a per-call `path` arg.
2305
+ return { command: "npx", args: ["-y", "@kontourai/surface", "mcp"] };
2306
+ }
2307
+ function trustMcp(p) {
2308
+ const mode = opt(p, "mode", "print");
2309
+ if (mode === "print") {
2310
+ console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
2311
+ process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
2312
+ process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
2313
+ return 0;
2314
+ }
2315
+ if (mode !== "enable" && mode !== "disable")
2316
+ die("trust-mcp --mode must be print|enable|disable");
2317
+ const configPath = path.resolve(opt(p, "config", ".mcp.json"));
2318
+ let config = {};
2319
+ try {
2320
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
2321
+ }
2322
+ catch {
2323
+ config = {};
2324
+ }
2325
+ if (typeof config !== "object" || config === null || Array.isArray(config))
2326
+ die(`${configPath} is not a JSON object — refusing to edit`);
2327
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers))
2328
+ config.mcpServers = {};
2329
+ if (mode === "enable") {
2330
+ config.mcpServers[TRUST_MCP_SERVER] = trustMcpRegistration();
2331
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
2332
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
2333
+ console.log(`enabled ${TRUST_MCP_SERVER} in ${configPath} (remove with: trust-mcp --mode disable)`);
2334
+ return 0;
2335
+ }
2336
+ // disable: remove only our own conventional entry; leave everything else untouched.
2337
+ if (Object.prototype.hasOwnProperty.call(config.mcpServers, TRUST_MCP_SERVER)) {
2338
+ delete config.mcpServers[TRUST_MCP_SERVER];
2339
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
2340
+ console.log(`disabled ${TRUST_MCP_SERVER} in ${configPath}`);
2341
+ }
2342
+ else {
2343
+ console.log(`${TRUST_MCP_SERVER} not present in ${configPath} — nothing to remove`);
2344
+ }
2345
+ return 0;
2346
+ }
2347
+ // ─── ADR 0012: agent coordination as liveness claims (policy-centered) ──────────
2348
+ // A work-claim is a regular Hachure claim governed by a *liveness policy* (ttl +
2349
+ // heartbeat → held/stale/released), keyed by the work-item subjectId, appended to a
2350
+ // shared stream all agents read. Status is RECOMPUTED via Surface's deriveTrustStatus
2351
+ // (no forked logic). Advisory, not a lock. The liveness policy is a general archetype
2352
+ // (not use-case-specific) and is a candidate to graduate upstream into Surface.
2353
+ const LIVENESS_POLICY = {
2354
+ id: "policy:liveness.hold",
2355
+ claimType: "liveness.hold",
2356
+ requiredEvidence: [],
2357
+ acceptanceCriteria: ["A heartbeat within ttlSeconds holds the claim; a lapse or release frees it."],
2358
+ reviewAuthority: "system",
2359
+ validityRule: { kind: "duration", durationDays: 1 },
2360
+ stalenessTriggers: [],
2361
+ conflictRules: [],
2362
+ impactLevel: "medium",
2363
+ };
2364
+ function livenessStreamFile(root) { return path.join(root, "liveness", "events.jsonl"); }
2365
+ function appendLivenessEvent(root, evt) {
2366
+ const file = livenessStreamFile(root);
2367
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2368
+ fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
2369
+ }
2370
+ function readLivenessEvents(root) {
2371
+ // Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
2372
+ // Using createRequire so the ESM sidecar can load a CJS module without bundling it.
2373
+ try {
2374
+ const _req = createRequire(import.meta.url);
2375
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
2376
+ const helper = _req(helperPath);
2377
+ return helper.readLivenessEvents(livenessStreamFile(root));
2378
+ }
2379
+ catch {
2380
+ // Fallback: read inline (keeps sidecar self-sufficient if helper is unavailable)
2381
+ let raw = "";
2382
+ try {
2383
+ raw = fs.readFileSync(livenessStreamFile(root), "utf8");
2384
+ }
2385
+ catch {
2386
+ return [];
2387
+ }
2388
+ return raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try {
2389
+ return JSON.parse(l);
2390
+ }
2391
+ catch {
2392
+ return null;
2393
+ } }).filter((x) => x !== null);
2394
+ }
2395
+ }
2396
+ function livenessLabel(status) {
2397
+ if (status === "verified")
2398
+ return "held";
2399
+ if (status === "stale" || status === "revoked")
2400
+ return "free"; // reclaimable: lapsed or released
2401
+ if (status === "superseded")
2402
+ return "superseded";
2403
+ return status;
2404
+ }
2405
+ // ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
2406
+ // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2407
+ // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
2408
+ // Additive + fail-open: a liveness-emit failure never affects the workflow command.
2409
+ const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
2410
+ function resolveLivenessActor() { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
2411
+ function livenessEnabled() { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
2412
+ function livenessLifecycle(taskDir, slug, kind, timestamp) {
2413
+ if (!livenessEnabled())
2414
+ return;
2415
+ try {
2416
+ const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
2417
+ const evt = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
2418
+ if (kind === "claim")
2419
+ evt.ttlSeconds = 1800;
2420
+ appendLivenessEvent(root, evt);
2421
+ }
2422
+ catch { /* best-effort; liveness is advisory and must never break the workflow */ }
2423
+ }
2424
+ async function liveness(p) {
2425
+ const root = path.resolve(opt(p, "artifact-root", ".flow-agents"));
2426
+ const action = p.positional[0] || "";
2427
+ const subjectId = p.positional[1] || "";
2428
+ const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
2429
+ const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
2430
+ if (action === "claim" || action === "heartbeat" || action === "release") {
2431
+ if (!subjectId)
2432
+ die(`liveness ${action} requires a subjectId`);
2433
+ const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
2434
+ if (action === "claim")
2435
+ evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
2436
+ appendLivenessEvent(root, evt);
2437
+ console.log(`liveness ${action}: ${subjectId} by ${actor}`);
2438
+ return 0;
2439
+ }
2440
+ if (action === "status") {
2441
+ const surface = (await import("@kontourai/surface"));
2442
+ if (typeof surface.deriveTrustStatus !== "function")
2443
+ die("@kontourai/surface deriveTrustStatus unavailable — requires surface >= 1.2");
2444
+ const subjectFilter = opt(p, "subject");
2445
+ const now = opt(p, "now") ? new Date(opt(p, "now")) : new Date();
2446
+ // Group events by subjectId::actor — one liveness claim per holder of a subject.
2447
+ const groups = new Map();
2448
+ for (const e of readLivenessEvents(root)) {
2449
+ if (!e.subjectId || !e.actor)
2450
+ continue;
2451
+ const key = `${e.subjectId}::${e.actor}`;
2452
+ let g = groups.get(key);
2453
+ if (!g) {
2454
+ g = { subjectId: String(e.subjectId), actor: String(e.actor), ttlSeconds: 1800, created: String(e.at), updated: String(e.at), events: [] };
2455
+ groups.set(key, g);
2456
+ }
2457
+ g.updated = String(e.at);
2458
+ if (e.type === "claim") {
2459
+ g.ttlSeconds = Number(e.ttlSeconds) || g.ttlSeconds;
2460
+ g.events.push({ id: `c:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
2461
+ }
2462
+ else if (e.type === "heartbeat") {
2463
+ g.events.push({ id: `h:${key}:${e.at}`, claimId: key, status: "verified", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
2464
+ }
2465
+ else if (e.type === "release") {
2466
+ g.events.push({ id: `r:${key}:${e.at}`, claimId: key, status: "revoked", type: "invalidation", actor: g.actor, method: "observation", evidenceIds: [], createdAt: e.at, verifiedAt: e.at });
2467
+ }
2468
+ }
2469
+ const rows = [];
2470
+ for (const g of groups.values()) {
2471
+ if (subjectFilter && g.subjectId !== subjectFilter)
2472
+ continue;
2473
+ const claim = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, surface: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
2474
+ const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
2475
+ rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
2476
+ }
2477
+ if (p.flags.has("json")) {
2478
+ console.log(JSON.stringify(rows, null, 2));
2479
+ return 0;
2480
+ }
2481
+ for (const r of rows)
2482
+ console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
2483
+ return 0;
2484
+ }
2485
+ die("liveness action must be one of: claim | heartbeat | release | status");
2486
+ return 1;
2487
+ }
2488
+ /**
2489
+ * Build a structured explanation for a specific claim.
2490
+ * PURE: report + bundle + id in, structured explanation out.
2491
+ * No fs, no CLI, no .flow-agents paths. Promotable to Surface #171.
2492
+ *
2493
+ * @param report TrustReport from buildTrustReport(bundle) — required for derived status
2494
+ * @param bundle Raw parsed trust.bundle (BundleFile shape)
2495
+ * @param claimId The claim id to explain
2496
+ */
2497
+ export function buildClaimExplanation(report, bundle, claimId) {
2498
+ const reportClaims = Array.isArray(report.claims) ? report.claims : [];
2499
+ const reportClaim = reportClaims.find((c) => c.id === claimId);
2500
+ if (!reportClaim) {
2501
+ return {
2502
+ found: false,
2503
+ status: "unknown",
2504
+ value: "",
2505
+ claimType: "",
2506
+ evidence: [],
2507
+ policy: null,
2508
+ why: { directInputs: [], leafClaims: [], diagnostics: [], transparencyGaps: [], changeRecords: [] },
2509
+ };
2510
+ }
2511
+ const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
2512
+ const bundleClaim = bundleClaims.find((c) => c.id === claimId) ?? reportClaim;
2513
+ const bundlePolicies = Array.isArray(bundle.policies) ? bundle.policies : [];
2514
+ const bundleEvidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
2515
+ // Governing policy — follow verificationPolicyId into bundle.policies[]
2516
+ const verificationPolicyId = typeof bundleClaim.verificationPolicyId === "string" ? bundleClaim.verificationPolicyId : undefined;
2517
+ const rawPolicy = verificationPolicyId ? bundlePolicies.find((p) => p.id === verificationPolicyId) : undefined;
2518
+ const policy = rawPolicy
2519
+ ? {
2520
+ id: String(rawPolicy.id ?? ""),
2521
+ requiredEvidence: Array.isArray(rawPolicy.requiredEvidence) ? rawPolicy.requiredEvidence : [],
2522
+ requiredMethods: Array.isArray(rawPolicy.requiredMethods) ? rawPolicy.requiredMethods : undefined,
2523
+ acceptanceCriteria: Array.isArray(rawPolicy.acceptanceCriteria) ? rawPolicy.acceptanceCriteria : [],
2524
+ reviewAuthority: String(rawPolicy.reviewAuthority ?? ""),
2525
+ }
2526
+ : null;
2527
+ // Evidence enhancement: pull evidence items for this claim, surface the execution block
2528
+ const claimEvidenceItems = bundleEvidence.filter((ev) => ev && ev.claimId === claimId);
2529
+ const evidence = claimEvidenceItems.map((ev) => {
2530
+ const exec = ev.execution && typeof ev.execution === "object" ? ev.execution : null;
2531
+ const execution = exec
2532
+ ? {
2533
+ runner: String(exec.runner ?? exec.label ?? ""),
2534
+ label: String(exec.label ?? exec.runner ?? ""),
2535
+ isError: Boolean(exec.isError ?? (typeof exec.exitCode === "number" && exec.exitCode !== 0)),
2536
+ exitCode: typeof exec.exitCode === "number" ? exec.exitCode : null,
2537
+ }
2538
+ : null;
2539
+ return {
2540
+ evidenceType: String(ev.evidenceType ?? ev.type ?? "unknown"),
2541
+ label: String(ev.label ?? ev.excerptOrSummary ?? ev.sourceRef ?? ev.id ?? ""),
2542
+ execution,
2543
+ passing: execution ? !execution.isError : String(ev.status ?? "") !== "disputed",
2544
+ summary: String(ev.excerptOrSummary ?? ev.summary ?? ev.label ?? ""),
2545
+ };
2546
+ });
2547
+ // Drilldown: extract from report structure (report.transparencyGaps, report.changeRecords)
2548
+ const allGaps = Array.isArray(report.transparencyGaps) ? report.transparencyGaps : [];
2549
+ const allChanges = Array.isArray(report.changeRecords) ? report.changeRecords : [];
2550
+ const transparencyGaps = allGaps.filter((g) => g && g.claimId === claimId);
2551
+ const changeRecords = allChanges.filter((c) => c && c.claimId === claimId);
2552
+ return {
2553
+ found: true,
2554
+ status: String(reportClaim.status ?? "unknown"),
2555
+ value: String(bundleClaim.value ?? reportClaim.value ?? ""),
2556
+ claimType: String(bundleClaim.claimType ?? reportClaim.claimType ?? ""),
2557
+ evidence,
2558
+ policy,
2559
+ why: {
2560
+ directInputs: [], // populated by buildDerivationDrilldown if non-leaf
2561
+ leafClaims: [],
2562
+ diagnostics: [],
2563
+ transparencyGaps,
2564
+ changeRecords,
2565
+ },
2566
+ };
2567
+ }
2568
+ /**
2569
+ * claim <id> <dir>
2570
+ *
2571
+ * Look up a specific claim in the session's trust.bundle and print:
2572
+ * - Derived status and raw value
2573
+ * - Failing evidence items (with execution block: runner, exitCode, isError)
2574
+ * - Governing VerificationPolicy (how-to-verify)
2575
+ * - Derivation drilldown / transparency gaps (why it is in that state)
2576
+ *
2577
+ * --json Emit the structured ClaimExplanation object instead of text.
2578
+ *
2579
+ * Usage: workflow-sidecar claim <claimId> <artifactDir>
2580
+ */
2581
+ async function claimLookup(p) {
2582
+ const claimId = p.positional[0] || die("claim id is required (first positional argument)");
2583
+ const rawDir = p.positional[1] || die("artifact directory is required (second positional argument)");
2584
+ const dir = path.resolve(rawDir);
2585
+ const bundlePath = path.join(dir, "trust.bundle");
2586
+ if (!fs.existsSync(bundlePath)) {
2587
+ process.stderr.write(`[claim] no trust.bundle at ${bundlePath} — run record-evidence first
2588
+ `);
2589
+ return 1;
2590
+ }
2591
+ const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2592
+ const bundleClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
2593
+ const bundleClaim = bundleClaims.find((c) => c.id === claimId);
2594
+ if (!bundleClaim) {
2595
+ const available = bundleClaims.map((c) => c.id).join("\n ");
2596
+ process.stderr.write(`[claim] unknown claim id: ${claimId}
2597
+ Available claim ids:
2598
+ ${available || "(none — bundle has no claims)"}
2599
+ `);
2600
+ return 1;
2601
+ }
2602
+ // Load Surface via tryLoadSurface() (ESM, cached, fail-open pattern)
2603
+ const surface = await tryLoadSurface();
2604
+ if (!surface || typeof surface.buildTrustReport !== "function" || typeof surface.buildDerivationDrilldown !== "function") {
2605
+ process.stderr.write(`[claim] @kontourai/surface unavailable or missing buildTrustReport/buildDerivationDrilldown
2606
+ `);
2607
+ return 0; // fail-open, consistent with gate-review pattern
2608
+ }
2609
+ // Build TrustReport (required — buildDerivationDrilldown needs TrustReport, not TrustBundle)
2610
+ const report = surface.buildTrustReport(bundle);
2611
+ // Build the structured explanation (pure, promotable to #171)
2612
+ const explanation = buildClaimExplanation(report, bundle, claimId);
2613
+ // Enrich the why.directInputs/leafClaims/diagnostics from the drilldown
2614
+ try {
2615
+ const drilldown = surface.buildDerivationDrilldown(report, claimId);
2616
+ if (drilldown) {
2617
+ explanation.why.directInputs = Array.isArray(drilldown.directInputs) ? drilldown.directInputs : [];
2618
+ explanation.why.leafClaims = Array.isArray(drilldown.leafClaims) ? drilldown.leafClaims : [];
2619
+ explanation.why.diagnostics = Array.isArray(drilldown.diagnostics) ? drilldown.diagnostics : [];
2620
+ }
2621
+ }
2622
+ catch {
2623
+ // buildDerivationDrilldown threw (e.g. claim not in report) — proceed without drilldown
2624
+ }
2625
+ if (p.flags.has("json")) {
2626
+ console.log(JSON.stringify(explanation, null, 2));
2627
+ return 0;
2628
+ }
2629
+ // ── Human-readable output ───────────────────────────────────────────────────
2630
+ const lines = [];
2631
+ lines.push(`Claim: ${claimId}`);
2632
+ lines.push(`Status: ${explanation.status} Value: ${explanation.value}`);
2633
+ lines.push(`Type: ${explanation.claimType}`);
2634
+ lines.push("");
2635
+ // Evidence section — failing items are the concrete "why disputed"
2636
+ const failingEvidence = explanation.evidence.filter((ev) => !ev.passing);
2637
+ const allEvidence = explanation.evidence;
2638
+ if (allEvidence.length > 0) {
2639
+ lines.push("Evidence:");
2640
+ for (const ev of allEvidence) {
2641
+ const passMark = ev.passing ? "pass" : "FAIL";
2642
+ const execStr = ev.execution
2643
+ ? ` [runner: ${ev.execution.runner}, exitCode: ${ev.execution.exitCode ?? "?"}, isError: ${ev.execution.isError}]`
2644
+ : "";
2645
+ lines.push(` [${passMark}] ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
2646
+ }
2647
+ if (failingEvidence.length > 0) {
2648
+ lines.push("");
2649
+ lines.push(`Failing evidence (disputed because):`);
2650
+ for (const ev of failingEvidence) {
2651
+ const execStr = ev.execution
2652
+ ? ` ${ev.execution.runner} exited ${ev.execution.exitCode ?? "?"} (isError: ${ev.execution.isError})`
2653
+ : "";
2654
+ lines.push(` ${ev.evidenceType}: ${ev.label || ev.summary}${execStr}`);
2655
+ }
2656
+ }
2657
+ }
2658
+ else {
2659
+ lines.push("Evidence: (none recorded for this claim)");
2660
+ }
2661
+ lines.push("");
2662
+ // Policy section — how-to-verify
2663
+ if (explanation.policy) {
2664
+ const pol = explanation.policy;
2665
+ lines.push(`Governing Policy (${pol.id}):`);
2666
+ lines.push(` requiredEvidence: [${pol.requiredEvidence.join(", ")}]`);
2667
+ if (pol.requiredMethods && pol.requiredMethods.length > 0) {
2668
+ lines.push(` requiredMethods: [${pol.requiredMethods.join(", ")}]`);
2669
+ }
2670
+ lines.push(` acceptanceCriteria: [${pol.acceptanceCriteria.join(" | ")}]`);
2671
+ lines.push(` reviewAuthority: ${pol.reviewAuthority}`);
2672
+ }
2673
+ else {
2674
+ lines.push("Governing Policy: (none — claim has no verificationPolicyId or policy not found in bundle)");
2675
+ }
2676
+ lines.push("");
2677
+ // Why section — derivation drilldown + transparency gaps
2678
+ lines.push("Derivation Drilldown:");
2679
+ if (explanation.why.directInputs.length > 0) {
2680
+ lines.push(` Direct inputs: ${explanation.why.directInputs.length} claim(s)`);
2681
+ for (const inp of explanation.why.directInputs) {
2682
+ const inpStatus = typeof inp.claim === "object" && inp.claim ? String(inp.claim.status ?? "?") : "?";
2683
+ lines.push(` - ${inp.inputClaimId ?? "?"} (status: ${inpStatus})`);
2684
+ }
2685
+ }
2686
+ else {
2687
+ lines.push(" Direct inputs: (none — leaf claim)");
2688
+ }
2689
+ if (explanation.why.leafClaims.length > 0) {
2690
+ lines.push(` Leaf claims: ${explanation.why.leafClaims.length} claim(s)`);
2691
+ }
2692
+ if (explanation.why.diagnostics.length > 0) {
2693
+ lines.push(` Diagnostics: ${explanation.why.diagnostics.length}`);
2694
+ for (const d of explanation.why.diagnostics) {
2695
+ lines.push(` - ${d.type ?? "?"}: ${d.message ?? ""}`);
2696
+ }
2697
+ }
2698
+ if (explanation.why.transparencyGaps.length > 0) {
2699
+ lines.push(` Transparency gaps: ${explanation.why.transparencyGaps.length}`);
2700
+ for (const g of explanation.why.transparencyGaps) {
2701
+ lines.push(` - [${g.severity ?? "?"}] ${g.type ?? "?"}: ${g.message ?? ""}`);
2702
+ }
2703
+ }
2704
+ else {
2705
+ lines.push(" Transparency gaps: (none)");
2706
+ }
2707
+ if (explanation.why.changeRecords.length > 0) {
2708
+ lines.push(` Change records: ${explanation.why.changeRecords.length}`);
2709
+ for (const cr of explanation.why.changeRecords) {
2710
+ lines.push(` - ${cr.action ?? "?"} at ${cr.at ?? cr.createdAt ?? "?"}`);
2711
+ }
2712
+ }
2713
+ console.log(lines.join("\n"));
2714
+ return 0;
2715
+ }
2716
+ // ─────────────────────────────────────────────────────────────────────────────
874
2717
  async function main() {
875
2718
  const p = parseArgs(process.argv.slice(2));
876
2719
  if (!p.command)
877
2720
  die("workflow-sidecar command is required");
878
- const lockRoot = ["ensure-session", "current", "dogfood-pass"].includes(p.command) ? path.resolve(opt(p, "artifact-root", ".flow-agents")) : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
2721
+ const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) ? path.resolve(opt(p, "artifact-root", ".flow-agents")) : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
879
2722
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
880
2723
  switch (p.command) {
881
2724
  case "ensure-session": return ensureSession(p);
@@ -883,12 +2726,20 @@ async function main() {
883
2726
  case "record-agent-event": return recordAgentEvent(p);
884
2727
  case "init-plan": return initPlan(p);
885
2728
  case "record-evidence": return recordEvidence(p);
2729
+ case "record-gate-claim": return recordGateClaim(p);
886
2730
  case "advance-state": return advanceState(p);
887
2731
  case "record-critique": return recordCritique(p);
888
2732
  case "import-critique": return importCritique(p);
889
2733
  case "record-release": return recordRelease(p);
890
2734
  case "record-learning": return recordLearning(p);
891
2735
  case "dogfood-pass": return dogfoodPass(p);
2736
+ case "gate-review": return gateReview(p);
2737
+ case "render-trust-panel": return renderTrustPanel(p);
2738
+ case "trust-mcp": return trustMcp(p);
2739
+ case "liveness": return liveness(p);
2740
+ case "claim": return claimLookup(p);
2741
+ case "seal-checkpoint": return sealCheckpoint(p);
2742
+ case "publish-delivery": return publishDeliveryCmd(p);
892
2743
  default: die(`unknown command: ${p.command}`);
893
2744
  }
894
2745
  });