@ngockhoale/ukit 3.3.3 → 3.4.1
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.
- package/CHANGELOG.md +44 -0
- package/manifests/engineConformance.yaml +17 -1
- package/manifests/hostCapabilities.yaml +68 -1
- package/manifests/platform.full.yaml +138 -0
- package/manifests/platform.user.yaml +255 -3
- package/package.json +1 -1
- package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
- package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
- package/scripts/probe/codex-capability-probe.mjs +169 -0
- package/src/cli/commands/doctor.js +168 -0
- package/src/cli/commands/indexTools.js +7 -0
- package/src/cli/commands/metrics.js +66 -2
- package/src/cli/commands/playbook.js +4 -4
- package/src/cli/commands/vm.js +49 -8
- package/src/core/agentRuntime/adapters.js +328 -27
- package/src/core/agentRuntime/artifacts.js +89 -0
- package/src/core/agentRuntime/context.js +345 -1
- package/src/core/agentRuntime/contract.js +296 -0
- package/src/core/agentRuntime/eventStore.js +176 -0
- package/src/core/agentRuntime/shadowRun.js +481 -5
- package/src/core/agentRuntime/telemetry.js +121 -0
- package/src/core/observability/emit/lifecycle.js +68 -1
- package/src/core/observability/emit/sessionBoot.js +393 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/registry.js +10 -0
- package/src/core/runtimeConfig.js +133 -0
- package/src/core/userPlaybooks.js +18 -3
- package/src/decision/registry.js +19 -0
- package/src/diagnostics/feedbackEvents.js +7 -4
- package/src/diagnostics/routeOutcomes.js +51 -6
- package/src/diagnostics/skillAccuracy.js +43 -3
- package/src/index/crossCheckMatrix.js +412 -0
- package/src/index/fixLoopEscalation.js +453 -0
- package/src/index/playbookRegistry.js +691 -0
- package/src/index/reviewPolicy.js +368 -0
- package/src/index/routeResolver.js +915 -0
- package/src/index/sessionHistoryExtractor.js +359 -0
- package/src/index/taskRouting.js +764 -581
- package/src/index/tierSelection.js +308 -0
- package/src/index/verificationMap.js +404 -0
- package/template_project/.claude/hooks/observability-emit.mjs +14 -0
- package/template_project/.claude/hooks/record-execution.mjs +19 -1
- package/template_project/.claude/hooks/skill-router.sh +691 -25
- package/template_project/.claude/hooks/verification-guard.sh +230 -1
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
- package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
- package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
- package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
- package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
- package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
- package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
- package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
- package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
- package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
- package/template_project/.codex/README.md +8 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
- package/template_project/ukit/README.md +1 -1
- package/template_project/ukit/storage/config.json +20 -0
- package/template_user/playbooks/architecture-decision.md +28 -0
- package/template_user/playbooks/autonomous-run.md +43 -0
- package/template_user/playbooks/autopilot-full.md +59 -0
- package/template_user/playbooks/autopilot-stack.md +54 -0
- package/template_user/playbooks/babysit.md +39 -0
- package/template_user/playbooks/bug-fix.md +3 -1
- package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
- package/template_user/playbooks/hillclimb.md +44 -0
- package/template_user/playbooks/investigation.md +21 -0
- package/template_user/playbooks/migration.md +21 -0
- package/template_user/playbooks/open-pr.md +48 -0
- package/template_user/playbooks/orchestrate.md +45 -0
- package/template_user/playbooks/performance.md +33 -0
- package/template_user/playbooks/prototype.md +28 -0
- package/template_user/playbooks/refactor.md +19 -0
- package/template_user/playbooks/release.md +28 -0
- package/template_user/playbooks/runtime-forensics.md +23 -0
- package/template_user/playbooks/session-pickup.md +31 -0
- package/template_user/playbooks/shipping.md +53 -0
- package/template_user/playbooks/skill-evaluation.md +48 -0
- package/template_user/playbooks/small-feature.md +20 -0
- package/template_user/playbooks/verification-map.json +153 -0
- package/template_user/playbooks/verification.md +22 -0
- 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
|
+
}
|