@ngockhoale/ukit 3.3.2 → 3.4.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 (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -18,8 +18,9 @@
18
18
  // VERDICT: approved | approved_minor | changes_requested
19
19
 
20
20
  import fs from 'node:fs';
21
+ import path from 'node:path';
21
22
  import process from 'node:process';
22
- import { pathToFileURL } from 'node:url';
23
+ import { fileURLToPath } from 'node:url';
23
24
 
24
25
  const SEVERITIES = ['critical', 'important', 'minor'];
25
26
 
@@ -169,6 +170,23 @@ function main() {
169
170
  process.exit(0);
170
171
  }
171
172
 
172
- if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
173
+ const isMainModule = (() => {
174
+ try {
175
+ const invoked = process.argv[1] ?? '';
176
+ if (!invoked) return false;
177
+ const self = path.resolve(fileURLToPath(import.meta.url));
178
+ const target = path.resolve(invoked);
179
+ if (self === target) return true;
180
+ // Installed mirrors may be reached through a symlinked directory (e.g.
181
+ // .codex/ukit -> ../.claude/ukit): realpath resolves the link, basename
182
+ // equality keeps same-named unrelated scripts from matching.
183
+ return path.basename(invoked) === 'review-panel-aggregate.mjs'
184
+ && fs.realpathSync(target) === self;
185
+ } catch {
186
+ return false;
187
+ }
188
+ })();
189
+
190
+ if (isMainModule) {
173
191
  main();
174
192
  }
@@ -0,0 +1,376 @@
1
+ // review-policy.mjs — parity-locked mirror of src/index/reviewPolicy.js
2
+ // (BL-015 / SPEC FR-003 / ARCH §Review Decision Policy). Mirrors never import
3
+ // src/; tests/consistency/reviewPolicyParity.test.js locks the export surface
4
+ // and asserts identical verdicts on shared fixtures. Keep logic identical.
5
+ //
6
+ // The evaluator answers "is one more verification round needed?" at checkpoints
7
+ // — playbook step boundaries and the pre-Stop gate (stop-coordinator.mjs) —
8
+ // from checkable signals only. It is the TRIGGER for the existing review lane
9
+ // (`review-verdict.mjs` + `review-panel-aggregate.mjs`, GAP M05 reuse), never a
10
+ // new reviewer and never a second gate.
11
+ // SPEC FR-003 / ARCH §Review Decision Policy).
12
+ //
13
+ // The evaluator answers "is one more verification round needed?" at checkpoints
14
+ // — playbook step boundaries and the pre-Stop gate (stop-coordinator.mjs) —
15
+ // from checkable signals only. It is the TRIGGER for the existing review lane
16
+ // (`review-verdict.mjs` + `review-panel-aggregate.mjs`, GAP M05 reuse), never a
17
+ // new reviewer and never a second gate: consumers record the verdict as an
18
+ // advisory while the completion gate stays the single Stop authority.
19
+ //
20
+ // Contract:
21
+ // evaluateReviewPolicy({signals, doneCriteria, evidenceClasses, fixLoopCount,
22
+ // round, cap, escalationDepth?, projectRoot?, appendDecisionReceipt?})
23
+ // → {action, namedCheck?, signalsHit[], rationale, reviewRounds,
24
+ // verified[], notVerified[], missingEvidence[]}
25
+ //
26
+ // action ∈ REVIEW_POLICY_ACTIONS — exactly five:
27
+ // 'finish' stop; done predicates evidenced, no suspicion.
28
+ // 'run-targeted-check' name the command/surface + predicate to satisfy.
29
+ // 'independent-review' name what the reviewer must verify; the caller
30
+ // bundles {original request, diff, context files,
31
+ // run evidence} for the review-verdict lane — never
32
+ // the agent's summary alone (SPEC FR-004d).
33
+ // 'escalate' name the deeper lane/question (unic-decision
34
+ // laneDeepening/verificationDepth family) or the
35
+ // human authorization boundary. Terminal hand-off:
36
+ // it is NOT another round, so the cap never traps it.
37
+ // 'inconclusive' honest stop: report verified-vs-not, name the
38
+ // missing evidence. Round cap + timeout land here —
39
+ // never an infinite block.
40
+ //
41
+ // Safety boundary (ARCH §Safety boundary): no self-declared confidence exists
42
+ // in the input contract — keys like `confidence`/`statedConfidence` are ignored
43
+ // by design, so identical signal sets decide identically whether or not the
44
+ // agent narrates certainty.
45
+ //
46
+ // Calibration (ARCH §Calibration plan): when a caller supplies `projectRoot` +
47
+ // an `appendDecisionReceipt` implementation (execution-ledger.mjs signature,
48
+ // consumed unchanged), every evaluation emits one `kind:'review-policy'`
49
+ // receipt carrying action + namedCheck + signalsHit + round so decision↔outcome
50
+ // joins can measure unnecessary-review and escaped-defect rates.
51
+ //
52
+ // Parity: tests/consistency/reviewPolicyParity.test.js locks this module and
53
+ // the installed mirror to identical exports and identical verdicts.
54
+
55
+ import { resolveCrossCheckDepth } from './cross-check-matrix.mjs';
56
+
57
+ // Exactly five actions — the closed enum consumers may switch on.
58
+ export const REVIEW_POLICY_ACTIONS = Object.freeze([
59
+ 'finish',
60
+ 'run-targeted-check',
61
+ 'independent-review',
62
+ 'escalate',
63
+ 'inconclusive',
64
+ ]);
65
+
66
+ // Action severity for multi-signal precedence and the escalationDepth bias.
67
+ // A higher-severity fired signal wins the action; the namedCheck travels with
68
+ // the winning signal.
69
+ const ACTION_SEVERITY = Object.freeze({
70
+ finish: 0,
71
+ 'run-targeted-check': 1,
72
+ 'independent-review': 2,
73
+ escalate: 3,
74
+ inconclusive: 0,
75
+ });
76
+
77
+ // Suspicion signal → action the policy fires. 'targeted' resolves to
78
+ // 'run-targeted-check', 'review' to 'independent-review'.
79
+ const SIGNAL_ACTION = Object.freeze({
80
+ targeted: 'run-targeted-check',
81
+ review: 'independent-review',
82
+ escalate: 'escalate',
83
+ });
84
+
85
+ // The 8-signal suspicion catalogue (ARCH §Suspicion-signal catalogue), kept as
86
+ // data so calibration + docs share one list. `check` names the exact check the
87
+ // action demands — command/surface + predicate, or what the reviewer verifies.
88
+ export const SUSPICION_SIGNAL_CATALOGUE = Object.freeze([
89
+ {
90
+ id: 'unchecked-request-details',
91
+ fires: 'targeted',
92
+ check: 'cross-check every request detail against the diff — each stated requirement must have a corresponding hunk or an explicit, evidenced skip',
93
+ },
94
+ {
95
+ id: 'wider-than-goal',
96
+ fires: 'review',
97
+ check: 'independent reviewer verifies each out-of-scope hunk is either required for the stated goal or reverted',
98
+ },
99
+ {
100
+ id: 'unwired-component',
101
+ fires: 'targeted',
102
+ check: 'verify the component is reachable in the usage flow — import/callsite present + render/import observation, not just a file on disk',
103
+ },
104
+ {
105
+ id: 'claimed-verification',
106
+ fires: 'targeted',
107
+ check: 're-run the claimed verification and require verbatim captured output — a claimed pass with no output is not evidence',
108
+ },
109
+ {
110
+ id: 'impl-detail-tests-only',
111
+ fires: 'targeted',
112
+ check: 'run one concrete behavioral input→output case per public entry point — tests asserting internals cannot catch user-visible failure',
113
+ },
114
+ {
115
+ id: 'tool-error-storm',
116
+ fires: 'review',
117
+ check: 'independent reviewer checks whether the unexplained tool errors mask a real (often environment-dependent) defect',
118
+ },
119
+ {
120
+ id: 'fix-loop-same-fingerprint',
121
+ fires: 'escalate',
122
+ check: 'fire the unic-decision laneDeepening/verificationDepth question → deeper debug lane (runtime-forensics for a live symptom, systematic-debugging depth otherwise); never another same-lane retry',
123
+ },
124
+ {
125
+ id: 'unconfirmed-assumptions',
126
+ fires: 'review',
127
+ check: 'independent reviewer verifies the unconfirmed assumptions the run carried to done — each must be confirmed or named as a stated limit',
128
+ },
129
+ ]);
130
+
131
+ // Catalogue ids callers may assert through `signals` (object-map or array).
132
+ // Derived signals (`claimed-verification`, `fix-loop-same-fingerprint`) share
133
+ // the same catalogue — the caller never has to restate what the inputs already
134
+ // prove.
135
+ const SIGNAL_ID_SET = new Set(SUSPICION_SIGNAL_CATALOGUE.map((s) => s.id));
136
+
137
+ // Round-cap default: one initial check + one re-check round is the ARCH
138
+ // "one round default-sufficient for small tasks" bound. Every check-driven
139
+ // loop must land `inconclusive` at the cap, never retry forever.
140
+ export const DEFAULT_REVIEW_ROUND_CAP = 2;
141
+
142
+ // Same-fingerprint fix-loop threshold — aligned with the route layer's
143
+ // debugLoopThreshold default (runtimeConfig.js:379). fixLoopCount ≥ threshold
144
+ // derives suspicion signal #7 and routes to `escalate`.
145
+ export const DEFAULT_FIX_LOOP_THRESHOLD = 2;
146
+
147
+ // escalationDepth (forward-declared slot for TASK-C85-016's depth matrix):
148
+ // {depth, reasons[]?} or a bare depth string. The depth matrix may only RAISE
149
+ // the action floor — it never suppresses a fired suspicion signal.
150
+ const DEPTH_MIN_ACTION = Object.freeze({
151
+ none: 'finish',
152
+ runnable: 'run-targeted-check',
153
+ 'review-round': 'independent-review',
154
+ escalate: 'escalate',
155
+ });
156
+
157
+ function isPlainObject(value) {
158
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
159
+ }
160
+
161
+ function clampInt(value, fallback, min = 0, max = 99) {
162
+ const n = Number(value);
163
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, Math.trunc(n))) : fallback;
164
+ }
165
+
166
+ // doneCriteria entry → {name, status}. Statuses:
167
+ // 'verified' — a real-run/surface receipt attests the predicate.
168
+ // 'claimed' — asserted with no captured output (suspicion signal #4).
169
+ // 'missing' — no evidence at all.
170
+ // Bare strings and `{satisfied:true}` shorthands normalize so callers can pass
171
+ // the completion contract's bare class tokens when they have nothing richer.
172
+ function normalizeDoneCriterion(entry) {
173
+ if (typeof entry === 'string' && entry.trim()) {
174
+ return { name: entry.trim(), status: 'missing' };
175
+ }
176
+ if (!isPlainObject(entry)) return null;
177
+ const name = typeof entry.name === 'string' && entry.name.trim()
178
+ ? entry.name.trim()
179
+ : (typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null);
180
+ if (!name) return null;
181
+ let status = typeof entry.status === 'string' ? entry.status.trim().toLowerCase() : null;
182
+ if (status !== 'verified' && status !== 'claimed') {
183
+ status = entry.satisfied === true || entry.verified === true ? 'verified' : 'missing';
184
+ }
185
+ return { name, status };
186
+ }
187
+
188
+ // signals may arrive as an object map ({'unwired-component': true, ...}) or an
189
+ // array of catalogue ids. Only catalogue keys with a truthy flag count — a
190
+ // caller cannot inject self-declared confidence or invent signals, and a
191
+ // `confidence: 'sure'` key is silently ignored by design.
192
+ function assertedSignalIds(signals) {
193
+ const ids = new Set();
194
+ if (Array.isArray(signals)) {
195
+ for (const id of signals) {
196
+ if (typeof id === 'string' && SIGNAL_ID_SET.has(id)) ids.add(id);
197
+ }
198
+ } else if (isPlainObject(signals)) {
199
+ for (const [id, flag] of Object.entries(signals)) {
200
+ if (flag && SIGNAL_ID_SET.has(id)) ids.add(id);
201
+ }
202
+ }
203
+ return ids;
204
+ }
205
+
206
+ function escalationDepthAction(escalationDepth) {
207
+ const depth = typeof escalationDepth === 'string'
208
+ ? escalationDepth
209
+ : escalationDepth?.depth;
210
+ return typeof depth === 'string' && Object.hasOwn(DEPTH_MIN_ACTION, depth)
211
+ ? DEPTH_MIN_ACTION[depth]
212
+ : 'finish';
213
+ }
214
+
215
+ // Resolve the route record's crossCheck sub-record (BL-016): a pinned `depth`
216
+ // field counts as an already-resolved value; otherwise the full sub-record
217
+ // (risk/implementerReliability/evidenceQuality/rigor/…) feeds the matrix.
218
+ // Returns null when no usable depth exists — never throws.
219
+ function resolveCrossCheckInput(crossCheck) {
220
+ if (crossCheck == null) return null;
221
+ if (typeof crossCheck === 'string') {
222
+ return Object.hasOwn(DEPTH_MIN_ACTION, crossCheck) ? crossCheck : null;
223
+ }
224
+ if (!isPlainObject(crossCheck)) return null;
225
+ if (typeof crossCheck.depth === 'string' && Object.hasOwn(DEPTH_MIN_ACTION, crossCheck.depth)) {
226
+ return crossCheck.depth;
227
+ }
228
+ try {
229
+ return resolveCrossCheckDepth(crossCheck);
230
+ } catch {
231
+ return null; // a malformed sub-record never blocks the evaluation
232
+ }
233
+ }
234
+
235
+ // ─── evaluator ──────────────────────────────────────────────────────────────
236
+
237
+ export function evaluateReviewPolicy({
238
+ signals = {},
239
+ doneCriteria = [],
240
+ evidenceClasses = [],
241
+ fixLoopCount = 0,
242
+ round = 0,
243
+ cap = DEFAULT_REVIEW_ROUND_CAP,
244
+ escalationDepth = null,
245
+ // TASK-C85-016 (BL-016): the route record's crossCheck sub-record resolves
246
+ // its depth through the matrix when the caller didn't pass an explicit
247
+ // escalationDepth. A pinned `depth` field on the sub-record wins as an
248
+ // already-resolved value; explicit escalationDepth wins over both.
249
+ crossCheck = null,
250
+ fixLoopThreshold = DEFAULT_FIX_LOOP_THRESHOLD,
251
+ projectRoot = null,
252
+ appendDecisionReceipt = null,
253
+ } = {}) {
254
+ const criteria = (Array.isArray(doneCriteria) ? doneCriteria : [])
255
+ .map(normalizeDoneCriterion)
256
+ .filter(Boolean);
257
+ const verified = criteria.filter((c) => c.status === 'verified').map((c) => c.name);
258
+ const notVerified = criteria.filter((c) => c.status !== 'verified').map((c) => c.name);
259
+ const claimed = criteria.filter((c) => c.status === 'claimed').map((c) => c.name);
260
+ const requiredEvidence = (Array.isArray(evidenceClasses) ? evidenceClasses : [])
261
+ .filter((e) => typeof e === 'string' && e.trim());
262
+ const cappedRound = clampInt(round, 0);
263
+ const cappedCap = clampInt(cap, DEFAULT_REVIEW_ROUND_CAP, 1);
264
+
265
+ // ── signal detection ────────────────────────────────────────────────────
266
+ // Caller-asserted signals + the two the inputs derive on their own: a
267
+ // 'claimed' criterion IS signal #4 (verification claimed, no output) and
268
+ // fixLoopCount ≥ threshold IS signal #7 (same-fingerprint fix loop).
269
+ const hitIds = assertedSignalIds(signals);
270
+ if (claimed.length > 0) hitIds.add('claimed-verification');
271
+ if (clampInt(fixLoopCount, 0) >= Math.max(1, clampInt(fixLoopThreshold, DEFAULT_FIX_LOOP_THRESHOLD))) {
272
+ hitIds.add('fix-loop-same-fingerprint');
273
+ }
274
+
275
+ const hits = SUSPICION_SIGNAL_CATALOGUE.filter((s) => hitIds.has(s.id));
276
+
277
+ // ── base action ─────────────────────────────────────────────────────────
278
+ let action = 'finish';
279
+ let namedCheck = null;
280
+ let rationale;
281
+
282
+ if (hits.length > 0) {
283
+ const winning = hits.reduce((best, signal) => (
284
+ ACTION_SEVERITY[SIGNAL_ACTION[signal.fires]] > ACTION_SEVERITY[SIGNAL_ACTION[best.fires]]
285
+ ? signal
286
+ : best
287
+ ));
288
+ action = SIGNAL_ACTION[winning.fires];
289
+ namedCheck = winning.check;
290
+ rationale = `suspicion signal${hits.length > 1 ? 's' : ''} fired: ${hits.map((s) => s.id).join(', ')} — strongest is ${winning.id} (${action})`;
291
+ } else if (notVerified.length > 0) {
292
+ // Unevidenced work with no named suspicion still cannot finish: demand one
293
+ // cheap targeted check per the cheapest defect to catch.
294
+ action = 'run-targeted-check';
295
+ namedCheck = `obtain the named evidence for: ${notVerified.join(', ')} — receipt or verbatim output, not a claim`;
296
+ rationale = `unverified done-criteria with no fired signal: ${notVerified.join(', ')}`;
297
+ } else if (criteria.length === 0 && requiredEvidence.length > 0) {
298
+ // No criteria but required evidence classes → confirm the classes landed.
299
+ action = 'run-targeted-check';
300
+ namedCheck = `confirm receipt classes present: ${requiredEvidence.join(', ')}`;
301
+ rationale = 'no done-criteria; required evidence classes unaccounted';
302
+ } else if (criteria.length === 0) {
303
+ // Nothing checkable exists at all — the honest report, not a free pass.
304
+ action = 'inconclusive';
305
+ rationale = 'no done-criteria and no evidence classes to verify — nothing checkable was provided';
306
+ } else {
307
+ // Every criterion verified — a verified criterion IS the evidence;
308
+ // declared evidence classes add no extra round on their own.
309
+ rationale = 'all done-criteria verified, no suspicion signals — review round suppressed';
310
+ }
311
+
312
+ // ── cross-check depth floor (escalationDepth) ───────────────────────────
313
+ // TASK-C85-016 wires the depth matrix here: a depth may raise the action
314
+ // floor (e.g. 'review-round' forces ≥ independent-review) but never lowers
315
+ // it — a fired suspicion signal always outranks a shallow depth.
316
+ const effectiveDepth = escalationDepth ?? resolveCrossCheckInput(crossCheck);
317
+ const depthFloor = escalationDepthAction(effectiveDepth);
318
+ if (ACTION_SEVERITY[depthFloor] > ACTION_SEVERITY[action]) {
319
+ const depth = typeof effectiveDepth === 'object' ? (effectiveDepth?.depth ?? effectiveDepth) : effectiveDepth;
320
+ action = depthFloor;
321
+ namedCheck = namedCheck
322
+ ?? (action === 'independent-review'
323
+ ? 'cross-check depth matrix requires one independent review round — reviewer verifies the request is satisfied, wired into flow, and free of convention breaks'
324
+ : (action === 'escalate'
325
+ ? SUSPICION_SIGNAL_CATALOGUE.find((s) => s.id === 'fix-loop-same-fingerprint').check
326
+ : 'run the artifact-class runnable checks required by the depth matrix'));
327
+ rationale += `; escalationDepth=${depth} raised the action floor to ${action}`;
328
+ }
329
+
330
+ // ── round cap → inconclusive (never infinite blocking) ──────────────────
331
+ // 'escalate' bypasses the cap deliberately: it is a terminal hand-off to a
332
+ // deeper lane, not another loop iteration.
333
+ if (action !== 'finish' && action !== 'escalate' && cappedRound >= cappedCap) {
334
+ const previous = action;
335
+ action = 'inconclusive';
336
+ namedCheck = null;
337
+ rationale = `review round cap reached (round=${cappedRound} ≥ cap=${cappedCap}) — ${previous} could not be confirmed; reporting verified-vs-not honestly instead of looping`;
338
+ }
339
+
340
+ const missingEvidence = [...new Set([...notVerified, ...requiredEvidence])];
341
+
342
+ const decision = {
343
+ action,
344
+ namedCheck,
345
+ signalsHit: hits.map((s) => s.id),
346
+ rationale,
347
+ reviewRounds: cappedRound + (action === 'finish' || action === 'inconclusive' ? 0 : 1),
348
+ verified,
349
+ notVerified,
350
+ missingEvidence,
351
+ };
352
+
353
+ // ── calibration receipt (consumed signature — never edited) ─────────────
354
+ // decisionKeys carry the receipt fields the ledger schema doesn't model:
355
+ // signalsHit, round, and outcome (unknown at decision time → pending).
356
+ if (typeof appendDecisionReceipt === 'function' && projectRoot) {
357
+ try {
358
+ const appended = appendDecisionReceipt(projectRoot, {
359
+ kind: 'review-policy',
360
+ boundary: 'review',
361
+ stage: 'deterministic',
362
+ outcomeClass: decision.action,
363
+ checkpoint: decision.namedCheck,
364
+ decisionKeys: [
365
+ `signalsHit=${decision.signalsHit.join(',') || 'none'}`,
366
+ `round=${cappedRound}`,
367
+ 'outcome=pending',
368
+ ],
369
+ agreement: 'n/a',
370
+ });
371
+ if (appended?.catch) appended.catch(() => {});
372
+ } catch { /* calibration telemetry never fails the evaluation */ }
373
+ }
374
+
375
+ return decision;
376
+ }