@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
@@ -0,0 +1,453 @@
1
+ // fixLoopEscalation.js — fix-loop escalation to a deeper lane (C85 TASK-018,
2
+ // BL-018 / SPEC FR-006 / ARCH §State Machine fix-loop transition).
3
+ //
4
+ // "Fast becomes sloppy" loops exit into rigor instead of a third blind retry:
5
+ // when `historySignals.fixLoopCount` — the fingerprint-joined counter of failed
6
+ // attempts on the SAME `routeFingerprint`/`requestKey` (never a session-wide
7
+ // counter, GAP M12) — reaches `orchestration.escalation.debugLoopThreshold`,
8
+ // the route deepens its lane:
9
+ //
10
+ // escalate → bounded unic-decision `laneDeepening`/`verificationDepth`
11
+ // questions (BL-017 family machinery) → deeper debug + verification lane.
12
+ // live-symptom loops hand off toward the `runtime-forensics` playbookId row;
13
+ // while the file is absent the route stamps `playbookLoad: 'missing'` and
14
+ // the lane still deepens (SPEC §14 degrade contract).
15
+ // decision endpoint down/malformed → deterministic deeper-lane fallback;
16
+ // the existing deterministic tier-bump path (`applyEscalationToRouteSummary`)
17
+ // stays in force as the escalation fallback — recorded as `fallback:tier-bump`.
18
+ // deeper lane already escalated and the same fingerprint still fails →
19
+ // BLOCKED with a named blocker + attempts evidence; never another
20
+ // same-lane retry (ARCH §Failure Modes "fix-loop escalation exhausted").
21
+ //
22
+ // This module is deterministic and performs NO I/O itself: the decision-plane
23
+ // call, the registry presence check, and the receipt append are injected by
24
+ // each router surface (taskRouting.js supplies the in-process decision client;
25
+ // route-task.mjs supplies the unic-decision.mjs CLI spawn + ledger receipts).
26
+ //
27
+ // Mirror parity: template_project/.claude/ukit/index/fix-loop-escalation.mjs
28
+ // carries the identical logic (the mirror cannot import src/). Both twins are
29
+ // locked by tests/index/fixLoopEscalation.test.js parity cases.
30
+
31
+ import {
32
+ resolveRouteStage,
33
+ resolveDecisionPlaneStage,
34
+ } from './routeResolver.js';
35
+ import { resolveCrossCheckDepth } from './crossCheckMatrix.js';
36
+
37
+ // Deepening vocabulary = the contract lane ladder (EXECUTION_MODE_ORDER); the
38
+ // non-contract 'informational' lane is never an escalation destination.
39
+ export const FIX_LOOP_LANE_CANDIDATES = Object.freeze([
40
+ 'tiny-fix',
41
+ 'local-fix',
42
+ 'local-build',
43
+ 'find-cause',
44
+ 'shared-edit',
45
+ 'map-impact',
46
+ 'review-release',
47
+ ]);
48
+
49
+ // Deterministic deeper-lane map — the fallback the endpoint-down path records
50
+ // as `fallback:tier-bump` AND the deterministic endpoint the escalated lane
51
+ // still deepens to. Architecture ladder (ARCH §State Machine): shallow fix
52
+ // lanes deepen to find-cause (systematic-debugging depth); find-cause widens
53
+ // to map-impact; already-deep lanes stay put — the loop on them is the
54
+ // exhaustion signal, handled by the BLOCKED transition.
55
+ export const FIX_LOOP_LANE_DEEPENING = Object.freeze({
56
+ 'tiny-fix': 'find-cause',
57
+ 'local-fix': 'find-cause',
58
+ 'local-build': 'find-cause',
59
+ 'find-cause': 'map-impact',
60
+ 'shared-edit': 'find-cause',
61
+ 'map-impact': 'map-impact',
62
+ 'review-release': 'map-impact',
63
+ informational: 'find-cause',
64
+ });
65
+
66
+ // BL-018 escalation questions — the BL-017 ROUTE_SHADOW_QUESTIONS entries the
67
+ // escalation fires. Candidates are protocol-local labels; identical rows live
68
+ // in route-resolver.mjs and are parity-locked by the family table test.
69
+ export const FIX_LOOP_ESCALATION_QUESTIONS = Object.freeze([
70
+ {
71
+ decisionKey: 'lane.deepening.v1',
72
+ kind: 'choice',
73
+ instruction: 'Execution lane after fix-loop escalation deepening.',
74
+ candidates: [...FIX_LOOP_LANE_CANDIDATES],
75
+ },
76
+ {
77
+ decisionKey: 'verification.depth.v1',
78
+ kind: 'choice',
79
+ instruction: 'Verification depth for this route.',
80
+ candidates: ['none', 'runnable', 'review-round', 'escalate'],
81
+ },
82
+ ]);
83
+
84
+ const DEFAULT_FIX_LOOP_DEBUG_THRESHOLD = 2;
85
+
86
+ // Live-symptom vocabulary — a non-deterministic repro (hang/leak/intermittent)
87
+ // is the runtime-forensics hand-off, while a named test-harness failure IS a
88
+ // deterministic repro and stays in the standard deeper lane. Mirrors the
89
+ // playbook-registry signal (its regexes are registry-private).
90
+ const LIVE_SYMPTOM_RE = /\b(hangs?|leaks?|sometimes|intermittent|flaky|occasionally|random|sporadic|one in \d+|about \d+ in \d+|cpuprofile|heap\s*(?:dump|snapshot|profile)|memory profile|dropped (?:a )?(?:cpu|heap|profile) artifact)\b/i;
91
+ const TEST_HARNESS_RE = /\b(spec|test|tests|assertion)\b/i;
92
+
93
+ function isPlainObject(value) {
94
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
95
+ }
96
+
97
+ function clampThreshold(value, fallback = DEFAULT_FIX_LOOP_DEBUG_THRESHOLD) {
98
+ return Number.isFinite(value) && value > 0
99
+ ? Math.max(1, Math.floor(value))
100
+ : fallback;
101
+ }
102
+
103
+ // Escalation settings consumed from `orchestration.escalation` — the SAME block
104
+ // the tier-bump ladder reads (runtimeConfig.js ships debugLoopThreshold: 2).
105
+ // `laneDeepening` is the additive BL-018 switch; absent defaults to enabled.
106
+ export function resolveFixLoopEscalationConfig(config = null) {
107
+ const raw = config?.orchestration?.escalation;
108
+ return {
109
+ enabled: typeof raw?.enabled === 'boolean' ? raw.enabled : true,
110
+ debugLoopThreshold: clampThreshold(raw?.debugLoopThreshold),
111
+ laneDeepening: raw?.laneDeepening !== false,
112
+ };
113
+ }
114
+
115
+ // Same-symptom detection joins on the fingerprint-joined history counter —
116
+ // fixLoopCount only counts markers matching the current routeFingerprint/
117
+ // requestKey (sessionHistoryExtractor), so a session full of UNRELATED failures
118
+ // can never trip this gate (SPEC §10). Same-symptom semantics live in the
119
+ // caller-supplied counter; this gate is a pure threshold comparison.
120
+ export function isFixLoopEscalationDue({
121
+ historySignals = null,
122
+ config = null,
123
+ } = {}) {
124
+ const settings = resolveFixLoopEscalationConfig(config);
125
+ if (!settings.enabled || !settings.laneDeepening) {
126
+ return false;
127
+ }
128
+ const fixLoopCount = Number.isFinite(historySignals?.fixLoopCount)
129
+ ? Math.max(0, Math.floor(historySignals.fixLoopCount))
130
+ : 0;
131
+ return fixLoopCount >= settings.debugLoopThreshold;
132
+ }
133
+
134
+ // Deterministic deeper lane for a fix-loop escalation — the fallback the
135
+ // endpoint-down path rides (recorded as `fallback:tier-bump` since the
136
+ // tier-escalation ladder stays the escalation's deterministic fallback).
137
+ export function deterministicEscalatedLane(currentLane = null) {
138
+ return FIX_LOOP_LANE_DEEPENING[currentLane] ?? 'find-cause';
139
+ }
140
+
141
+ // Live-symptom detection for the runtime-forensics hand-off. Only a
142
+ // non-deterministic repro qualifies — an explicit spec/test failure is a
143
+ // deterministic repro, which deepens to find-cause instead.
144
+ export function isLiveSymptomRoute({
145
+ routeSummary = null,
146
+ routingContext = {},
147
+ } = {}) {
148
+ if (routeSummary?.workGroup === 'runtime-forensics') return true;
149
+ const signalText = [routingContext.promptText, routingContext.commandText]
150
+ .map((value) => String(value ?? '').trim().toLowerCase())
151
+ .filter(Boolean)
152
+ .join('\n');
153
+ if (signalText === '') return false;
154
+ return LIVE_SYMPTOM_RE.test(signalText) && !TEST_HARNESS_RE.test(signalText);
155
+ }
156
+
157
+ // Deterministic deeper verification depth — the cross-check matrix (BL-016)
158
+ // resolved over the route's own risk/evidence inputs. 'none' cannot be the
159
+ // answer to a fix loop: the floor is 'runnable' so the deeper lane always
160
+ // carries a runnable check.
161
+ export function resolveEscalationVerificationDepth({
162
+ routeSummary = null,
163
+ config = null,
164
+ } = {}) {
165
+ try {
166
+ const resolved = resolveCrossCheckDepth({
167
+ riskFloor: routeSummary?.riskFloor ?? null,
168
+ rigor: routeSummary?.execution?.rigor ?? null,
169
+ evidenceQuality: routeSummary?.verificationRecipe?.evidenceRequired ?? null,
170
+ overrides: config?.crossCheck ?? null,
171
+ });
172
+ const depth = typeof resolved?.depth === 'string' ? resolved.depth : null;
173
+ return depth && depth !== 'none' ? depth : 'runnable';
174
+ } catch {
175
+ return 'runnable';
176
+ }
177
+ }
178
+
179
+ function validAnswerValue(result, decisionKey, candidates) {
180
+ const answers = Array.isArray(result?.answers) ? result.answers : [];
181
+ for (const answer of answers) {
182
+ if (answer?.decisionKey !== decisionKey) continue;
183
+ if (answer?.validationStatus !== 'valid') continue;
184
+ const value = typeof answer?.value === 'string' ? answer.value.trim() : null;
185
+ if (value && candidates.includes(value)) return value;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // Build the bounded statePacket the laneDeepening/verificationDepth batch
191
+ // carries — identical vocabulary to the shadow batch (bounded counters and
192
+ // enums only; never prompt text, transcript text, or raw state blobs).
193
+ export function buildFixLoopStatePacket({
194
+ routeSummary = null,
195
+ routingContext = {},
196
+ } = {}) {
197
+ const hist = routeSummary?.historySignals ?? null;
198
+ return {
199
+ stateVersion: 1,
200
+ taskClass: routingContext.taskClass ?? routingContext.taskType ?? 'unknown',
201
+ intentSignals: routingContext.intentMode ? [routingContext.intentMode] : [],
202
+ targetClass: routingContext.targetFile ? 'file-target' : 'no-target',
203
+ execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
204
+ riskSignals: routeSummary?.riskFloor?.codes ?? [],
205
+ history: hist
206
+ ? {
207
+ priorAttemptCount: Math.max(0, Math.trunc(hist.priorAttemptCount ?? 0) || 0),
208
+ fixLoopCount: Math.max(0, Math.trunc(hist.fixLoopCount ?? 0) || 0),
209
+ sameSymptomReask: hist.sameSymptomReask === true,
210
+ correctionEvents: Math.max(0, Math.trunc(hist.correctionEvents ?? hist.correctionCount ?? 0) || 0),
211
+ degraded: hist.degraded === true,
212
+ }
213
+ : null,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Apply the fix-loop escalation transition to a built routeSummary.
219
+ *
220
+ * Additive route fields produced:
221
+ * escalatedLane deeper lane, or 'blocked' on exhaustion
222
+ * escalateReason bounded machine-readable reason
223
+ * escalatedVerificationDepth deeper verification depth (escalate only)
224
+ * escalatedRouteFingerprint the routeFingerprint this escalation record
225
+ * belongs to — the same-loop join key: a prior
226
+ * escalatedLane/blocked is only honored when
227
+ * its carried fingerprint equals the current
228
+ * routeFingerprint (SPEC §10; a different
229
+ * fingerprint's record is a different loop and
230
+ * can never fabricate a blocked verdict or
231
+ * leak a lane onto an unrelated route).
232
+ *
233
+ * Returns `{action:'escalate'|'blocked'|'preserve'|'clear'}`; 'preserve'/'clear'
234
+ * leave the summary so threshold-1 attempts and non-loop routes stay exactly
235
+ * as before (a previously escalated lane persists across emissions — it is the
236
+ * same fingerprint's recorded escalation, not a fresh one). 'blocked' is
237
+ * terminal: a same-fingerprint re-ask stays blocked with no decision-plane
238
+ * round and no re-escalation.
239
+ *
240
+ * Injected seams (each surface wires its own):
241
+ * ask({batch}) → DecisionBatchResult decision-plane call; null = stage off
242
+ * appendReceipt(receipt) decisions.tsv writer; may be async
243
+ * runtimeForensicsPresent boolean — runtime-forensics.md
244
+ * resolved through the playbook
245
+ * registry (absent → 'missing')
246
+ * previousRouteSummary prior emission for the same route
247
+ * (drives the exhaustion BLOCKED path)
248
+ * routeFingerprint current route's fingerprint — the
249
+ * same-loop join key stamped onto
250
+ * the escalation record
251
+ */
252
+ export async function applyFixLoopEscalation({
253
+ routeSummary = null,
254
+ routingContext = {},
255
+ previousRouteSummary = null,
256
+ routeFingerprint = null,
257
+ config = null,
258
+ ask = null,
259
+ appendReceipt = null,
260
+ runtimeForensicsPresent = null,
261
+ now = () => Date.now(),
262
+ } = {}) {
263
+ if (!isPlainObject(routeSummary)) {
264
+ return { action: 'clear', routeSummary };
265
+ }
266
+
267
+ const settings = resolveFixLoopEscalationConfig(config);
268
+ const stage = resolveRouteStage(config, 'escalation');
269
+ const fixLoopCount = Number.isFinite(routeSummary?.historySignals?.fixLoopCount)
270
+ ? Math.max(0, Math.floor(routeSummary.historySignals.fixLoopCount))
271
+ : 0;
272
+ const thresholdMet = fixLoopCount >= settings.debugLoopThreshold;
273
+ // Same-loop join (SPEC §10): a prior escalation record only binds when the
274
+ // fingerprint it was stamped under equals THIS route's fingerprint — a
275
+ // different fingerprint's escalatedLane is a different loop, never this
276
+ // route's spent escalation, and a fingerprint-less record can never be
277
+ // verified as same-loop (escalate fresh, never fabricate 'blocked').
278
+ const priorLane = previousRouteSummary?.escalatedLane ?? null;
279
+ const priorLoopFingerprint = previousRouteSummary?.escalatedRouteFingerprint ?? null;
280
+ const sameLoop = Boolean(priorLane)
281
+ && Boolean(priorLoopFingerprint)
282
+ && priorLoopFingerprint === routeFingerprint;
283
+
284
+ // ── not due: preserve a same-fingerprint escalation, else stay clean ──
285
+ if (stage === 'off' || !settings.enabled || !settings.laneDeepening || !thresholdMet) {
286
+ if (sameLoop) {
287
+ routeSummary.escalatedLane = priorLane;
288
+ routeSummary.escalateReason = previousRouteSummary.escalateReason ?? null;
289
+ routeSummary.escalatedRouteFingerprint = priorLoopFingerprint;
290
+ if (previousRouteSummary.escalatedVerificationDepth != null) {
291
+ routeSummary.escalatedVerificationDepth = previousRouteSummary.escalatedVerificationDepth;
292
+ } else {
293
+ delete routeSummary.escalatedVerificationDepth;
294
+ }
295
+ return { action: 'preserve', routeSummary };
296
+ }
297
+ delete routeSummary.escalatedLane;
298
+ delete routeSummary.escalateReason;
299
+ delete routeSummary.escalatedVerificationDepth;
300
+ delete routeSummary.escalatedRouteFingerprint;
301
+ return { action: 'clear', routeSummary };
302
+ }
303
+
304
+ const currentLane = typeof routeSummary.executionMode === 'string'
305
+ ? routeSummary.executionMode
306
+ : null;
307
+ const deterministicLane = deterministicEscalatedLane(currentLane);
308
+ const liveSymptom = isLiveSymptomRoute({ routeSummary, routingContext });
309
+ const emit = (receipt) => {
310
+ if (typeof appendReceipt !== 'function') return;
311
+ try {
312
+ const pending = appendReceipt(receipt);
313
+ if (pending?.catch) pending.catch(() => {});
314
+ } catch {
315
+ // Receipt loss is advisory — the escalation stands regardless.
316
+ }
317
+ };
318
+
319
+ // ── blocked is terminal: the same fingerprint already exhausted its
320
+ // escalation → stay blocked with the prior blocker, no decision-plane
321
+ // round, no re-escalation (a different fingerprint's 'blocked' record can
322
+ // never reach here — sameLoop already gates the join).
323
+ if (sameLoop && priorLane === 'blocked') {
324
+ routeSummary.escalatedLane = 'blocked';
325
+ routeSummary.escalateReason = previousRouteSummary.escalateReason ?? null;
326
+ routeSummary.escalatedRouteFingerprint = priorLoopFingerprint;
327
+ delete routeSummary.escalatedVerificationDepth;
328
+ return { action: 'blocked', routeSummary };
329
+ }
330
+
331
+ // ── exhausted: the deeper lane already escalated for this fingerprint and
332
+ // the same fingerprint still fails → BLOCKED with the blocker named; never
333
+ // another same-lane retry. escalatedLane is recorded, never promoted to
334
+ // executionMode, so priorLane === currentLane IS the spent-escalation case.
335
+ if (sameLoop) {
336
+ const reason = [
337
+ `blocked: fix-loop-exhausted lane=${priorLane} fixLoopCount=${fixLoopCount}`,
338
+ 'blocker=deeper-lane-still-failing (no further same-lane retries — escalate to the human or new evidence)',
339
+ ].join(' ');
340
+ routeSummary.escalatedLane = 'blocked';
341
+ routeSummary.escalateReason = reason;
342
+ routeSummary.escalatedRouteFingerprint = priorLoopFingerprint;
343
+ delete routeSummary.escalatedVerificationDepth;
344
+ emit({
345
+ kind: 'applied',
346
+ boundary: 'route',
347
+ stage: 'deterministic',
348
+ outcomeClass: 'blocked',
349
+ checkpoint: `fix-loop escalation exhausted lane=${priorLane} attempts=${fixLoopCount}`,
350
+ latencyClass: 'unknown',
351
+ decisionKeys: ['lane.deepening.v1'],
352
+ agreement: 'n/a',
353
+ fallbackCode: 'fix-loop-exhausted',
354
+ now: typeof now === 'function' ? now() : undefined,
355
+ });
356
+ return { action: 'blocked', routeSummary };
357
+ }
358
+
359
+ // ── escalate: laneDeepening/verificationDepth question → deeper lane ──
360
+ const planeStage = resolveDecisionPlaneStage(config);
361
+ let batchResult = null;
362
+ if (planeStage !== 'off' && typeof ask === 'function') {
363
+ const batch = {
364
+ batchId: `fix-loop-escalation-${now().toString(36)}`,
365
+ boundary: 'route',
366
+ deadlineMs: Math.max(
367
+ 500,
368
+ Number.isFinite(config?.decisionPlane?.timeoutMs)
369
+ ? config.decisionPlane.timeoutMs
370
+ : 2000,
371
+ ),
372
+ statePacket: buildFixLoopStatePacket({ routeSummary, routingContext }),
373
+ questions: [...FIX_LOOP_ESCALATION_QUESTIONS],
374
+ };
375
+ try {
376
+ batchResult = await ask({ batch });
377
+ } catch {
378
+ batchResult = null;
379
+ }
380
+ }
381
+
382
+ const modelLane = batchResult
383
+ ? validAnswerValue(batchResult, 'lane.deepening.v1', FIX_LOOP_LANE_CANDIDATES)
384
+ : null;
385
+ const modelDepth = batchResult
386
+ ? validAnswerValue(
387
+ batchResult,
388
+ 'verification.depth.v1',
389
+ FIX_LOOP_ESCALATION_QUESTIONS[1].candidates,
390
+ )
391
+ : null;
392
+ // A model lane that refuses to deepen is not an escalation answer — the
393
+ // deterministic deeper lane wins so the loop actually exits its lane.
394
+ const usableModelLane = modelLane && modelLane !== currentLane ? modelLane : null;
395
+ const lane = usableModelLane ?? deterministicLane;
396
+ const endpointDown = batchResult !== null
397
+ && batchResult.status !== 'accepted'
398
+ && batchResult.status !== 'partial'
399
+ && !usableModelLane;
400
+ const depth = (modelDepth && modelDepth !== 'none' ? modelDepth : null)
401
+ ?? resolveEscalationVerificationDepth({ routeSummary, config });
402
+
403
+ const reason = [
404
+ `fix-loop: fixLoopCount=${fixLoopCount} threshold=${settings.debugLoopThreshold} fingerprint-joined`,
405
+ lane === usableModelLane
406
+ ? `laneDeepening=${lane}`
407
+ : `laneDeepening=${lane} (${endpointDown ? 'fallback:tier-bump' : 'deterministic'})`,
408
+ `verificationDepth=${depth}`,
409
+ liveSymptom ? 'live-symptom→runtime-forensics' : null,
410
+ ].filter(Boolean).join(' ');
411
+
412
+ routeSummary.escalatedLane = lane;
413
+ routeSummary.escalateReason = reason;
414
+ routeSummary.escalatedVerificationDepth = depth;
415
+ // The escalation record carries THIS route's fingerprint — the next
416
+ // emission's same-loop join (exhaustion/blocked-terminal/preserve) only
417
+ // binds a record whose stamped fingerprint still matches.
418
+ routeSummary.escalatedRouteFingerprint = routeFingerprint;
419
+
420
+ // Live-symptom hand-off: the escalation destination row is runtime-forensics.
421
+ // While the file is absent the registry degrade applies — playbookLoad:
422
+ // 'missing' is stamped honestly and the lane deepens anyway (SPEC §14).
423
+ if (liveSymptom && routeSummary.workGroup !== 'runtime-forensics') {
424
+ routeSummary.playbookId = 'runtime-forensics';
425
+ routeSummary.workGroup = 'runtime-forensics';
426
+ if (runtimeForensicsPresent === true) {
427
+ routeSummary.playbookReason = 'fix-loop-escalation';
428
+ routeSummary.playbookLoad = null;
429
+ } else {
430
+ routeSummary.playbookReason = 'fix-loop-escalation:playbook-missing';
431
+ routeSummary.playbookLoad = 'missing';
432
+ }
433
+ } else if (liveSymptom) {
434
+ // Natural resolution already claimed the row — surface the destination id
435
+ // (the registry returned playbookId: null on the missing-file degrade).
436
+ routeSummary.playbookId = 'runtime-forensics';
437
+ routeSummary.playbookLoad = runtimeForensicsPresent === true ? null : 'missing';
438
+ }
439
+
440
+ emit({
441
+ kind: endpointDown ? 'fallback' : 'applied',
442
+ boundary: 'route',
443
+ stage: planeStage === 'off' ? 'deterministic' : planeStage,
444
+ outcomeClass: batchResult?.status ?? 'deterministic',
445
+ checkpoint: `fix-loop escalate lane=${lane} depth=${depth} loop=${fixLoopCount}`,
446
+ latencyClass: batchResult?.latencyClass ?? 'unknown',
447
+ decisionKeys: FIX_LOOP_ESCALATION_QUESTIONS.map((q) => q.decisionKey),
448
+ agreement: usableModelLane ? 'model' : 'deterministic',
449
+ fallbackCode: batchResult?.fallbackCode ?? null,
450
+ now: typeof now === 'function' ? now() : undefined,
451
+ });
452
+ return { action: 'escalate', routeSummary };
453
+ }