@kontourai/flow-agents 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+ //
3
+ // Shared bundle-shape classification/divergence-construction, extracted from
4
+ // scripts/ci/trust-reconcile.js so a local, pre-push preflight (issue #356) can reuse
5
+ // EXACTLY the same shape checks CI enforces, rather than risk a forked copy that
6
+ // silently drifts from what trust-reconcile.js actually does (the historical failure
7
+ // mode this module exists to close off — see command-log-chain.js for the identical
8
+ // rationale applied to the hash-chain/laundering primitives).
9
+ //
10
+ // This module is SHAPE-only: it classifies a bundle's own claims/evidence and builds
11
+ // the `issues[]` entries that do not require a fresh CI command re-run (no `runCommand`,
12
+ // no manifest command execution). The ACTUAL fresh-run comparison for reconcilable
13
+ // command claims (`ciResult.passed`) stays in trust-reconcile.js, since that requires a
14
+ // live CI/local command execution a local preflight must not perform.
15
+ //
16
+ // trust-reconcile.js requires this module instead of defining these functions inline —
17
+ // see its own comments at the require() site and the (former) location of
18
+ // classifyBundleClaims for the extraction history.
19
+
20
+ // hasLaunderingOperator is imported (not re-implemented) so this module and
21
+ // scripts/ci/trust-reconcile.js apply the identical exit-code-mask heuristic.
22
+ const { hasLaunderingOperator } = require('./command-log-chain.js');
23
+
24
+ /**
25
+ * Classify a trust.bundle's claims into: reconcilable command claims (test_output +
26
+ * execution.label), session-local claims (attestation/observation/citation), never-captured
27
+ * or unbacked command claims (not-run divergence), and command-backed claims carrying a
28
+ * waiver (waiver-on-command divergence). Returns
29
+ * { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand }.
30
+ *
31
+ * Source of truth: evidence[].execution.label is the command string recorded at capture time.
32
+ * evidence[].passing (normalized) means the agent claimed this passed. `claim.status` is NOT
33
+ * trusted here — the caller re-derives it CI-side (see derive-claim-status.mjs / finding-3).
34
+ *
35
+ * WS8 iteration-2 hardening:
36
+ * - finding 1: ANY pass-asserting claim whose evidence is `evidenceType: test_output`
37
+ * (Surface's default when unset) but which did NOT reconcile — i.e. it has no
38
+ * manifest-matchable execution.label — is a divergence, NOT session-local. A test_output
39
+ * claim either reconciles against the manifest or is a divergence; it is never accepted on
40
+ * self-reported status. (Previously only the literal claimType `workflow.check.command`
41
+ * was guarded, so a fabricated kind:"test" claim with no command slipped through.)
42
+ * - finding 4: a command-backed (test_output) claim carrying a waiver is a divergence — a
43
+ * command-backed check reconciles against CI or fails; it cannot be waived.
44
+ */
45
+ function classifyBundleClaims(bundle) {
46
+ const evidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
47
+ const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
48
+
49
+ const claimById = new Map();
50
+ for (const c of claims) if (c && c.id) claimById.set(c.id, c);
51
+
52
+ // Evidence indexing. A missing evidenceType defaults to test_output for backward
53
+ // compatibility with pre-classification bundles (same default classifyEvidence uses).
54
+ const claimHasLabeledTestOutput = new Set(); // test_output evidence WITH an execution.label
55
+ const claimHasTestOutputEvidence = new Set(); // ANY test_output evidence (label or not)
56
+ // WS8 iteration-4 (converged finding): the session-local (non-test_output) evidenceType per
57
+ // claim, so the reconciler can name it on the loud ATTESTED marker below — a fabricated
58
+ // human_attestation/attestation/external claim with no --command is otherwise
59
+ // indistinguishable, in the reconciler's own output, from a genuinely re-runnable check.
60
+ const claimEvidenceType = new Map();
61
+ for (const ev of evidence) {
62
+ if (!ev || !ev.claimId) continue;
63
+ const evType = ev.evidenceType || 'test_output';
64
+ if (evType !== 'test_output') {
65
+ if (!claimEvidenceType.has(ev.claimId)) claimEvidenceType.set(ev.claimId, evType);
66
+ continue;
67
+ }
68
+ claimHasTestOutputEvidence.add(ev.claimId);
69
+ if (ev.execution && ev.execution.label) claimHasLabeledTestOutput.add(ev.claimId);
70
+ }
71
+
72
+ // finding 4: a command-backed (test_output-evidence) claim that also carries a waiver.
73
+ const waiverOnCommand = [];
74
+ for (const c of claims) {
75
+ if (!c || !c.id) continue;
76
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
77
+ if (waiver && typeof waiver === 'object' && claimHasTestOutputEvidence.has(c.id)) {
78
+ waiverOnCommand.push({ claimId: c.id, claimType: String(c.claimType || ''), subject: c.subjectId || c.fieldOrBehavior || c.id });
79
+ }
80
+ }
81
+
82
+ // (A) Reconcilable claimed-passes: evidence items that are test_output (CI-reconcilable),
83
+ // carry an execution.label, and assert pass. Session-local evidenceTypes
84
+ // (crawl_observation, human_attestation, attestation, policy_rule, source_excerpt,
85
+ // document_citation, calculation_trace) are NOT reconciled per-command — they are handled
86
+ // by the session-local/waiver path below.
87
+ const reconcilable = [];
88
+ const reconcilableClaimIds = new Set();
89
+ const seen = new Set();
90
+ for (const ev of evidence) {
91
+ if (!ev || !ev.execution || !ev.execution.label) continue;
92
+ if (!isPassingValue(ev.passing)) continue;
93
+ const evType = ev.evidenceType || 'test_output';
94
+ if (evType !== 'test_output') continue; // session-local — not CI-reconcilable
95
+ const cmd = normalizeCmd(ev.execution.label);
96
+ if (!cmd) continue;
97
+ reconcilableClaimIds.add(ev.claimId);
98
+ if (seen.has(cmd)) continue;
99
+ seen.add(cmd);
100
+ const claim = claimById.get(ev.claimId);
101
+ reconcilable.push({ cmd, claimId: ev.claimId, evId: ev.id, claimType: claim ? String(claim.claimType || '') : '' });
102
+ }
103
+
104
+ // (B) Session-local claims, never-captured command claims, and unreconciled test_output.
105
+ const sessionLocal = [];
106
+ const noEvidenceCommand = [];
107
+ const seenClaims = new Set();
108
+ for (const c of claims) {
109
+ if (!c || !c.id || typeof c.claimType !== 'string') continue;
110
+ // #267/#282: a superseded critique write is HISTORY — excluded from reconcile evaluation so a
111
+ // resolved session converges (a fail critique that a later same-reviewer pass superseded no
112
+ // longer blocks). Scoped to NON-test_output claims so a command-backed claim can never launder
113
+ // a real failure by carrying superseded_by — a test_output claim always reconciles or diverges.
114
+ if (c.metadata && typeof c.metadata === 'object' && c.metadata.superseded_by && !claimHasTestOutputEvidence.has(c.id)) continue;
115
+ if (reconcilableClaimIds.has(c.id)) continue; // handled by (A)
116
+ if (seenClaims.has(c.id)) continue;
117
+ const status = String(c.status || '');
118
+ const assertsPass = isPassingValue(c.value) || status === 'verified' || status === 'assumed';
119
+ const isFailing = status === 'disputed' || status === 'rejected';
120
+ if (!assertsPass && !isFailing) continue; // pending/unknown non-asserting — ignore (as before)
121
+ seenClaims.add(c.id);
122
+
123
+ // finding 1: a pass-asserting claim backed by test_output evidence that did NOT reconcile
124
+ // (it has test_output evidence but no manifest-matchable execution.label — otherwise it
125
+ // would be in bucket A) is a not-run divergence. A test_output claim reconciles against the
126
+ // manifest or it is a divergence — it is NEVER accepted as session-local on self-report.
127
+ if (assertsPass && claimHasTestOutputEvidence.has(c.id)) {
128
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
129
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}]`, claimId: c.id, claimType: c.claimType, reason: 'test_output-unreconciled' });
130
+ continue;
131
+ }
132
+
133
+ // A workflow.check.command claim with no captured (labeled) evidence is a never-captured
134
+ // claimed pass — not-run divergence (anti-gaming teeth preserved).
135
+ if (assertsPass && c.claimType === 'workflow.check.command' && !claimHasLabeledTestOutput.has(c.id)) {
136
+ const rawCmd = normalizeCmd(c.fieldOrBehavior || c.value || '');
137
+ noEvidenceCommand.push({ cmd: rawCmd || `[claim:${c.id}:${c.claimType}]`, claimId: c.id, claimType: c.claimType, reason: 'no-evidence-command' });
138
+ continue;
139
+ }
140
+
141
+ const waiver = (c.metadata && typeof c.metadata === 'object') ? c.metadata.waiver : undefined;
142
+ sessionLocal.push({
143
+ claimId: c.id,
144
+ claimType: c.claimType,
145
+ assertedStatus: status,
146
+ value: c.value,
147
+ waiver: (waiver && typeof waiver === 'object') ? waiver : null,
148
+ subject: c.subjectId || c.fieldOrBehavior || c.id,
149
+ evidenceType: claimEvidenceType.get(c.id) || 'unknown',
150
+ });
151
+ }
152
+
153
+ return { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand };
154
+ }
155
+
156
+ /** Normalize a command string: collapse whitespace, trim. (Mirrors trust-reconcile.js's own.) */
157
+ function normalizeCmd(cmd) {
158
+ return String(cmd || '').replace(/\s+/g, ' ').trim();
159
+ }
160
+
161
+ /**
162
+ * Normalize ev.passing to a boolean.
163
+ * Treats true / 1 / "true" / "pass" as passing.
164
+ * Prevents a claim from dodging reconciliation via a non-boolean value.
165
+ */
166
+ function isPassingValue(v) {
167
+ return v === true || v === 1 || v === 'true' || v === 'pass';
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Pure shape-level divergence ("issue") construction.
172
+ //
173
+ // Each function below takes already-computed classification inputs (the buckets
174
+ // classifyBundleClaims returns, plus a resolved manifest / derived-status map) and
175
+ // returns an issues[] array structurally IDENTICAL in shape (`{ type, cmd?, message }`)
176
+ // to what trust-reconcile.js's Step 2 block pushes inline. None of these functions
177
+ // execute a command or otherwise perform a fresh CI run — they are pure, local, and fast.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * finding 4 (server-side): a command-backed (test_output-evidence) claim carrying a
182
+ * waiver is a divergence — a command-backed check reconciles against CI or fails; it
183
+ * cannot be waived away.
184
+ */
185
+ function waiverOnCommandIssues(waiverOnCommand) {
186
+ const issues = [];
187
+ for (const { claimId, claimType, subject } of waiverOnCommand || []) {
188
+ issues.push({
189
+ type: 'waiver-on-command-check',
190
+ message: `trust divergence: claim '${claimId}' (${subject}, claimType: ${claimType}) carries a waiver but is backed by test_output evidence — a command-backed check reconciles against CI or fails and cannot be waived`,
191
+ });
192
+ }
193
+ return issues;
194
+ }
195
+
196
+ /**
197
+ * not-run divergences: never-captured command claims (no evidence) AND test_output
198
+ * claims that did not reconcile (no manifest-matchable execution.label).
199
+ */
200
+ function noEvidenceCommandIssues(noEvidenceCommand) {
201
+ const issues = [];
202
+ for (const { cmd, claimId, claimType, reason } of noEvidenceCommand || []) {
203
+ const message = reason === 'test_output-unreconciled'
204
+ ? `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass with test_output evidence but has no manifest-matched execution.label — a test_output claim must reconcile against the manifest or it is a divergence (never accepted as session-local)`
205
+ : `trust divergence: claim '${claimId}' (claimType: ${claimType}) asserts pass but has no supporting evidence item — command never captured`;
206
+ issues.push({ type: 'not-run', cmd, message });
207
+ }
208
+ return issues;
209
+ }
210
+
211
+ /**
212
+ * Manifest-membership subset of the `reconcilable` loop: ONLY the "not in the reconcile
213
+ * manifest" `not-run` case, plus the laundering check (via the shared hasLaunderingOperator).
214
+ * The ACTUAL fresh-run comparison (`ciResult.passed`) is NOT here — it requires a live CI/
215
+ * local command execution the preflight must not perform, and stays in trust-reconcile.js.
216
+ *
217
+ * Returns { issues, unresolved } where `unresolved` is the subset of `reconcilable` entries
218
+ * that passed the laundering + manifest-membership checks and therefore DO require a fresh
219
+ * CI run to fully reconcile — callers that need full parity (trust-reconcile.js) continue
220
+ * from there; callers that are shape-only (the local preflight) simply do not resolve them
221
+ * further and treat "manifest-matched, not laundered" as shape-clean.
222
+ */
223
+ function reconcilableManifestIssues(reconcilable, manifestByCmd) {
224
+ const issues = [];
225
+ const unresolved = [];
226
+ for (const entry of reconcilable || []) {
227
+ const { cmd } = entry;
228
+ const normalCmd = normalizeCmd(cmd);
229
+
230
+ // (a) Laundering operator check — must come first (most specific signal).
231
+ if (hasLaunderingOperator(cmd)) {
232
+ issues.push({
233
+ type: 'laundering',
234
+ cmd,
235
+ message: `trust divergence: agent claimed '${cmd}' passed; command contains exit-code-laundering operator (|| ... / ; true / ; exit 0 / etc.)`,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ // A test_output claim MUST name a manifest (required-lane) command. An agent
241
+ // cannot self-label an arbitrary command test_output to dodge the manifest.
242
+ const manifestEntry = manifestByCmd.get(normalCmd);
243
+ if (!manifestEntry) {
244
+ issues.push({
245
+ type: 'not-run',
246
+ cmd,
247
+ message: `trust divergence: agent claimed '${cmd}' passed; command is not in the reconcile manifest — a test_output claim must name a manifest/required-lane command (CI cannot self-declare an arbitrary command)`,
248
+ });
249
+ continue;
250
+ }
251
+
252
+ unresolved.push({ ...entry, manifestEntry });
253
+ }
254
+ return { issues, unresolved };
255
+ }
256
+
257
+ /**
258
+ * Session-local claims: not CI-reconcilable, but NOT a pass bypass. Each must either
259
+ * (a) carry a loud, justified waiver, or (b) resolve a real CI-RE-DERIVED `verified`
260
+ * status. WS8 iteration-2 hardening:
261
+ * - finding 3: the status used here is RE-DERIVED CI-side, never the self-reported
262
+ * claim.status. A mismatch is a `status-misassertion` divergence.
263
+ * - finding 2: `assumed` alone is NO LONGER a silent pass. `assumed` is acceptable
264
+ * ONLY with a waiver (printed as a loud WAIVED line by the caller). An unwaived
265
+ * `assumed` claim is an `unwaived-assumed` divergence (restores pre-WS8 semantics
266
+ * where `assumed` alone never satisfied assertsPass).
267
+ *
268
+ * Q1/iteration-1-F1 (extraction-granularity + caller-controlled mode): `derivedStatus` is a
269
+ * `Map<string,string|null>|null` — the SAME value trust-reconcile.js's `deriveClaimStatuses()`
270
+ * produces (shells out to derive-claim-status.mjs, local-only, no CI command execution).
271
+ * `opts.onUnderivable` makes the `derivedStatus === null` behavior an EXPLICIT caller choice —
272
+ * there is no silent default that fails open:
273
+ * - `'fail'` (DEFAULT — the safe/original CI behavior; a caller that forgets `opts` never
274
+ * fails open): when `derivedStatus` is null, EVERY session-local pass-asserting claim
275
+ * becomes a `status-underivable` divergence (verbatim pre-#356 message + `continue`) —
276
+ * we never fall back to trusting the bundle's own status. `scripts/ci/trust-reconcile.js`
277
+ * MUST use this mode; it is CI's trust anchor.
278
+ * - `'reduce'` (LOCAL-PREFLIGHT-ONLY opt-in): when `derivedStatus` is null, DEGRADE to a
279
+ * documented reduced-coverage mode — status-misassertion/status-underivable checks are
280
+ * skipped entirely (nothing to re-derive against), but the waiver/unwaived-assumed/
281
+ * session-local-failed/unwaived-session-local checks still run against the claim's own
282
+ * self-reported `assertedStatus`. Only `src/cli/workflow-sidecar.ts`'s local
283
+ * `runReconcilePreflight` opts into this (and surfaces the reduced coverage to the user via
284
+ * a warning) — CI must never reach this branch.
285
+ * When `derivedStatus` is non-null, both modes behave identically (full parity with CI).
286
+ *
287
+ * Returns { issues, attestedCount, logEvents } — attestedCount mirrors trust-reconcile.js's
288
+ * own "N attested claim(s) accepted without independent verification" summary line;
289
+ * logEvents is the ordered list of WAIVED/ATTESTED terminal classifications (F3, iteration-1)
290
+ * so a caller's stdout narrative (e.g. trust-reconcile.js's WAIVED/ATTESTED log lines) is
291
+ * driven by this single classification instead of a parallel re-derivation.
292
+ */
293
+ function sessionLocalShapeIssues(sessionLocal, derivedStatus, opts) {
294
+ const onUnderivable = (opts && opts.onUnderivable) || 'fail';
295
+ const issues = [];
296
+ let attestedCount = 0;
297
+ // F3 (iteration-1): single source of truth for the WAIVED/ATTESTED classification, so
298
+ // trust-reconcile.js's stdout narrative loop consumes this instead of re-deriving its own
299
+ // (previously parallel, driftable) copy. Only populated when a claim reaches the WAIVED or
300
+ // ATTESTED terminal below (never for issues) — callers that don't log can ignore it.
301
+ const logEvents = [];
302
+
303
+ for (const { claimId, claimType, assertedStatus, waiver, subject, evidenceType } of sessionLocal || []) {
304
+ let status;
305
+ if (derivedStatus) {
306
+ // finding 3: re-derive; never trust the asserted status.
307
+ const derived = derivedStatus.get(claimId);
308
+ if (derived === undefined || derived === null) {
309
+ issues.push({
310
+ type: 'status-underivable',
311
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) could not be re-derived CI-side from the bundle's own evidence/events/policies — refusing to trust its self-reported status '${assertedStatus || 'unknown'}' (fail-closed)`,
312
+ });
313
+ continue;
314
+ }
315
+ if (derived !== assertedStatus) {
316
+ issues.push({
317
+ type: 'status-misassertion',
318
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI re-derivation from the bundle's own evidence/events/policies yields '${derived}' — the reconciler does not trust self-reported claim.status`,
319
+ });
320
+ continue;
321
+ }
322
+ status = derived;
323
+ } else if (onUnderivable === 'reduce') {
324
+ // Reduced-coverage mode (derivedStatus === null, explicit local-preflight opt-in): trust
325
+ // the self-reported status for the remaining shape checks only. status-misassertion/
326
+ // status-underivable are, by definition, not checkable without a derivation source —
327
+ // documented gap, not a bug.
328
+ status = assertedStatus;
329
+ } else {
330
+ // Fail-closed mode (default; CI): restores the pre-#356 inline behavior verbatim — we
331
+ // never fall back to trusting a self-reported status.
332
+ issues.push({
333
+ type: 'status-underivable',
334
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts status '${assertedStatus || 'unknown'}' but CI-side re-derivation is unavailable — refusing to trust a self-reported status (fail-closed)`,
335
+ });
336
+ continue;
337
+ }
338
+
339
+ if (status === 'disputed' || status === 'rejected') {
340
+ issues.push({
341
+ type: 'session-local-failed',
342
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status '${status}' — a failing/rejected claim blocks (session-local classification is not a pass bypass)`,
343
+ });
344
+ continue;
345
+ }
346
+ // finding 2: a waiver is the ONLY way an `assumed` (or otherwise non-`verified`)
347
+ // session-local claim passes. `verified` still passes on its own re-derived status.
348
+ if (waiver && waiver.reason && waiver.approved_by) {
349
+ logEvents.push({ kind: 'waived', claimId, claimType, subject, evidenceType, status, waiver });
350
+ continue; // WAIVED — caller may log this loudly; not an issue.
351
+ }
352
+ if (status === 'verified') {
353
+ attestedCount++;
354
+ logEvents.push({ kind: 'attested', claimId, claimType, subject, evidenceType, status });
355
+ continue; // ATTESTED (not independently verifiable at L0) — caller may log; not an issue.
356
+ }
357
+ if (status === 'assumed') {
358
+ issues.push({
359
+ type: 'unwaived-assumed',
360
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) has re-derived status 'assumed' but carries no waiver — 'assumed' alone is not a pass; it requires a documented waiver (--accepted-gap-reason/--waived-by) to be accepted`,
361
+ });
362
+ continue;
363
+ }
364
+ issues.push({
365
+ type: 'unwaived-session-local',
366
+ message: `trust divergence: session-local claim '${claimId}' (claimType: ${claimType}) asserts pass with re-derived status '${status || 'unknown'}' but has no waiver and no CI-re-derived verified status`,
367
+ });
368
+ }
369
+
370
+ return { issues, attestedCount, logEvents };
371
+ }
372
+
373
+ module.exports = {
374
+ classifyBundleClaims,
375
+ normalizeCmd,
376
+ isPassingValue,
377
+ waiverOnCommandIssues,
378
+ noEvidenceCommandIssues,
379
+ reconcilableManifestIssues,
380
+ sessionLocalShapeIssues,
381
+ };
@@ -120,6 +120,7 @@ function loadActorIdentityHelper(): {
120
120
  isUnresolvedActor: (actor: string) => boolean;
121
121
  sanitizeSegment: (value: unknown) => string;
122
122
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
123
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
123
124
  } {
124
125
  const _req = createRequire(import.meta.url);
125
126
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
@@ -129,6 +130,7 @@ function loadActorIdentityHelper(): {
129
130
  isUnresolvedActor: (actor: string) => boolean;
130
131
  sanitizeSegment: (value: unknown) => string;
131
132
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
133
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
132
134
  };
133
135
  }
134
136
 
@@ -199,7 +201,16 @@ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: str
199
201
  const helper = loadActorIdentityHelper();
200
202
  const resolved = helper.resolveActor(process.env);
201
203
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
202
- return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
204
+ // #398: reconstruct the SAME struct resolveActor serialized for a CI actor, mirroring
205
+ // resolveEnsureSessionActor (workflow-sidecar.ts) via the shared detectCiActor. Without this the
206
+ // else-branch would write `record.actor = {runtime:"unknown", session_id:<the whole triple>}` for a
207
+ // CI session — actor_key stays correct (so no false-block), but record.actor is malformed and the
208
+ // audit-trail / `assignment-provider status` output for CI sessions would be corrupt.
209
+ const ci = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
210
+ const actor: ActorStruct = ci && ci.session_id
211
+ ? { runtime: ci.runtime, session_id: ci.session_id, host: os.hostname(), human: null }
212
+ : { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
213
+ return { actor, actorKey: resolved.actor };
203
214
  }
204
215
 
205
216
  export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
@@ -559,31 +570,129 @@ function claimLocalFile(argv: string[]): number {
559
570
  return 0;
560
571
  }
561
572
 
562
- function releaseLocalFile(argv: string[]): number {
563
- const args = parseArgs(argv);
564
- const provider = flagString(args.flags, "provider", "local-file");
565
- if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
566
- const artifactRoot = requireFlag(args, "artifact-root");
567
- const subjectId = requireFlag(args, "subject-id");
568
- const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
569
- const reason = flagString(args.flags, "reason") ?? "released";
573
+ /**
574
+ * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
575
+ * withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
576
+ * release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
577
+ * logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
578
+ * rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
579
+ * below) is now a thin parse-args/print-envelope shell around this, mirroring the
580
+ * performLocalSupersede/supersedeLocalFile extraction shape exactly.
581
+ *
582
+ * Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
583
+ * Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
584
+ * keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
585
+ *
586
+ * - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
587
+ * record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
588
+ * "no active claim to release". This is the one deliberate idempotency change vs today's
589
+ * releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
590
+ * - `releasedBy` is provided and does not match the existing record's holder: never force-release
591
+ * a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
592
+ * (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
593
+ * `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
594
+ * (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
595
+ * path (status/effective-state) and this write path (release) must use the identical
596
+ * canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
597
+ * bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
598
+ * `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
599
+ * fail to release here because the releaser's canonical key was compared against the wrong
600
+ * (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
601
+ * calls unconditionally — as a prior version of this function did — is NOT correct for override
602
+ * actors and reintroduces the exact #291 seam on the release path.
603
+ *
604
+ * Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
605
+ * `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
606
+ * ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
607
+ * an `actor_key`-stamped record would have its ownership compared as
608
+ * `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
609
+ * can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
610
+ * check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
611
+ * than allowed to silently no-op or wrongly refuse.
612
+ */
613
+ export function performLocalRelease(
614
+ artifactRoot: string,
615
+ subjectId: string,
616
+ releasedBy: ActorStruct | null,
617
+ opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
618
+ ): AssignmentClaimRecord | null {
619
+ const helper = loadActorIdentityHelper();
620
+ const reason = opts.reason ?? "released";
621
+ const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
570
622
 
571
623
  // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
572
624
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
573
- return withSubjectLock(artifactRoot, subjectId, (): number => {
625
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
574
626
  const existing = readLocalRecord(artifactRoot, subjectId);
575
- if (!existing || existing.status !== "claimed") throw new Error(`no active claim to release for subject: ${subjectId}`);
627
+ if (!existing || existing.status !== "claimed") {
628
+ if (tolerateNoActiveClaim) return null;
629
+ throw new Error(`no active claim to release for subject: ${subjectId}`);
630
+ }
631
+
632
+ if (releasedBy) {
633
+ // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
634
+ // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
635
+ // ownership — see this function's doc comment. This is the ONLY combination that fires: it
636
+ // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
637
+ // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
638
+ // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
639
+ if (!opts.actorKey && existing.actor_key) {
640
+ if (tolerateNoActiveClaim) {
641
+ console.error(
642
+ `[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`,
643
+ );
644
+ return null;
645
+ }
646
+ throw new Error(
647
+ "performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records",
648
+ );
649
+ }
650
+
651
+ // AC6: never force-release a claim held by a different actor. Mirrors
652
+ // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
653
+ // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
654
+ // string, present on records written by the fixed performLocalClaim/performLocalSupersede
655
+ // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
656
+ // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
657
+ // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
658
+ // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
659
+ // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
660
+ // would compare the bare actor_key form against a re-derived triple form for an
661
+ // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
662
+ // seam, relocated to this write path).
663
+ const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
664
+ const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
665
+ if (holderActorKey !== releasedByActorKey) {
666
+ if (tolerateNoActiveClaim) return null;
667
+ throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
668
+ }
669
+ }
670
+
576
671
  const record: AssignmentClaimRecord = {
577
672
  ...existing,
673
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
578
674
  status: "released",
579
675
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
580
676
  };
581
677
  writeLocalRecord(artifactRoot, subjectId, record);
582
- console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
583
- return 0;
678
+ return record;
584
679
  });
585
680
  }
586
681
 
682
+ function releaseLocalFile(argv: string[]): number {
683
+ const args = parseArgs(argv);
684
+ const provider = flagString(args.flags, "provider", "local-file");
685
+ if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
686
+ const artifactRoot = requireFlag(args, "artifact-root");
687
+ const subjectId = requireFlag(args, "subject-id");
688
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
689
+ const reason = flagString(args.flags, "reason") ?? "released";
690
+
691
+ const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
692
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
693
+ return 0;
694
+ }
695
+
587
696
  /**
588
697
  * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
589
698
  * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's