@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
|
@@ -33,7 +33,90 @@ import {
|
|
|
33
33
|
} from '../runtime/memory-policy.mjs';
|
|
34
34
|
import { resolveRecordFreshness } from '../runtime/memory-freshness.mjs';
|
|
35
35
|
import { resolveMemoryStage } from '../runtime/memory-flags.mjs';
|
|
36
|
-
|
|
36
|
+
import {
|
|
37
|
+
ROUTE_VERSION,
|
|
38
|
+
ROUTE_CONTRACT_VERSION,
|
|
39
|
+
ROUTE_SCHEMA_STAGES,
|
|
40
|
+
ROUTE_INTENT_KINDS,
|
|
41
|
+
ROUTE_MUTABILITIES,
|
|
42
|
+
ROUTE_RIGOR_LEVELS,
|
|
43
|
+
ROUTE_MODEL_TIERS,
|
|
44
|
+
ROUTE_RISK_FLOORS,
|
|
45
|
+
ROUTE_RISK_REASON_CODES,
|
|
46
|
+
ROUTE_EXECUTION_MODES,
|
|
47
|
+
ROUTE_EFFORTS,
|
|
48
|
+
resolveRouteSchemaStage,
|
|
49
|
+
resolveRouteStage,
|
|
50
|
+
resolveResumableRunStage,
|
|
51
|
+
resolveDecisionPlaneStage,
|
|
52
|
+
resolveDecisionFamilyStage,
|
|
53
|
+
buildExecutionContract,
|
|
54
|
+
resolveModelTier,
|
|
55
|
+
isSharedImpactFile,
|
|
56
|
+
deriveRiskFloor,
|
|
57
|
+
formatRiskFloorSegment,
|
|
58
|
+
deriveCeremonyLimits,
|
|
59
|
+
formatLimitsSegment,
|
|
60
|
+
deriveFastPath,
|
|
61
|
+
formatFastPathSegment,
|
|
62
|
+
isDeliveryOnlyRequest,
|
|
63
|
+
buildCompletionState,
|
|
64
|
+
buildResolvedRouteFields,
|
|
65
|
+
validateResolvedRoute,
|
|
66
|
+
compactResolvedRoute,
|
|
67
|
+
resumableTaskBoundary,
|
|
68
|
+
buildShadowDecisionQuestions,
|
|
69
|
+
ROUTE_SHADOW_QUESTIONS,
|
|
70
|
+
deriveRouteFields,
|
|
71
|
+
} from './route-resolver.mjs';
|
|
72
|
+
import { applyMeasuredTier, loadTierMap } from './tier-selection.mjs';
|
|
73
|
+
import { resolvePlaybookRoute, loadRegistry } from './playbook-registry.mjs';
|
|
74
|
+
import { extractHistorySignalsAsync } from './session-history-extractor.mjs';
|
|
75
|
+
import {
|
|
76
|
+
applyFixLoopEscalation,
|
|
77
|
+
isFixLoopEscalationDue,
|
|
78
|
+
} from './fix-loop-escalation.mjs';
|
|
79
|
+
|
|
80
|
+
// TASK-004 (BL-006): the derivation primitives this file used to carry inline now
|
|
81
|
+
// live in route-resolver.mjs — the same module skill-router.sh imports, so both
|
|
82
|
+
// router surfaces compute identical fields. Exported names stay identical via
|
|
83
|
+
// re-export (tests and the Codex instruction path import them from here).
|
|
84
|
+
export {
|
|
85
|
+
ROUTE_VERSION,
|
|
86
|
+
ROUTE_CONTRACT_VERSION,
|
|
87
|
+
ROUTE_SCHEMA_STAGES,
|
|
88
|
+
ROUTE_INTENT_KINDS,
|
|
89
|
+
ROUTE_MUTABILITIES,
|
|
90
|
+
ROUTE_RIGOR_LEVELS,
|
|
91
|
+
ROUTE_MODEL_TIERS,
|
|
92
|
+
ROUTE_RISK_FLOORS,
|
|
93
|
+
ROUTE_RISK_REASON_CODES,
|
|
94
|
+
ROUTE_EXECUTION_MODES,
|
|
95
|
+
ROUTE_EFFORTS,
|
|
96
|
+
resolveRouteSchemaStage,
|
|
97
|
+
resolveRouteStage,
|
|
98
|
+
resolveResumableRunStage,
|
|
99
|
+
resolveDecisionPlaneStage,
|
|
100
|
+
resolveDecisionFamilyStage,
|
|
101
|
+
buildExecutionContract,
|
|
102
|
+
resolveModelTier,
|
|
103
|
+
isSharedImpactFile,
|
|
104
|
+
deriveRiskFloor,
|
|
105
|
+
formatRiskFloorSegment,
|
|
106
|
+
deriveCeremonyLimits,
|
|
107
|
+
formatLimitsSegment,
|
|
108
|
+
deriveFastPath,
|
|
109
|
+
formatFastPathSegment,
|
|
110
|
+
isDeliveryOnlyRequest,
|
|
111
|
+
buildCompletionState,
|
|
112
|
+
buildResolvedRouteFields,
|
|
113
|
+
validateResolvedRoute,
|
|
114
|
+
compactResolvedRoute,
|
|
115
|
+
resumableTaskBoundary,
|
|
116
|
+
buildShadowDecisionQuestions,
|
|
117
|
+
ROUTE_SHADOW_QUESTIONS,
|
|
118
|
+
deriveRouteFields,
|
|
119
|
+
};
|
|
37
120
|
const {
|
|
38
121
|
resolveContext,
|
|
39
122
|
buildCodeIndex,
|
|
@@ -71,56 +154,6 @@ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
|
|
|
71
154
|
return unique(docs).slice(0, CONTEXT_DOCS_MAX);
|
|
72
155
|
}
|
|
73
156
|
|
|
74
|
-
// --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
|
|
75
|
-
// Literal mirror of the canonical block in src/index/taskRouting.js — this file cannot
|
|
76
|
-
// import from src/, so the copy is locked by tests/consistency/executionContractSync.test.js.
|
|
77
|
-
// Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
|
|
78
|
-
// "off". All fields are additive on top of the existing routeSummary shape — legacy
|
|
79
|
-
// top-level fields are never removed or renamed, so older consumers keep working.
|
|
80
|
-
export const ROUTE_VERSION = 1;
|
|
81
|
-
export const ROUTE_CONTRACT_VERSION = 1;
|
|
82
|
-
export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
|
|
83
|
-
export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
|
|
84
|
-
export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
|
|
85
|
-
export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
|
|
86
|
-
export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
|
|
87
|
-
export const ROUTE_RISK_FLOORS = new Set(['none', 'high-risk']);
|
|
88
|
-
export const ROUTE_EFFORTS = new Set(['low', 'medium', 'high']);
|
|
89
|
-
// SPEC §5 FR-003: fixed table order — codes are emitted and printed in this order.
|
|
90
|
-
// The first six raise the floor to 'high-risk'; the last two are informational only.
|
|
91
|
-
export const ROUTE_RISK_REASON_CODES = Object.freeze([
|
|
92
|
-
'security-sensitive',
|
|
93
|
-
'shared-impact',
|
|
94
|
-
'public-contract',
|
|
95
|
-
'schema-change',
|
|
96
|
-
'destructive-action',
|
|
97
|
-
'cross-engine',
|
|
98
|
-
'established-precedent',
|
|
99
|
-
'explicit-local-target',
|
|
100
|
-
]);
|
|
101
|
-
const ROUTE_RISK_FLOOR_RAISING_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(0, 6));
|
|
102
|
-
const ROUTE_RISK_ENGINES = ['claude code', 'codex', 'omp'];
|
|
103
|
-
|
|
104
|
-
// FR-004 (M01.3'): Fast Path eligibility vocabulary. The suppressed list is fixed
|
|
105
|
-
// text per SPEC — deliberately not configurable. Reasons are the informational
|
|
106
|
-
// risk codes (the last two ROUTE_RISK_REASON_CODES entries) present on the route.
|
|
107
|
-
const FAST_PATH_MODES = new Set(['tiny-fix', 'local-fix']);
|
|
108
|
-
const FAST_PATH_SUPPRESSED = Object.freeze(['design', 'smart', 'subagents', 'broad-verify']);
|
|
109
|
-
const FAST_PATH_REASON_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(6));
|
|
110
|
-
// 'informational' is a real router emission (no completion contract) even though it is
|
|
111
|
-
// not part of the seven-lane execution-mode ladder.
|
|
112
|
-
export const ROUTE_EXECUTION_MODES = [
|
|
113
|
-
'tiny-fix',
|
|
114
|
-
'local-fix',
|
|
115
|
-
'local-build',
|
|
116
|
-
'find-cause',
|
|
117
|
-
'shared-edit',
|
|
118
|
-
'map-impact',
|
|
119
|
-
'review-release',
|
|
120
|
-
'informational',
|
|
121
|
-
];
|
|
122
|
-
const ROUTE_ESCALATION_CEILING = 'review-release';
|
|
123
|
-
const ROUTE_GOAL_MAX_LENGTH = 240;
|
|
124
157
|
|
|
125
158
|
// --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
|
|
126
159
|
// SPEC-principle-index.md, SPEC-model-roles.md). Literal mirror of the canonical block in
|
|
@@ -142,29 +175,33 @@ If the user says "new task", re-route — do not treat the message as the next s
|
|
|
142
175
|
"might help" is a hypothesis, not a fix; it does not ship.
|
|
143
176
|
4. Verify on the same surface: the original repro now passes. "Inconclusive" or
|
|
144
177
|
wrong-surface is not a pass. A unit test shows branch behavior, not bug absence.
|
|
145
|
-
5. Keep the rejected hypotheses — one line each, why ruled out.
|
|
178
|
+
5. Keep the rejected hypotheses — one line each, why ruled out. When rejections on
|
|
179
|
+
this same defect cross the fix-loop threshold, stop retrying this lane —
|
|
180
|
+
escalate to runtime-forensics (live symptom) or a deeper debug/verify lane.
|
|
146
181
|
Reply: what was broken, root cause, fix, how verified — paste failing-then-passing
|
|
147
182
|
repro output verbatim.
|
|
148
183
|
Ask the human only for: irreversible writes, a genuine preference call no experiment settles, or a real dead end. Everything else: do it, report it.`,
|
|
149
|
-
'
|
|
184
|
+
'feature-implementation': `You own this task. Normalize the goal, build, verify.
|
|
150
185
|
If the user says "new task", re-route — do not treat the message as the next step.
|
|
151
186
|
1. State the done condition as a checkable predicate before writing code.
|
|
152
187
|
2. Find the established analog — follow it unless you name why it does not fit.
|
|
188
|
+
(Skippable only when you can name why no analog exists.)
|
|
153
189
|
3. Name the data shape and its organizing structure before writing logic.
|
|
154
190
|
4. Implement the smallest change satisfying the predicate.
|
|
155
191
|
5. Verify against the predicate on the real artifact — not "it compiles".
|
|
156
192
|
6. Widen once: check the impact surface the route named, no broader.
|
|
157
193
|
Reply: what changed, the predicate, the evidence it now holds.
|
|
158
|
-
Ask the human only for: irreversible writes, a genuine preference call no experiment
|
|
194
|
+
Ask the human only for: irreversible writes, a genuine preference call no experiment
|
|
195
|
+
settles, or a real dead end. Everything else: do it, report it.`,
|
|
159
196
|
});
|
|
160
197
|
|
|
161
198
|
// Lane → policy (§2.2). tiny-fix/local-fix stay lean (Fast Path); review-release and
|
|
162
199
|
// informational carry no policy.
|
|
163
200
|
export const WORKFLOW_POLICY_BY_MODE = Object.freeze({
|
|
164
201
|
'find-cause': 'bug-fix',
|
|
165
|
-
'local-build': '
|
|
166
|
-
'shared-edit': '
|
|
167
|
-
'map-impact': '
|
|
202
|
+
'local-build': 'feature-implementation',
|
|
203
|
+
'shared-edit': 'feature-implementation',
|
|
204
|
+
'map-impact': 'feature-implementation',
|
|
168
205
|
});
|
|
169
206
|
|
|
170
207
|
// SPEC-principle-index §2.1: ~16-line index, one line per principle; group headers are
|
|
@@ -279,450 +316,6 @@ export function formatModelRolesBlock(roles = null) {
|
|
|
279
316
|
fast-worker→${resolved['fast-worker']}, vision→${resolved.vision}. Delegate via role name; inherit-parent = same model as parent.`;
|
|
280
317
|
}
|
|
281
318
|
|
|
282
|
-
// Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
|
|
283
|
-
// malformed values degrade to "off" — the conservative reading that keeps the route
|
|
284
|
-
// byte-identical to the pre-M01.1 shape.
|
|
285
|
-
export function resolveRouteSchemaStage(config = null) {
|
|
286
|
-
const stage = config?.routing?.routeSchema?.stage;
|
|
287
|
-
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// FR-002: generic stage resolver — every routing.<key>.stage shares the same
|
|
291
|
-
// absent/malformed → 'off' contract. resolveRouteSchemaStage stays as the
|
|
292
|
-
// routeSchema-specific spelling of this helper.
|
|
293
|
-
export function resolveRouteStage(config = null, key) {
|
|
294
|
-
const stage = config?.routing?.[key]?.stage;
|
|
295
|
-
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// FR-008 (M07): decision-plane stage resolvers — same absent/malformed → 'off'
|
|
299
|
-
// contract as routing.* stages. `decisionPlane.enabled === false` is the global
|
|
300
|
-
// emergency disable and reads as 'off' regardless of stage keys.
|
|
301
|
-
export function resolveDecisionPlaneStage(config = null) {
|
|
302
|
-
const plane = config?.decisionPlane;
|
|
303
|
-
if (!plane || typeof plane !== 'object' || plane.enabled === false) return 'off';
|
|
304
|
-
return ROUTE_SCHEMA_STAGES.has(plane.stage) ? plane.stage : 'off';
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Per-family override wins over the global stage; absence inherits it.
|
|
308
|
-
export function resolveDecisionFamilyStage(config = null, family) {
|
|
309
|
-
const globalStage = resolveDecisionPlaneStage(config);
|
|
310
|
-
const override = config?.decisionPlane?.families?.[family]?.stage;
|
|
311
|
-
return ROUTE_SCHEMA_STAGES.has(override) ? override : globalStage;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
|
|
315
|
-
// collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
|
|
316
|
-
// any of the first six (floor-raising) codes fired — informational codes never
|
|
317
|
-
// raise it. Detectors read the normalized signal text (same source the mode
|
|
318
|
-
// ladder uses) plus the raw target path and context preview.
|
|
319
|
-
export function deriveRiskFloor({
|
|
320
|
-
promptText = '',
|
|
321
|
-
commandText = '',
|
|
322
|
-
targetFile = null,
|
|
323
|
-
executionMode = null,
|
|
324
|
-
activeSkillIds = [],
|
|
325
|
-
contextPreview = null,
|
|
326
|
-
} = {}) {
|
|
327
|
-
const signalText = buildNormalizedRouteSignalText(promptText, commandText);
|
|
328
|
-
const target = String(targetFile || '');
|
|
329
|
-
const skillIds = Array.isArray(activeSkillIds) ? activeSkillIds : [];
|
|
330
|
-
const codes = [];
|
|
331
|
-
if (
|
|
332
|
-
/\b(auth|security|token|permission|secret|credential|password|vulnerab|exploit|xss|injection)\b/i.test(signalText)
|
|
333
|
-
|| skillIds.includes('discover-security')
|
|
334
|
-
) {
|
|
335
|
-
codes.push('security-sensitive');
|
|
336
|
-
}
|
|
337
|
-
if (isSharedImpactFile(targetFile) || executionMode === 'shared-edit' || executionMode === 'map-impact') {
|
|
338
|
-
codes.push('shared-impact');
|
|
339
|
-
}
|
|
340
|
-
if (
|
|
341
|
-
/(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
|
|
342
|
-
|| /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
|
|
343
|
-
) {
|
|
344
|
-
codes.push('public-contract');
|
|
345
|
-
}
|
|
346
|
-
if (
|
|
347
|
-
/(^|\/)(migrations?|db|database|prisma|schema)/i.test(target)
|
|
348
|
-
|| /\b(migration|migrate|schema|alter table|add column|drop column)\b/i.test(signalText)
|
|
349
|
-
) {
|
|
350
|
-
codes.push('schema-change');
|
|
351
|
-
}
|
|
352
|
-
if (/\b(delete|drop|truncate|destroy|wipe|uninstall|rm -rf|purge)\b/i.test(signalText)) {
|
|
353
|
-
codes.push('destructive-action');
|
|
354
|
-
}
|
|
355
|
-
const engineHits = ROUTE_RISK_ENGINES.filter(
|
|
356
|
-
(name) => new RegExp(`\\b${name}\\b`, 'i').test(signalText),
|
|
357
|
-
).length;
|
|
358
|
-
if (
|
|
359
|
-
/\bcross[- ]engine\b|\ball engines\b/i.test(signalText)
|
|
360
|
-
|| engineHits >= 2
|
|
361
|
-
|| /^template_project\/\.(claude|codex|omp)\//i.test(target)
|
|
362
|
-
) {
|
|
363
|
-
codes.push('cross-engine');
|
|
364
|
-
}
|
|
365
|
-
if ((contextPreview?.analogFiles?.length ?? 0) > 0 || (contextPreview?.styleFiles?.length ?? 0) > 0) {
|
|
366
|
-
codes.push('established-precedent');
|
|
367
|
-
}
|
|
368
|
-
if (target && !isSharedImpactFile(targetFile)) {
|
|
369
|
-
codes.push('explicit-local-target');
|
|
370
|
-
}
|
|
371
|
-
return {
|
|
372
|
-
floor: codes.some((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code)) ? 'high-risk' : 'none',
|
|
373
|
-
codes,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Route-line segment (FR-003): on 'high-risk' only the floor-raising codes print;
|
|
378
|
-
// on 'none' the informational codes do. Empty list → no segment at all.
|
|
379
|
-
function formatRiskFloorSegment(riskFloor = null) {
|
|
380
|
-
if (!riskFloor) {
|
|
381
|
-
return null;
|
|
382
|
-
}
|
|
383
|
-
const printed = riskFloor.floor === 'high-risk'
|
|
384
|
-
? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
|
|
385
|
-
: riskFloor.codes;
|
|
386
|
-
return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
|
|
390
|
-
// into an advisory map. Only finite numbers survive — a non-numeric or missing
|
|
391
|
-
// key is simply absent. Zero is a real budget ("no read passes"), never
|
|
392
|
-
// filtered. Same key set as the ceremonyBudget.limits builder below.
|
|
393
|
-
export function deriveCeremonyLimits(executionContract = null) {
|
|
394
|
-
if (executionContract === null || typeof executionContract !== 'object') {
|
|
395
|
-
return {};
|
|
396
|
-
}
|
|
397
|
-
return Object.fromEntries(
|
|
398
|
-
['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
|
|
399
|
-
.filter((key) => Number.isFinite(executionContract[key]))
|
|
400
|
-
.map((key) => [key, executionContract[key]]),
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
|
|
405
|
-
// keys print. Empty/absent map → null so the segment never appears.
|
|
406
|
-
export function formatLimitsSegment(limits = null) {
|
|
407
|
-
if (limits === null || typeof limits !== 'object') {
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
const parts = [
|
|
411
|
-
['reads', 'maxReadPasses'],
|
|
412
|
-
['ctx', 'maxContextPulls'],
|
|
413
|
-
['reassess', 'maxReadPassesBeforeReassess'],
|
|
414
|
-
]
|
|
415
|
-
.filter(([, key]) => Number.isFinite(limits[key]))
|
|
416
|
-
.map(([label, key]) => `${label}:${limits[key]}`);
|
|
417
|
-
return parts.length > 0 ? `limits=${parts.join(',')}` : null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
|
|
421
|
-
// was supplied — eligibility must never be derived without the floor check, so a
|
|
422
|
-
// missing floor means "not computed", not "none". Eligible iff the lane is
|
|
423
|
-
// tiny-fix/local-fix, exactly one local target is known, the floor is 'none',
|
|
424
|
-
// a bounded verification path exists (targeted commands or the tiny-fix
|
|
425
|
-
// 'minimal-or-targeted' contract policy), and the target is not shared-impact.
|
|
426
|
-
export function deriveFastPath({
|
|
427
|
-
executionMode = null,
|
|
428
|
-
targetFile = null,
|
|
429
|
-
riskFloor = null,
|
|
430
|
-
verificationRecommendation = null,
|
|
431
|
-
contextPreview = null,
|
|
432
|
-
} = {}) {
|
|
433
|
-
if (!riskFloor) {
|
|
434
|
-
return null;
|
|
435
|
-
}
|
|
436
|
-
const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
|
|
437
|
-
const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
|
|
438
|
-
const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
|
|
439
|
-
|| buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
|
|
440
|
-
const eligible = FAST_PATH_MODES.has(executionMode)
|
|
441
|
-
&& hasLocalTarget
|
|
442
|
-
&& riskFloor.floor === 'none'
|
|
443
|
-
&& hasBoundedVerification
|
|
444
|
-
&& !isSharedImpactFile(targetFile);
|
|
445
|
-
return {
|
|
446
|
-
eligible,
|
|
447
|
-
suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
|
|
448
|
-
reasons,
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Route-line segment (FR-004): emitted only for eligible routes — ineligible
|
|
453
|
-
// routes keep the routeSummary.fastPath field for telemetry but stay silent.
|
|
454
|
-
function formatFastPathSegment(fastPath = null) {
|
|
455
|
-
if (!fastPath?.eligible) {
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
const reasons = fastPath.reasons?.length ? ` (${fastPath.reasons.join(',')})` : '';
|
|
459
|
-
return `fastPath=on | suppress: ${FAST_PATH_SUPPRESSED.join(',')}${reasons}`;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
|
|
463
|
-
// repository mutation the ledger could ever receipt. With no edit/review/debug/build
|
|
464
|
-
// signal present, routing it to an investigation lane fabricates write debt and the
|
|
465
|
-
// completion gate then demands an edit that cannot exist. Extracted from
|
|
466
|
-
// deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
|
|
467
|
-
// (delivery-only → 'delivery') instead of duplicating the regexes.
|
|
468
|
-
function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
|
|
469
|
-
const signalRaw = String(signalText || '').toLowerCase();
|
|
470
|
-
const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
|
|
471
|
-
|| /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
|
|
472
|
-
|| /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
|
|
473
|
-
|| /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
|
|
474
|
-
return deliveryWordSignal
|
|
475
|
-
&& scores.editCertainty === 0
|
|
476
|
-
&& !scores.implementSignal
|
|
477
|
-
&& !scores.reviewSignal
|
|
478
|
-
&& !scores.debugSignal
|
|
479
|
-
&& !scores.failureSignal
|
|
480
|
-
&& !scores.impactSignal
|
|
481
|
-
&& !scores.buildSignal
|
|
482
|
-
&& !scores.directTransformSignal
|
|
483
|
-
&& !scores.smallFixSignal
|
|
484
|
-
&& !scores.sharedRisk
|
|
485
|
-
&& !targetFile;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
|
|
489
|
-
// intent.kind; intentMode maps to kind as question/explanation → informational,
|
|
490
|
-
// ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
|
|
491
|
-
// investigation, review/audit → review.
|
|
492
|
-
function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
|
|
493
|
-
if (executionMode === 'find-cause') return 'investigation';
|
|
494
|
-
if (executionMode === 'review-release') return 'review';
|
|
495
|
-
if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
|
|
496
|
-
if (executionMode) return 'mutation';
|
|
497
|
-
if (intentMode === 'review-specific') return 'review';
|
|
498
|
-
if (intentMode === 'debug-specific') return 'investigation';
|
|
499
|
-
if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
|
|
500
|
-
return 'informational';
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Existing read-only vs mutating classification: only the informational lane carries
|
|
504
|
-
// no write debt; every contract lane is mutating.
|
|
505
|
-
function deriveRouteMutability(executionMode = null) {
|
|
506
|
-
return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function compactRouteGoal(text = '') {
|
|
510
|
-
const goal = String(text || '').trim();
|
|
511
|
-
if (!goal) return null;
|
|
512
|
-
return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Builds the additive C01 groups for one resolved route. rigor stays null until
|
|
516
|
-
// M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
|
|
517
|
-
// populate; escalation.current mirrors the selected mode.
|
|
518
|
-
function buildResolvedRouteFields({
|
|
519
|
-
routingContext = {},
|
|
520
|
-
activeSkillIds = [],
|
|
521
|
-
executionMode = null,
|
|
522
|
-
executionContract = null,
|
|
523
|
-
completionState = null,
|
|
524
|
-
riskFloor = null,
|
|
525
|
-
} = {}) {
|
|
526
|
-
// Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
|
|
527
|
-
// contract lane and the additive riskFloor. The router cannot observe host
|
|
528
|
-
// binding capabilities, so the emitted pair is advisory text by definition.
|
|
529
|
-
const tierDecision = resolveModelTier({ executionMode, riskFloor });
|
|
530
|
-
const signalText = buildNormalizedRouteSignalText(routingContext.promptText, routingContext.commandText);
|
|
531
|
-
const deliveryOnly = isDeliveryOnlyRequest({
|
|
532
|
-
signalText,
|
|
533
|
-
scores: routingContext.executionScores ?? {},
|
|
534
|
-
targetFile: routingContext.targetFile ?? null,
|
|
535
|
-
});
|
|
536
|
-
return {
|
|
537
|
-
routeVersion: ROUTE_VERSION,
|
|
538
|
-
intent: {
|
|
539
|
-
kind: deriveRouteIntentKind({
|
|
540
|
-
executionMode,
|
|
541
|
-
intentMode: routingContext.intentMode ?? null,
|
|
542
|
-
deliveryOnly,
|
|
543
|
-
}),
|
|
544
|
-
mutability: deriveRouteMutability(executionMode),
|
|
545
|
-
goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
|
|
546
|
-
doneConditions: [],
|
|
547
|
-
},
|
|
548
|
-
execution: {
|
|
549
|
-
mode: executionMode,
|
|
550
|
-
rigor: null,
|
|
551
|
-
riskFloor: riskFloor?.floor ?? null,
|
|
552
|
-
phase: null,
|
|
553
|
-
contractVersion: ROUTE_CONTRACT_VERSION,
|
|
554
|
-
modelTier: tierDecision.tier,
|
|
555
|
-
effort: tierDecision.effort,
|
|
556
|
-
},
|
|
557
|
-
evidence: {
|
|
558
|
-
observations: [],
|
|
559
|
-
riskSignals: riskFloor?.codes ?? [],
|
|
560
|
-
activationReasons: [],
|
|
561
|
-
suppressionReasons: [],
|
|
562
|
-
completionRequirements: unique(completionState?.missingEvidence ?? []),
|
|
563
|
-
},
|
|
564
|
-
ceremonyBudget: {
|
|
565
|
-
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
566
|
-
rigor: null,
|
|
567
|
-
limits: riskFloor
|
|
568
|
-
? Object.fromEntries(
|
|
569
|
-
['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
|
|
570
|
-
.filter((key) => typeof executionContract?.[key] === 'number')
|
|
571
|
-
.map((key) => [key, executionContract[key]]),
|
|
572
|
-
)
|
|
573
|
-
: {},
|
|
574
|
-
consumed: {},
|
|
575
|
-
exceptions: [],
|
|
576
|
-
},
|
|
577
|
-
capabilityPolicy: {
|
|
578
|
-
policyVersion: ROUTE_CONTRACT_VERSION,
|
|
579
|
-
required: [],
|
|
580
|
-
recommended: [],
|
|
581
|
-
suppressed: [],
|
|
582
|
-
activeSkillIds: unique(activeSkillIds),
|
|
583
|
-
},
|
|
584
|
-
escalation: {
|
|
585
|
-
current: { mode: executionMode, rigor: null },
|
|
586
|
-
ceiling: ROUTE_ESCALATION_CEILING,
|
|
587
|
-
triggers: [],
|
|
588
|
-
history: [],
|
|
589
|
-
},
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
|
|
594
|
-
// throws and never inspects legacy fields — old consumers may carry anything else.
|
|
595
|
-
export function validateResolvedRoute(route = null) {
|
|
596
|
-
const errors = [];
|
|
597
|
-
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
598
|
-
if (!isObject(route)) {
|
|
599
|
-
return { valid: false, errors: ['route must be an object.'] };
|
|
600
|
-
}
|
|
601
|
-
if (route.routeVersion !== ROUTE_VERSION) {
|
|
602
|
-
errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
|
|
603
|
-
}
|
|
604
|
-
if (!isObject(route.intent)) {
|
|
605
|
-
errors.push('intent must be an object.');
|
|
606
|
-
} else {
|
|
607
|
-
if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
|
|
608
|
-
errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
|
|
609
|
-
}
|
|
610
|
-
if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
|
|
611
|
-
errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
|
|
612
|
-
}
|
|
613
|
-
if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
|
|
614
|
-
errors.push('intent.goal must be a string or null.');
|
|
615
|
-
}
|
|
616
|
-
if (!Array.isArray(route.intent.doneConditions)) {
|
|
617
|
-
errors.push('intent.doneConditions must be an array.');
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (!isObject(route.execution)) {
|
|
621
|
-
errors.push('execution must be an object.');
|
|
622
|
-
} else {
|
|
623
|
-
if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
|
|
624
|
-
errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
625
|
-
}
|
|
626
|
-
if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
|
|
627
|
-
errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
628
|
-
}
|
|
629
|
-
if (route.execution.riskFloor !== null && !ROUTE_RISK_FLOORS.has(route.execution.riskFloor)) {
|
|
630
|
-
errors.push(`execution.riskFloor must be null or one of: ${[...ROUTE_RISK_FLOORS].join(', ')}.`);
|
|
631
|
-
}
|
|
632
|
-
if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
|
|
633
|
-
errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
|
|
634
|
-
}
|
|
635
|
-
if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
|
|
636
|
-
errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
|
|
637
|
-
}
|
|
638
|
-
if (route.execution.effort !== null && route.execution.effort !== undefined
|
|
639
|
-
&& !ROUTE_EFFORTS.has(route.execution.effort)) {
|
|
640
|
-
errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
if (!isObject(route.evidence)) {
|
|
644
|
-
errors.push('evidence must be an object.');
|
|
645
|
-
} else {
|
|
646
|
-
for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
|
|
647
|
-
if (!Array.isArray(route.evidence[key])) {
|
|
648
|
-
errors.push(`evidence.${key} must be an array.`);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
if (Array.isArray(route.evidence.riskSignals)
|
|
652
|
-
&& route.evidence.riskSignals.some(
|
|
653
|
-
(code) => typeof code !== 'string' || !ROUTE_RISK_REASON_CODES.includes(code),
|
|
654
|
-
)) {
|
|
655
|
-
errors.push(`evidence.riskSignals entries must be one of: ${ROUTE_RISK_REASON_CODES.join(', ')}.`);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
if (!isObject(route.ceremonyBudget)) {
|
|
659
|
-
errors.push('ceremonyBudget must be an object.');
|
|
660
|
-
} else {
|
|
661
|
-
if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
|
|
662
|
-
errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
663
|
-
}
|
|
664
|
-
if (!isObject(route.ceremonyBudget.limits)) {
|
|
665
|
-
errors.push('ceremonyBudget.limits must be an object.');
|
|
666
|
-
}
|
|
667
|
-
if (!isObject(route.ceremonyBudget.consumed)) {
|
|
668
|
-
errors.push('ceremonyBudget.consumed must be an object.');
|
|
669
|
-
}
|
|
670
|
-
if (!Array.isArray(route.ceremonyBudget.exceptions)) {
|
|
671
|
-
errors.push('ceremonyBudget.exceptions must be an array.');
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
if (!isObject(route.capabilityPolicy)) {
|
|
675
|
-
errors.push('capabilityPolicy must be an object.');
|
|
676
|
-
} else {
|
|
677
|
-
for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
|
|
678
|
-
if (!Array.isArray(route.capabilityPolicy[key])) {
|
|
679
|
-
errors.push(`capabilityPolicy.${key} must be an array.`);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
if (!isObject(route.escalation)) {
|
|
684
|
-
errors.push('escalation must be an object.');
|
|
685
|
-
} else {
|
|
686
|
-
if (!isObject(route.escalation.current)) {
|
|
687
|
-
errors.push('escalation.current must be an object.');
|
|
688
|
-
} else {
|
|
689
|
-
if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
|
|
690
|
-
errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
|
|
691
|
-
}
|
|
692
|
-
if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
|
|
693
|
-
errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
if (!Array.isArray(route.escalation.triggers)) {
|
|
697
|
-
errors.push('escalation.triggers must be an array.');
|
|
698
|
-
}
|
|
699
|
-
if (!Array.isArray(route.escalation.history)) {
|
|
700
|
-
errors.push('escalation.history must be an array.');
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
return { valid: errors.length === 0, errors };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
|
|
707
|
-
// get a bounded view that may omit verbose evidence but never mode, rigor, required
|
|
708
|
-
// completion evidence, or escalation state. Returns null for legacy (stage-off)
|
|
709
|
-
// summaries so compact output stays byte-identical when the schema stage is off.
|
|
710
|
-
export function compactResolvedRoute(routeSummary = null) {
|
|
711
|
-
if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
return {
|
|
715
|
-
routeVersion: routeSummary.routeVersion,
|
|
716
|
-
intent: routeSummary.intent ?? null,
|
|
717
|
-
execution: routeSummary.execution ?? null,
|
|
718
|
-
evidence: {
|
|
719
|
-
completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
|
|
720
|
-
},
|
|
721
|
-
ceremonyBudget: routeSummary.ceremonyBudget ?? null,
|
|
722
|
-
capabilityPolicy: routeSummary.capabilityPolicy ?? null,
|
|
723
|
-
escalation: routeSummary.escalation ?? null,
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
319
|
const STOPWORDS = new Set([
|
|
727
320
|
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
728
321
|
'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
|
|
@@ -1432,6 +1025,16 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
|
|
|
1432
1025
|
previousContextSnapshot,
|
|
1433
1026
|
recentOutputSnapshot,
|
|
1434
1027
|
});
|
|
1028
|
+
// BL-013: bounded history signals from the caller-supplied transcript
|
|
1029
|
+
// pointer (input.transcriptPath / UKIT_TRANSCRIPT_PATH). Degrades to the
|
|
1030
|
+
// zeroed struct when absent — never throws, never extends the budget.
|
|
1031
|
+
const historySignals = await extractHistorySignalsAsync({
|
|
1032
|
+
transcriptPath: normalizedInput.transcriptPath
|
|
1033
|
+
?? process.env.UKIT_TRANSCRIPT_PATH
|
|
1034
|
+
?? null,
|
|
1035
|
+
routeFingerprint: normalizedInput.routeFingerprint ?? null,
|
|
1036
|
+
requestKey,
|
|
1037
|
+
});
|
|
1435
1038
|
const helperCacheSnapshot = await readHelperCacheSnapshot({
|
|
1436
1039
|
rootDir: absoluteRoot,
|
|
1437
1040
|
indexGeneratedAtMs,
|
|
@@ -1449,10 +1052,17 @@ export async function routeTask(input = {}, { signal = null, deadlineMs = null,
|
|
|
1449
1052
|
useIndexedContext,
|
|
1450
1053
|
previousContextSnapshot,
|
|
1451
1054
|
recentOutputSnapshot,
|
|
1452
|
-
previousRouteSummary: null,
|
|
1055
|
+
previousRouteSummary: normalizedInput.previousRouteSummary ?? null,
|
|
1453
1056
|
escalationConfig,
|
|
1057
|
+
// BL-018: caller-injected seams — the in-process entry accepts a prior
|
|
1058
|
+
// emission (exhaustion check) and a stubbed decision/registrar for tests.
|
|
1059
|
+
escalationDecision: normalizedInput.escalationDecision ?? null,
|
|
1060
|
+
// C89 TASK-005: typed delegation-advice consult seam (same {ask,
|
|
1061
|
+
// appendReceipt, override} contract as src deriveTaskRoute).
|
|
1062
|
+
roleAdviceDecision: normalizedInput.roleAdviceDecision ?? null,
|
|
1454
1063
|
sessionId: normalizedInput.sessionId ?? null,
|
|
1455
1064
|
indexGeneratedAtMs,
|
|
1065
|
+
historySignals,
|
|
1456
1066
|
});
|
|
1457
1067
|
const sharedState = createSharedRouteState({
|
|
1458
1068
|
route,
|
|
@@ -1491,6 +1101,19 @@ async function main() {
|
|
|
1491
1101
|
const routeCachePath = path.join(rootDir, '.claude', 'ukit', 'route-cache.json');
|
|
1492
1102
|
const routeAuditPath = path.join(rootDir, '.ukit', 'storage', 'cache', 'route-audit.json');
|
|
1493
1103
|
const persistedPreviousState = await readJson(sharedStatePath, {});
|
|
1104
|
+
// BL-017 (SPEC FR-005): detached hook-path shadow pass — skill-router.sh
|
|
1105
|
+
// spawns this fire-and-forget per emitted route. Self-deadline so a wedged
|
|
1106
|
+
// import/read can never leave an orphan (same watchdog posture as the hook).
|
|
1107
|
+
if (args.includes('--decision-shadow')) {
|
|
1108
|
+
setTimeout(() => process.exit(0), DECISION_SHADOW_TIMEOUT_CAP_MS * 3).unref();
|
|
1109
|
+
await runHookPathDecisionShadow({
|
|
1110
|
+
rootDir,
|
|
1111
|
+
statePath: sharedStatePath,
|
|
1112
|
+
targetFile: readFlagValue(args, '--target-file') ?? null,
|
|
1113
|
+
});
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1494
1117
|
const sessionId = normalizeSessionId(readFlagValue(args, '--session-id'));
|
|
1495
1118
|
const previousStateOwnershipBlocked = hasRouteStateOwner(persistedPreviousState)
|
|
1496
1119
|
&& !isRouteStateCompatible(persistedPreviousState, sessionId);
|
|
@@ -1574,6 +1197,20 @@ async function main() {
|
|
|
1574
1197
|
previousContextSnapshot,
|
|
1575
1198
|
recentOutputSnapshot,
|
|
1576
1199
|
});
|
|
1200
|
+
// BL-013: bounded session-history signals from the transcript pointer
|
|
1201
|
+
// (--transcript flag or UKIT_TRANSCRIPT_PATH env; absent → degraded zeroed
|
|
1202
|
+
// struct). Extracted once here so provisional/cached/final emissions carry
|
|
1203
|
+
// the identical counters/enums — never raw transcript text.
|
|
1204
|
+
const transcriptPath = readFlagValue(args, '--transcript')
|
|
1205
|
+
?? process.env.UKIT_TRANSCRIPT_PATH
|
|
1206
|
+
?? null;
|
|
1207
|
+
const historySignals = await extractHistorySignalsAsync({
|
|
1208
|
+
transcriptPath,
|
|
1209
|
+
routeFingerprint: previousState?.requestKey === requestKey
|
|
1210
|
+
? previousState?.fingerprint ?? null
|
|
1211
|
+
: null,
|
|
1212
|
+
requestKey,
|
|
1213
|
+
});
|
|
1577
1214
|
const reusableState = reuseSharedRouteState({
|
|
1578
1215
|
previousState,
|
|
1579
1216
|
requestKey,
|
|
@@ -1594,6 +1231,15 @@ async function main() {
|
|
|
1594
1231
|
const canPersistCachedRouteState = !cachedRouteStateOwnershipBlocked;
|
|
1595
1232
|
|
|
1596
1233
|
if (reusableState) {
|
|
1234
|
+
// BL-013: refresh the bounded counters on a same-requestKey reuse — the
|
|
1235
|
+
// emitted state always carries current signals even when the route body
|
|
1236
|
+
// is reused verbatim.
|
|
1237
|
+
if (historySignals && reusableState.routeSummary) {
|
|
1238
|
+
reusableState.routeSummary = {
|
|
1239
|
+
...reusableState.routeSummary,
|
|
1240
|
+
historySignals,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1597
1243
|
printRouteState(reusableState);
|
|
1598
1244
|
return;
|
|
1599
1245
|
}
|
|
@@ -1635,11 +1281,43 @@ async function main() {
|
|
|
1635
1281
|
} else {
|
|
1636
1282
|
delete rescuedRouteSummary.riskEscalation;
|
|
1637
1283
|
}
|
|
1284
|
+
// BL-013: refresh the bounded counters on a cached-route reuse — identical
|
|
1285
|
+
// merge rule as the prior-state reuse above.
|
|
1286
|
+
if (historySignals) {
|
|
1287
|
+
rescuedRouteSummary.historySignals = historySignals;
|
|
1288
|
+
}
|
|
1638
1289
|
applyRescueBiasToRouteSummary(rescuedRouteSummary);
|
|
1639
1290
|
applyEscalationToRouteSummary(rescuedRouteSummary, {
|
|
1640
1291
|
config: escalationConfig,
|
|
1641
1292
|
targetFile: cachedRouteState.routingContext?.targetFile ?? null,
|
|
1642
1293
|
});
|
|
1294
|
+
// BL-018: re-run the fix-loop transition on the rescued summary — a
|
|
1295
|
+
// refreshed fixLoopCount may trip, escalate, or exhaust (BLOCKED); the
|
|
1296
|
+
// cached summary doubles as the previous emission for exhaustion.
|
|
1297
|
+
if (isFixLoopEscalationDue({ historySignals: rescuedRouteSummary.historySignals, config: escalationConfig })) {
|
|
1298
|
+
// Same fingerprint the persisted sharedState carries below — the
|
|
1299
|
+
// escalation record stamps it so the next emission's same-loop join
|
|
1300
|
+
// (exhaustion/blocked-terminal/preserve) binds only this route.
|
|
1301
|
+
const rescuedRouteFingerprint = buildRouteStateFingerprint({
|
|
1302
|
+
...cachedRouteState,
|
|
1303
|
+
requestKey,
|
|
1304
|
+
routeSummary: rescuedRouteSummary,
|
|
1305
|
+
});
|
|
1306
|
+
await applyFixLoopEscalation({
|
|
1307
|
+
routeSummary: rescuedRouteSummary,
|
|
1308
|
+
routingContext: cachedRouteState.routingContext ?? {},
|
|
1309
|
+
previousRouteSummary: cachedRouteState.routeSummary,
|
|
1310
|
+
routeFingerprint: rescuedRouteFingerprint,
|
|
1311
|
+
config: escalationConfig,
|
|
1312
|
+
ask: ({ batch }) => askFixLoopEscalationDecision({
|
|
1313
|
+
rootDir,
|
|
1314
|
+
config: escalationConfig,
|
|
1315
|
+
batch,
|
|
1316
|
+
}),
|
|
1317
|
+
appendReceipt: (receipt) => appendDecisionReceipt(rootDir, receipt),
|
|
1318
|
+
runtimeForensicsPresent: await runtimeForensicsRegistered({ projectRoot: rootDir }),
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1643
1321
|
appendRiskEscalationSegment(rescuedRouteSummary);
|
|
1644
1322
|
}
|
|
1645
1323
|
const sharedState = {
|
|
@@ -1688,6 +1366,7 @@ async function main() {
|
|
|
1688
1366
|
escalationConfig,
|
|
1689
1367
|
sessionId,
|
|
1690
1368
|
indexGeneratedAtMs,
|
|
1369
|
+
historySignals,
|
|
1691
1370
|
});
|
|
1692
1371
|
await seedHelperCaches({
|
|
1693
1372
|
rootDir,
|
|
@@ -1878,62 +1557,6 @@ async function prepareTaskRoute({
|
|
|
1878
1557
|
const UNIC_DECISION_CLI_PATH = path.join(__routeTaskDir, 'unic-decision.mjs');
|
|
1879
1558
|
const DECISION_SHADOW_TIMEOUT_CAP_MS = 3000;
|
|
1880
1559
|
|
|
1881
|
-
// Route-side shadow questions — mirror of the DECISION_REGISTRY entries owned
|
|
1882
|
-
// by taskRouting outside the preflight bundle (src/decision/registry.js).
|
|
1883
|
-
// Candidates are protocol-local labels, not user-facing prose.
|
|
1884
|
-
const ROUTE_SHADOW_QUESTIONS = Object.freeze([
|
|
1885
|
-
{
|
|
1886
|
-
decisionKey: 'route.intent-kind.v1',
|
|
1887
|
-
family: 'route',
|
|
1888
|
-
kind: 'choice',
|
|
1889
|
-
instruction: 'Intent kind for the route: informational | mutation | investigation | review | delivery.',
|
|
1890
|
-
candidates: ['informational', 'mutation', 'investigation', 'review', 'delivery'],
|
|
1891
|
-
},
|
|
1892
|
-
{
|
|
1893
|
-
decisionKey: 'route.rigor.v1',
|
|
1894
|
-
family: 'rigor',
|
|
1895
|
-
kind: 'score',
|
|
1896
|
-
instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
|
|
1897
|
-
candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
|
|
1898
|
-
},
|
|
1899
|
-
{
|
|
1900
|
-
decisionKey: 'resume.next-action.v1',
|
|
1901
|
-
family: 'resume',
|
|
1902
|
-
kind: 'choice',
|
|
1903
|
-
instruction: 'Next resumable action at the continuation boundary.',
|
|
1904
|
-
// deriveNextAction vocabulary (read from source); the rescue-bias override
|
|
1905
|
-
// 'execute-current-milestone' can overwrite nextActionType post-derivation —
|
|
1906
|
-
// baseline compare then scores 'disagree', which is a real signal, not noise.
|
|
1907
|
-
candidates: [
|
|
1908
|
-
'ask-user-confirmation',
|
|
1909
|
-
'run-primary-verification',
|
|
1910
|
-
'run-fallback-verification',
|
|
1911
|
-
'pull-indexed-context',
|
|
1912
|
-
'read-skill-instructions',
|
|
1913
|
-
'inspect-structure',
|
|
1914
|
-
],
|
|
1915
|
-
},
|
|
1916
|
-
{
|
|
1917
|
-
decisionKey: 'verify.depth.v1',
|
|
1918
|
-
family: 'verify',
|
|
1919
|
-
kind: 'choice',
|
|
1920
|
-
instruction: 'Verification depth among allowed levels.',
|
|
1921
|
-
candidates: ['sanity', 'targeted', 'impact', 'full'],
|
|
1922
|
-
},
|
|
1923
|
-
// NOT wired — recorded per migration-backlog review:
|
|
1924
|
-
// capability.impact.v1 — noul kind; no threshold baseline exists on
|
|
1925
|
-
// routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
|
|
1926
|
-
// the question has no comparison value.
|
|
1927
|
-
// learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
|
|
1928
|
-
// (memoryFlags.js), not the route boundary.
|
|
1929
|
-
]);
|
|
1930
|
-
|
|
1931
|
-
// Only families whose resolved stage is not 'off' get a question.
|
|
1932
|
-
export function buildShadowDecisionQuestions(config = null) {
|
|
1933
|
-
return ROUTE_SHADOW_QUESTIONS
|
|
1934
|
-
.filter((q) => resolveDecisionFamilyStage(config, q.family) !== 'off')
|
|
1935
|
-
.map(({ family, ...question }) => question);
|
|
1936
|
-
}
|
|
1937
1560
|
|
|
1938
1561
|
// Probability → band for receipts. Bands only; raw probabilities never leave
|
|
1939
1562
|
// the adapter result.
|
|
@@ -1946,6 +1569,11 @@ function probabilityBand(value) {
|
|
|
1946
1569
|
|
|
1947
1570
|
// Deterministic baselines the shadow answers are compared against. Only fields
|
|
1948
1571
|
// the deterministic route already computes; null baseline → 'unknown'.
|
|
1572
|
+
// BL-017: the adopted families map to the fields the route already resolved —
|
|
1573
|
+
// playbook-select → playbookId ('none' when unrouted), laneDeepening →
|
|
1574
|
+
// executionMode. The review/cross-check/verification-depth baselines are
|
|
1575
|
+
// produced by later wave modules; until then their baseline stays null and
|
|
1576
|
+
// receipts record bands + agreement 'unknown' — real data, not fabricated.
|
|
1949
1577
|
function shadowBaselineFor(decisionKey, routeSummary) {
|
|
1950
1578
|
if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
|
|
1951
1579
|
if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
|
|
@@ -1960,6 +1588,17 @@ function shadowBaselineFor(decisionKey, routeSummary) {
|
|
|
1960
1588
|
?? routeSummary?.resolvedVerificationPlan?.depth
|
|
1961
1589
|
?? null;
|
|
1962
1590
|
}
|
|
1591
|
+
if (decisionKey === 'playbook.select.v1') {
|
|
1592
|
+
return routeSummary?.playbookId ?? 'none';
|
|
1593
|
+
}
|
|
1594
|
+
if (decisionKey === 'lane.deepening.v1') {
|
|
1595
|
+
return routeSummary?.executionMode ?? null;
|
|
1596
|
+
}
|
|
1597
|
+
// BL-019: the deterministic pick is the effective tier — measuredTier when
|
|
1598
|
+
// the promoted map already owns the route, else the static modelTier.
|
|
1599
|
+
if (decisionKey === 'tier.selection.v1') {
|
|
1600
|
+
return routeSummary?.measuredTier ?? routeSummary?.executionContract?.modelTier ?? null;
|
|
1601
|
+
}
|
|
1963
1602
|
return null;
|
|
1964
1603
|
}
|
|
1965
1604
|
|
|
@@ -1976,8 +1615,9 @@ function answerCandidateLabel(answer, question) {
|
|
|
1976
1615
|
|
|
1977
1616
|
// Fold a CLI batch result into the redacted receipt shape (FR-009):
|
|
1978
1617
|
// {decisionKeys, probabilityBands, outcomeClass, checkpoint, latencyClass,
|
|
1979
|
-
//
|
|
1980
|
-
|
|
1618
|
+
// fallbackCode, agreement, batchId}. Never carries state text, prompts, or
|
|
1619
|
+
// secrets.
|
|
1620
|
+
function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [], batchId = null }) {
|
|
1981
1621
|
const questionByKey = new Map(questions.map((q) => [q.decisionKey, q]));
|
|
1982
1622
|
const answers = Array.isArray(result?.answers) ? result.answers : [];
|
|
1983
1623
|
const probabilityBands = {};
|
|
@@ -2006,9 +1646,69 @@ function buildDecisionShadowReceipt({ stage, result, routeSummary, questions = [
|
|
|
2006
1646
|
latencyClass: result?.latencyClass ?? 'unknown',
|
|
2007
1647
|
fallbackCode: result?.fallbackCode ?? null,
|
|
2008
1648
|
agreement,
|
|
1649
|
+
batchId,
|
|
2009
1650
|
};
|
|
2010
1651
|
}
|
|
2011
1652
|
|
|
1653
|
+
// BL-017 (SPEC FR-005): per-question receipt rows — one decisions.tsv append
|
|
1654
|
+
// per enabled question in the batch, all sharing `batchId` (carried inside the
|
|
1655
|
+
// checkpoint cell: the receipt signature is consumed unchanged). An answered
|
|
1656
|
+
// question scores its own agreement against the deterministic baseline; an
|
|
1657
|
+
// adapter miss keeps fallbackCode 'no-answer' rather than dropping the row —
|
|
1658
|
+
// a silent gap is how coverage numbers go stale. Typed failures ride kind
|
|
1659
|
+
// 'fallback' so the deterministic policy visibly stayed in force.
|
|
1660
|
+
async function appendPerQuestionShadowReceipts({
|
|
1661
|
+
rootDir,
|
|
1662
|
+
stage,
|
|
1663
|
+
result,
|
|
1664
|
+
routeSummary,
|
|
1665
|
+
questions,
|
|
1666
|
+
batchId,
|
|
1667
|
+
appendDecisionImpl,
|
|
1668
|
+
now,
|
|
1669
|
+
}) {
|
|
1670
|
+
const append = appendDecisionImpl ?? appendDecisionReceipt;
|
|
1671
|
+
const answers = Array.isArray(result?.answers) ? result.answers : [];
|
|
1672
|
+
const answerByKey = new Map(
|
|
1673
|
+
answers
|
|
1674
|
+
.filter((a) => a?.decisionKey && a?.validationStatus === 'valid')
|
|
1675
|
+
.map((a) => [a.decisionKey, a]),
|
|
1676
|
+
);
|
|
1677
|
+
const outcomeClass = result?.status ?? 'unavailable';
|
|
1678
|
+
const kind = outcomeClass === 'unavailable' ? 'fallback' : 'shadow';
|
|
1679
|
+
const checkpointCell = `${result?.checkpoint ?? 'no-checkpoint'} batch=${batchId}`;
|
|
1680
|
+
for (const question of questions) {
|
|
1681
|
+
const answer = answerByKey.get(question.decisionKey) ?? null;
|
|
1682
|
+
let agreement = 'unknown';
|
|
1683
|
+
if (answer) {
|
|
1684
|
+
const label = answerCandidateLabel(answer, question);
|
|
1685
|
+
const baseline = shadowBaselineFor(question.decisionKey, routeSummary);
|
|
1686
|
+
if (label !== null && baseline !== null && baseline !== undefined) {
|
|
1687
|
+
agreement = label.toLowerCase() === String(baseline).toLowerCase()
|
|
1688
|
+
? 'agree'
|
|
1689
|
+
: 'disagree';
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const fallbackCode = result?.fallbackCode ?? (answer ? null : 'no-answer');
|
|
1693
|
+
try {
|
|
1694
|
+
await append(rootDir, {
|
|
1695
|
+
kind,
|
|
1696
|
+
boundary: 'route',
|
|
1697
|
+
stage,
|
|
1698
|
+
outcomeClass,
|
|
1699
|
+
checkpoint: checkpointCell,
|
|
1700
|
+
latencyClass: result?.latencyClass ?? 'unknown',
|
|
1701
|
+
decisionKeys: [question.decisionKey],
|
|
1702
|
+
agreement,
|
|
1703
|
+
fallbackCode,
|
|
1704
|
+
now: now(),
|
|
1705
|
+
});
|
|
1706
|
+
} catch {
|
|
1707
|
+
// Receipt loss is advisory — never fail the route over telemetry.
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
2012
1712
|
/**
|
|
2013
1713
|
* Run the decision-plane shadow adapter for a finalized route. Returns the
|
|
2014
1714
|
* redacted receipt, or null when the plane is off / no family is enabled.
|
|
@@ -2041,8 +1741,12 @@ export async function runRouteDecisionShadow({
|
|
|
2041
1741
|
: DECISION_SHADOW_TIMEOUT_CAP_MS) + 1000,
|
|
2042
1742
|
DECISION_SHADOW_TIMEOUT_CAP_MS + 1000,
|
|
2043
1743
|
);
|
|
1744
|
+
const batchId = `route-shadow-${now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
1745
|
+
// BL-017: historySignals feed laneDeepening/verificationDepth features —
|
|
1746
|
+
// bounded counters only (BL-013 contract: never transcript text).
|
|
1747
|
+
const hist = routeSummary?.historySignals ?? null;
|
|
2044
1748
|
const batch = {
|
|
2045
|
-
batchId
|
|
1749
|
+
batchId,
|
|
2046
1750
|
boundary: 'route',
|
|
2047
1751
|
deadlineMs: Math.max(500, timeoutMs - 1000),
|
|
2048
1752
|
statePacket: {
|
|
@@ -2052,6 +1756,15 @@ export async function runRouteDecisionShadow({
|
|
|
2052
1756
|
targetClass: routingContext.targetFile ? 'file-target' : 'no-target',
|
|
2053
1757
|
execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
|
|
2054
1758
|
riskSignals: routeSummary?.riskFloor?.codes ?? [],
|
|
1759
|
+
history: hist
|
|
1760
|
+
? {
|
|
1761
|
+
priorAttemptCount: Math.max(0, Math.trunc(hist.priorAttemptCount ?? 0) || 0),
|
|
1762
|
+
fixLoopCount: Math.max(0, Math.trunc(hist.fixLoopCount ?? 0) || 0),
|
|
1763
|
+
sameSymptomReask: hist.sameSymptomReask === true,
|
|
1764
|
+
correctionEvents: Math.max(0, Math.trunc(hist.correctionEvents ?? hist.correctionCount ?? 0) || 0),
|
|
1765
|
+
degraded: hist.degraded === true,
|
|
1766
|
+
}
|
|
1767
|
+
: null,
|
|
2055
1768
|
},
|
|
2056
1769
|
questions,
|
|
2057
1770
|
};
|
|
@@ -2088,27 +1801,145 @@ export async function runRouteDecisionShadow({
|
|
|
2088
1801
|
};
|
|
2089
1802
|
}
|
|
2090
1803
|
|
|
2091
|
-
const receipt = buildDecisionShadowReceipt({
|
|
2092
|
-
|
|
2093
|
-
|
|
1804
|
+
const receipt = buildDecisionShadowReceipt({
|
|
1805
|
+
stage,
|
|
1806
|
+
result,
|
|
1807
|
+
routeSummary,
|
|
1808
|
+
questions,
|
|
1809
|
+
batchId,
|
|
1810
|
+
});
|
|
1811
|
+
// BL-017: batched emission — one append-only decisions.tsv row per enabled
|
|
1812
|
+
// question sharing batchId; codes/bands only via the ledger's typed receipt
|
|
1813
|
+
// writer (statically imported above alongside recordLedgerEvent). Receipt
|
|
1814
|
+
|
|
1815
|
+
// append failures stay advisory inside the helper.
|
|
1816
|
+
await appendPerQuestionShadowReceipts({
|
|
1817
|
+
rootDir,
|
|
1818
|
+
stage,
|
|
1819
|
+
result,
|
|
1820
|
+
routeSummary,
|
|
1821
|
+
questions,
|
|
1822
|
+
batchId,
|
|
1823
|
+
appendDecisionImpl,
|
|
1824
|
+
now,
|
|
1825
|
+
});
|
|
1826
|
+
// BL-019 (SPEC FR-001): the measured-vs-static A/B row — logged once per
|
|
1827
|
+
// route whenever a derived tier-map.json exists and the tier-selection
|
|
1828
|
+
// family isn't stage-off (inherited shadow stage). The raw map pick is
|
|
1829
|
+
// logged even on 'hold' (the A/B collection is the point); the static tier
|
|
1830
|
+
// is rebuilt from the contract literal so a promoted modelTier can't fake
|
|
1831
|
+
// its own baseline. Bounded cells only — tier names + counts + promotion.
|
|
1832
|
+
if (resolveDecisionFamilyStage(config, 'tier-selection') !== 'off') {
|
|
1833
|
+
try {
|
|
1834
|
+
const tierMap = await loadTierMap(rootDir);
|
|
1835
|
+
if (tierMap) {
|
|
1836
|
+
const staticTier = buildExecutionContract(routeSummary?.executionMode)?.modelTier
|
|
1837
|
+
?? routeSummary?.executionContract?.modelTier
|
|
1838
|
+
?? null;
|
|
1839
|
+
const measuredPick = tierMap.byMode?.[routeSummary?.executionMode] ?? null;
|
|
1840
|
+
const append = appendDecisionImpl ?? appendDecisionReceipt;
|
|
1841
|
+
await append(rootDir, {
|
|
1842
|
+
kind: 'shadow',
|
|
1843
|
+
boundary: 'tier-selection',
|
|
1844
|
+
stage,
|
|
1845
|
+
outcomeClass: 'tier-ab',
|
|
1846
|
+
checkpoint: `batch=${batchId} promotion=${tierMap.decision} joined=${tierMap.joinedRoutes} agreement=${(tierMap.agreement ?? 0).toFixed(2)} static=${staticTier ?? 'unknown'} measured=${measuredPick ?? 'none'}`,
|
|
1847
|
+
latencyClass: 'fast',
|
|
1848
|
+
decisionKeys: ['tier.selection.v1'],
|
|
1849
|
+
agreement: staticTier && measuredPick
|
|
1850
|
+
? (measuredPick === staticTier ? 'agree' : 'disagree')
|
|
1851
|
+
: 'unknown',
|
|
1852
|
+
fallbackCode: measuredPick ? null : 'no-measured-pick',
|
|
1853
|
+
now: now(),
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
} catch {
|
|
1857
|
+
// Receipt loss is advisory — never fail the route over telemetry.
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return receipt;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// BL-018 (SPEC FR-006): the bounded-question `ask` seam — one batch through
|
|
1864
|
+
// the installed unic-decision CLI (the same adapter the shadow pass spawns).
|
|
1865
|
+
// Shared by the fix-loop escalation (answer consumed) and the C89-005
|
|
1866
|
+
// role-advice consult (answer recorded as suggest-only shadow). Every failure
|
|
1867
|
+
// degrades to a typed 'unavailable' result → deterministic fallback, never a
|
|
1868
|
+
// throw.
|
|
1869
|
+
async function askUnicDecisionBatch({
|
|
1870
|
+
rootDir = process.cwd(),
|
|
1871
|
+
config = null,
|
|
1872
|
+
batch = null,
|
|
1873
|
+
spawnImpl = spawnSync,
|
|
1874
|
+
} = {}) {
|
|
2094
1875
|
try {
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
1876
|
+
const cliPath = process.env.UKIT_DECISION_CLI_PATH || UNIC_DECISION_CLI_PATH;
|
|
1877
|
+
const timeoutMs = Math.min(
|
|
1878
|
+
Math.max(500, (Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : 2000) + 1000),
|
|
1879
|
+
DECISION_SHADOW_TIMEOUT_CAP_MS + 1000,
|
|
1880
|
+
);
|
|
1881
|
+
const spawned = spawnImpl(
|
|
1882
|
+
process.execPath,
|
|
1883
|
+
[cliPath, '--root', rootDir],
|
|
1884
|
+
{
|
|
1885
|
+
cwd: rootDir,
|
|
1886
|
+
input: JSON.stringify(batch),
|
|
1887
|
+
encoding: 'utf8',
|
|
1888
|
+
timeout: timeoutMs,
|
|
1889
|
+
},
|
|
1890
|
+
);
|
|
1891
|
+
if (spawned && !spawned.error && spawned.status === 0 && spawned.stdout) {
|
|
1892
|
+
return JSON.parse(spawned.stdout);
|
|
1893
|
+
}
|
|
1894
|
+
return { status: 'unavailable', fallbackCode: 'adapter-spawn-failed', answers: [] };
|
|
1895
|
+
} catch {
|
|
1896
|
+
return { status: 'unavailable', fallbackCode: 'adapter-spawn-failed', answers: [] };
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// Whether a `runtime-forensics` playbook file resolves through the registry —
|
|
1901
|
+
// BL-023 owns the file; until it lands every install degrades to 'missing'
|
|
1902
|
+
// and the lane-deepening still applies (SPEC §14).
|
|
1903
|
+
async function runtimeForensicsRegistered({ projectRoot = null } = {}) {
|
|
1904
|
+
try {
|
|
1905
|
+
const records = await loadRegistry({ projectRoot, homeDir: os.homedir() });
|
|
1906
|
+
return records.some((record) => record?.frontmatterValid && record?.playbookId === 'runtime-forensics');
|
|
1907
|
+
} catch {
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
// BL-017 (SPEC FR-005, hook path): the detached shadow pass skill-router.sh
|
|
1912
|
+
// spawns per emitted route (`node route-task.mjs --decision-shadow --root R`).
|
|
1913
|
+
// It re-reads the persisted route state + merged config and replays the same
|
|
1914
|
+
// stage-gated adapter batch the helper path runs inline — receipts land via
|
|
1915
|
+
// appendDecisionReceipt; the deterministic route was already written by the
|
|
1916
|
+
// parent so nothing here can rewrite it. Fire-and-forget: swallows every
|
|
1917
|
+
// failure (typed fallback rows still land through runRouteDecisionShadow),
|
|
1918
|
+
// prints nothing, exits 0.
|
|
1919
|
+
async function runHookPathDecisionShadow({
|
|
1920
|
+
rootDir,
|
|
1921
|
+
statePath,
|
|
1922
|
+
targetFile = null,
|
|
1923
|
+
} = {}) {
|
|
1924
|
+
try {
|
|
1925
|
+
const state = await readJson(statePath, null);
|
|
1926
|
+
const routeSummary = state?.routeSummary ?? null;
|
|
1927
|
+
if (!routeSummary || typeof routeSummary !== 'object') return;
|
|
1928
|
+
const config = await readMergedConfig(rootDir);
|
|
1929
|
+
const routingContext = {
|
|
1930
|
+
...(state?.routingContext ?? {}),
|
|
1931
|
+
targetFile,
|
|
1932
|
+
};
|
|
1933
|
+
await runRouteDecisionShadow({
|
|
1934
|
+
rootDir,
|
|
1935
|
+
config,
|
|
1936
|
+
routeSummary,
|
|
1937
|
+
routingContext,
|
|
2107
1938
|
});
|
|
2108
1939
|
} catch {
|
|
2109
|
-
//
|
|
1940
|
+
// Detached child: a failed shadow pass is observable via decisions.tsv
|
|
1941
|
+
// fallback receipts or absence — never propagated.
|
|
2110
1942
|
}
|
|
2111
|
-
return receipt;
|
|
2112
1943
|
}
|
|
2113
1944
|
|
|
2114
1945
|
// --- M04.1 resumable run emit (TASK-007, SPEC §5 FR-013..FR-015) --------------
|
|
@@ -2119,24 +1950,6 @@ export async function runRouteDecisionShadow({
|
|
|
2119
1950
|
// I/O — the route stays byte-identical. Every failure is advisory: routing never
|
|
2120
1951
|
// fails over run-state persistence.
|
|
2121
1952
|
|
|
2122
|
-
// Same absent/malformed → 'off' contract as the routing.* stage resolvers; the
|
|
2123
|
-
// stage key lives under continuity.resumableRun (TASK-001 config block).
|
|
2124
|
-
export function resolveResumableRunStage(config = null) {
|
|
2125
|
-
const stage = config?.continuity?.resumableRun?.stage;
|
|
2126
|
-
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
// The logical task boundary is the explicit user prompt — a new prompt means a
|
|
2130
|
-
// new task, so the persisted record resets instead of merging stale plans.
|
|
2131
|
-
// Hashed: prompt text never persists (C10 redaction contract).
|
|
2132
|
-
function resumableTaskBoundary(routingContext = {}) {
|
|
2133
|
-
const text = String(
|
|
2134
|
-
routingContext.lastExplicitUserPromptText ?? routingContext.promptText ?? '',
|
|
2135
|
-
).trim();
|
|
2136
|
-
return text
|
|
2137
|
-
? crypto.createHash('sha256').update(text).digest('hex').slice(0, 32)
|
|
2138
|
-
: 'no-prompt';
|
|
2139
|
-
}
|
|
2140
1953
|
|
|
2141
1954
|
// Session-scoped when a session id exists (one record file per session, reset by
|
|
2142
1955
|
// taskBoundary); prompt-scoped otherwise so concurrent anonymous routes never
|
|
@@ -2251,7 +2064,16 @@ export async function emitResumableRun({
|
|
|
2251
2064
|
const status = write.ok
|
|
2252
2065
|
? 'written'
|
|
2253
2066
|
: (write.journaled ? 'journaled' : `skipped-${write.reason ?? 'unknown'}`);
|
|
2254
|
-
|
|
2067
|
+
// TASK-007 (BL-009): playbook route fields ride the ledger row so
|
|
2068
|
+
// outcomes/audit can join the routed playbook for this run.
|
|
2069
|
+
const pointer = {
|
|
2070
|
+
taskId,
|
|
2071
|
+
taskBoundary,
|
|
2072
|
+
stage,
|
|
2073
|
+
status,
|
|
2074
|
+
playbookId: routeSummary?.playbookId ?? null,
|
|
2075
|
+
workGroup: routeSummary?.workGroup ?? null,
|
|
2076
|
+
};
|
|
2255
2077
|
// Ledger pointer event — same fail-closed journal discipline; advisory only.
|
|
2256
2078
|
try {
|
|
2257
2079
|
await recordLedgerEvent(
|
|
@@ -2279,6 +2101,13 @@ async function finalizeTaskRoute({
|
|
|
2279
2101
|
projectVerification = null,
|
|
2280
2102
|
cachedContextResult = null,
|
|
2281
2103
|
cachedVerificationPlan = null,
|
|
2104
|
+
// BL-018: injected decision/registrar seams — hook callers leave both null
|
|
2105
|
+
// (real adapter + real registry); tests stub them for deterministic lanes.
|
|
2106
|
+
escalationDecision = null,
|
|
2107
|
+
// C89 TASK-005: injected seam for the typed delegation-advice consult
|
|
2108
|
+
// ({ask, appendReceipt, override}); absent → the real unic-decision CLI
|
|
2109
|
+
// spawn when the decision plane is enabled, else a typed local outcome.
|
|
2110
|
+
roleAdviceDecision = null,
|
|
2282
2111
|
useIndexedContext = true,
|
|
2283
2112
|
previousContextSnapshot = null,
|
|
2284
2113
|
recentOutputSnapshot = null,
|
|
@@ -2286,6 +2115,9 @@ async function finalizeTaskRoute({
|
|
|
2286
2115
|
escalationConfig = null,
|
|
2287
2116
|
sessionId = null,
|
|
2288
2117
|
indexGeneratedAtMs = null,
|
|
2118
|
+
// BL-013: precomputed bounded history struct from the caller's transcript
|
|
2119
|
+
// extraction — merged into the route record additively + decisions.tsv.
|
|
2120
|
+
historySignals = null,
|
|
2289
2121
|
} = {}) {
|
|
2290
2122
|
const absoluteRoot = path.resolve(rootDir);
|
|
2291
2123
|
const activeSkills = preparedRoute?.activeSkills ?? [];
|
|
@@ -2358,7 +2190,47 @@ async function finalizeTaskRoute({
|
|
|
2358
2190
|
routeSchemaStage: resolveRouteSchemaStage(escalationConfig),
|
|
2359
2191
|
runtimeConfig: escalationConfig,
|
|
2360
2192
|
resolvedWorkflowPolicy,
|
|
2193
|
+
historySignals,
|
|
2361
2194
|
});
|
|
2195
|
+
// TASK-007 (BL-009): playbook resolution rides the same route signals —
|
|
2196
|
+
// the registry degrades to playbookId: null + reason and never blocks the
|
|
2197
|
+
// route. Same merge the hook performs via mergeResolverFields.
|
|
2198
|
+
try {
|
|
2199
|
+
const playbookResolution = await resolvePlaybookRoute({
|
|
2200
|
+
promptText: routingContext.promptText,
|
|
2201
|
+
commandText: routingContext.commandText,
|
|
2202
|
+
targetFile: routingContext.targetFile ?? null,
|
|
2203
|
+
executionMode: routingContext.executionMode ?? null,
|
|
2204
|
+
intentMode: routingContext.intentMode ?? null,
|
|
2205
|
+
escalationTriggers: routeSummary?.escalationTriggers ?? [],
|
|
2206
|
+
projectRoot: absoluteRoot,
|
|
2207
|
+
homeDir: os.homedir(),
|
|
2208
|
+
});
|
|
2209
|
+
routeSummary.playbookId = playbookResolution.playbookId;
|
|
2210
|
+
routeSummary.workGroup = playbookResolution.workGroup;
|
|
2211
|
+
routeSummary.playbookReason = playbookResolution.reason;
|
|
2212
|
+
routeSummary.playbookLoad = playbookResolution.playbookLoad;
|
|
2213
|
+
// BL-010 (ARCH Task Contract): `path` = direct|handoff; `fastPathKind` is
|
|
2214
|
+
// the ARCH `none|small-feature|bug-fix` enum — named Kind because
|
|
2215
|
+
// routeSummary.fastPath already carries the FR-004 eligibility object.
|
|
2216
|
+
// Keys land only when a routing-table row claimed the prompt (workGroup
|
|
2217
|
+
// non-null) — identical key-presence to deriveTaskRoute so schema-off /
|
|
2218
|
+
// no-match routes never grow stray keys.
|
|
2219
|
+
if (playbookResolution.workGroup != null) {
|
|
2220
|
+
routeSummary.path = playbookResolution.path;
|
|
2221
|
+
routeSummary.fastPathKind = playbookResolution.fastPath;
|
|
2222
|
+
} else {
|
|
2223
|
+
delete routeSummary.path;
|
|
2224
|
+
delete routeSummary.fastPathKind;
|
|
2225
|
+
}
|
|
2226
|
+
} catch {
|
|
2227
|
+
routeSummary.playbookId = null;
|
|
2228
|
+
routeSummary.workGroup = null;
|
|
2229
|
+
routeSummary.playbookReason = 'registry-error';
|
|
2230
|
+
routeSummary.playbookLoad = null;
|
|
2231
|
+
delete routeSummary.path;
|
|
2232
|
+
delete routeSummary.fastPathKind;
|
|
2233
|
+
}
|
|
2362
2234
|
routeSummary.continuationState = advanceContinuationState(
|
|
2363
2235
|
routeSummary.continuationState,
|
|
2364
2236
|
previousRouteSummary?.continuationState ?? null,
|
|
@@ -2378,10 +2250,79 @@ async function finalizeTaskRoute({
|
|
|
2378
2250
|
delete routeSummary.riskEscalation;
|
|
2379
2251
|
}
|
|
2380
2252
|
applyRescueBiasToRouteSummary(routeSummary);
|
|
2253
|
+
// BL-019 (SPEC FR-001): fold the persisted measured map into the route —
|
|
2254
|
+
// measuredTier is always emitted (nullable); the authoritative modelTier
|
|
2255
|
+
// follows the map ONLY when tier-map.json says 'promote'. Runs before the
|
|
2256
|
+
// escalation re-derives tierLane so the printed lane reflects the final
|
|
2257
|
+
// tier; never throws (absent/corrupt artifact → static stays).
|
|
2258
|
+
await applyMeasuredTier({ projectRoot: absoluteRoot, routeSummary });
|
|
2381
2259
|
applyEscalationToRouteSummary(routeSummary, {
|
|
2382
2260
|
config: escalationConfig,
|
|
2383
2261
|
targetFile: routingContext.targetFile ?? null,
|
|
2384
2262
|
});
|
|
2263
|
+
// BL-018 (SPEC FR-006): fix-loop escalation. historySignals.fixLoopCount is
|
|
2264
|
+
// fingerprint-joined (same routeFingerprint/requestKey, never session-wide);
|
|
2265
|
+
// at debugLoopThreshold the lane deepens via the laneDeepening/
|
|
2266
|
+
// verificationDepth question, endpoint-down → deterministic fallback
|
|
2267
|
+
// (fallback:tier-bump), deeper-lane exhaustion → BLOCKED. Runs after the
|
|
2268
|
+
// tier-bump application so both fallback surfaces coexist honestly.
|
|
2269
|
+
// Computed now — BEFORE the escalation writes its additive fields — so the
|
|
2270
|
+
// stamped escalatedRouteFingerprint is exactly the fingerprint
|
|
2271
|
+
// createSharedRouteState persists on this emission (the fingerprint payload
|
|
2272
|
+
// excludes the escalation fields, so pre- and post-escalation agree).
|
|
2273
|
+
const routeFingerprint = buildRouteStateFingerprint({
|
|
2274
|
+
activeSkills,
|
|
2275
|
+
routingContext,
|
|
2276
|
+
previousContext: previousContextSnapshot,
|
|
2277
|
+
recentOutput: recentOutputSnapshot,
|
|
2278
|
+
routeSummary,
|
|
2279
|
+
});
|
|
2280
|
+
const laneEscalationDue = isFixLoopEscalationDue({
|
|
2281
|
+
historySignals: routeSummary.historySignals,
|
|
2282
|
+
config: escalationConfig,
|
|
2283
|
+
});
|
|
2284
|
+
if (laneEscalationDue) {
|
|
2285
|
+
await applyFixLoopEscalation({
|
|
2286
|
+
routeSummary,
|
|
2287
|
+
routingContext,
|
|
2288
|
+
previousRouteSummary,
|
|
2289
|
+
routeFingerprint,
|
|
2290
|
+
config: escalationConfig,
|
|
2291
|
+
ask: escalationDecision?.ask
|
|
2292
|
+
?? (({ batch }) => askUnicDecisionBatch({
|
|
2293
|
+
rootDir: absoluteRoot,
|
|
2294
|
+
config: escalationConfig,
|
|
2295
|
+
batch,
|
|
2296
|
+
})),
|
|
2297
|
+
appendReceipt: escalationDecision?.appendReceipt
|
|
2298
|
+
?? ((receipt) => appendDecisionReceipt(absoluteRoot, receipt)),
|
|
2299
|
+
runtimeForensicsPresent: escalationDecision?.runtimeForensicsPresent
|
|
2300
|
+
?? await runtimeForensicsRegistered({ projectRoot: absoluteRoot }),
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
// ledger's typed receipt writer (consumed signature, ledger unchanged).
|
|
2304
|
+
// Emitted only when extraction actually ran on a transcript pointer — a
|
|
2305
|
+
// route with no transcript (missing-transcript-path) stays byte-identical:
|
|
2306
|
+
// no receipt, no history row. A real but unreadable transcript still lands
|
|
2307
|
+
// a degraded receipt so coverage is measurable. Never carries text.
|
|
2308
|
+
if (routeSummary.historySignals && routeSummary.historySignals.reason !== 'missing-transcript-path') {
|
|
2309
|
+
try {
|
|
2310
|
+
const hist = routeSummary.historySignals;
|
|
2311
|
+
await appendDecisionReceipt(absoluteRoot, {
|
|
2312
|
+
kind: 'shadow',
|
|
2313
|
+
boundary: 'route-history',
|
|
2314
|
+
stage: 'deterministic',
|
|
2315
|
+
outcomeClass: hist.degraded ? 'degraded' : 'ok',
|
|
2316
|
+
checkpoint: `hist prior=${hist.priorAttemptCount ?? 0} loop=${hist.fixLoopCount ?? 0} reask=${hist.sameSymptomReask ? 'yes' : 'no'} corrections=${hist.correctionEvents ?? 0}`,
|
|
2317
|
+
latencyClass: 'fast',
|
|
2318
|
+
decisionKeys: ['history.signals'],
|
|
2319
|
+
agreement: 'n/a',
|
|
2320
|
+
fallbackCode: hist.degraded ? (hist.reason ?? 'degraded') : null,
|
|
2321
|
+
});
|
|
2322
|
+
} catch {
|
|
2323
|
+
// Receipt loss is advisory — never fail the route over telemetry.
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2385
2326
|
appendRiskEscalationSegment(routeSummary);
|
|
2386
2327
|
// M07 (TASK-005): stage-gated decision-plane shadow call. 'off' → null and
|
|
2387
2328
|
// nothing else happens (byte-identical route); any other stage → bounded
|
|
@@ -2395,6 +2336,34 @@ async function finalizeTaskRoute({
|
|
|
2395
2336
|
if (decisionShadow) {
|
|
2396
2337
|
routeSummary.decisionPlane = decisionShadow;
|
|
2397
2338
|
}
|
|
2339
|
+
// C89 TASK-005 (SPEC §6 FR-04): typed delegation advice consult — the
|
|
2340
|
+
// optional unic-decision round only ever answers the owner-bounded role
|
|
2341
|
+
// question through the installed CLI; the deterministic fields the route
|
|
2342
|
+
// already carries stay authoritative, every failure records a typed
|
|
2343
|
+
// advice.model outcome, and the decisions.tsv row lands via
|
|
2344
|
+
// appendDecisionReceipt (never silent).
|
|
2345
|
+
if (routeSummary?.delegationAdvice) {
|
|
2346
|
+
try {
|
|
2347
|
+
const roleAdviceAsk = roleAdviceDecision?.ask
|
|
2348
|
+
?? (resolveDecisionPlaneStage(escalationConfig) !== 'off'
|
|
2349
|
+
? (({ batch }) => askUnicDecisionBatch({
|
|
2350
|
+
rootDir: absoluteRoot,
|
|
2351
|
+
config: escalationConfig,
|
|
2352
|
+
batch,
|
|
2353
|
+
}))
|
|
2354
|
+
: null);
|
|
2355
|
+
await applyRoleAdviceConsult({
|
|
2356
|
+
routeSummary,
|
|
2357
|
+
config: escalationConfig,
|
|
2358
|
+
ask: roleAdviceAsk,
|
|
2359
|
+
appendReceipt: roleAdviceDecision?.appendReceipt
|
|
2360
|
+
?? ((receipt) => appendDecisionReceipt(absoluteRoot, receipt)),
|
|
2361
|
+
override: roleAdviceDecision?.override ?? null,
|
|
2362
|
+
});
|
|
2363
|
+
} catch {
|
|
2364
|
+
// Advisory consult — never fail or mutate the route over a shadow ask.
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2398
2367
|
// M04.1 (TASK-007): stage-gated resumable-run emit. 'off' → null and nothing
|
|
2399
2368
|
// else happens (byte-identical route); any other stage → the C10 record is
|
|
2400
2369
|
// persisted/updated under .ukit/storage/runs/ and a bounded pointer lands on
|
|
@@ -2443,6 +2412,18 @@ function printRouteState(state) {
|
|
|
2443
2412
|
if (recentOutputDisplay) {
|
|
2444
2413
|
console.log(`recent-output: ${recentOutputDisplay}`);
|
|
2445
2414
|
}
|
|
2415
|
+
// BL-013: bounded history line — counters/enums only, never transcript text.
|
|
2416
|
+
// Printed whenever extraction ran (a degraded struct still reports honestly).
|
|
2417
|
+
if (state.routeSummary?.historySignals) {
|
|
2418
|
+
const hist = state.routeSummary.historySignals;
|
|
2419
|
+
console.log(
|
|
2420
|
+
`history: prior=${hist.priorAttemptCount ?? 0}`
|
|
2421
|
+
+ ` loop=${hist.fixLoopCount ?? 0}`
|
|
2422
|
+
+ ` reask=${hist.sameSymptomReask ? 'yes' : 'no'}`
|
|
2423
|
+
+ ` corrections=${hist.correctionEvents ?? 0}`
|
|
2424
|
+
+ (hist.degraded ? ` degraded=${hist.reason ?? 'unknown'}` : ''),
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2446
2427
|
console.log(`summary: ${compactSummary}`);
|
|
2447
2428
|
if (state.routeSummary?.executionContract?.modelTier) {
|
|
2448
2429
|
console.log(`modelTier: ${state.routeSummary.executionContract.modelTier}`);
|
|
@@ -2456,6 +2437,14 @@ function printRouteState(state) {
|
|
|
2456
2437
|
console.log(`escalationReason: ${state.routeSummary.escalationReason}`);
|
|
2457
2438
|
}
|
|
2458
2439
|
}
|
|
2440
|
+
// BL-018: the deeper lane surface — 'blocked' is the exhaustion verdict,
|
|
2441
|
+
// otherwise a lane from FIX_LOOP_LANE_CANDIDATES.
|
|
2442
|
+
if (state.routeSummary?.escalatedLane) {
|
|
2443
|
+
console.log(`escalatedLane: ${state.routeSummary.escalatedLane}`);
|
|
2444
|
+
if (state.routeSummary.escalateReason) {
|
|
2445
|
+
console.log(`escalateReason: ${state.routeSummary.escalateReason}`);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2459
2448
|
if (state.routeSummary?.visionLane) {
|
|
2460
2449
|
console.log(`visionLane: ${state.routeSummary.visionLane}`);
|
|
2461
2450
|
if (state.routeSummary.visionModel) {
|
|
@@ -2479,6 +2468,12 @@ function printRouteState(state) {
|
|
|
2479
2468
|
}
|
|
2480
2469
|
// v3 advisory blocks (SPEC-playbook-todo / SPEC-principle-index / SPEC-model-roles):
|
|
2481
2470
|
// additive stdout text, byte-stable for unchanged config.
|
|
2471
|
+
// TASK-007 (BL-009): playbook surface — the instruction-mediated load cue
|
|
2472
|
+
// Codex and print consumers read. playbookId only prints when a playbook
|
|
2473
|
+
// resolved; the degrade reason stays machine-side on the route record.
|
|
2474
|
+
if (state.routeSummary?.playbookId) {
|
|
2475
|
+
console.log(`playbook: ${state.routeSummary.playbookId} — read playbooks/${state.routeSummary.playbookId}.md and run its steps verbatim`);
|
|
2476
|
+
}
|
|
2482
2477
|
if (state.routeSummary?.workflowPolicyName) {
|
|
2483
2478
|
console.log(`workflow: ${state.routeSummary.workflowPolicyName} — copy steps verbatim into your todo list; skipped steps keep skip:<reason>`);
|
|
2484
2479
|
}
|
|
@@ -3352,6 +3347,265 @@ function deriveDelegationRecommendation({
|
|
|
3352
3347
|
|
|
3353
3348
|
return null;
|
|
3354
3349
|
}
|
|
3350
|
+
// --- C89 TASK-005 (SPEC §6 FR-04, §11): typed delegation advice (shadow) ------
|
|
3351
|
+
// Lockstep mirror of src/index/taskRouting.js — wraps the single route owner
|
|
3352
|
+
// (deriveDelegationRecommendation above); never a second router, never a
|
|
3353
|
+
// spawn path. Gated on `subagentOrchestrator.roleAdvice.stage`
|
|
3354
|
+
// ('off'/malformed → null, zero cost); any other stage emits the typed
|
|
3355
|
+
// advisory struct. The optional unic-decision consult
|
|
3356
|
+
// (applyRoleAdviceConsult) only ever answers an owner-bounded enum question;
|
|
3357
|
+
// deterministic fields stay authoritative and every failure records a typed
|
|
3358
|
+
// outcome on advice.model — never silent.
|
|
3359
|
+
const DELEGATION_ADVICE_POLICY_VERSION = 'role-advice.v1';
|
|
3360
|
+
const DELEGATION_ADVICE_DECISION_KEY = 'route.delegation-role.v1';
|
|
3361
|
+
// SPEC §6 initial role vocabulary — maps onto existing frontmatter agents,
|
|
3362
|
+
// no new agent file (retriever/reviewer reserved for role-bearing lanes;
|
|
3363
|
+
// C89-006 consumes role+contextMode only as advisory unless host-verified).
|
|
3364
|
+
const DELEGATION_ADVICE_ROLES = Object.freeze(['retriever', 'reasoner', 'reviewer']);
|
|
3365
|
+
const DELEGATION_ADVICE_CONTEXT_MODES = Object.freeze(['isolated', 'curated']);
|
|
3366
|
+
|
|
3367
|
+
// Delegation hint → {role, contextMode, reasonCode}. The owner hints all carry
|
|
3368
|
+
// reasoning work; the noisy debug lane needs isolation from the noisy loop,
|
|
3369
|
+
// the plan/feature lanes need the curated indexed bundle.
|
|
3370
|
+
const DELEGATION_HINT_ROLES = Object.freeze({
|
|
3371
|
+
'ukit-small-task-maintainer': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'small-task-maintenance' },
|
|
3372
|
+
'subagent-driven-development': { role: 'reasoner', contextMode: 'curated', reasonCode: 'planned-batch-execution' },
|
|
3373
|
+
'bug-debugger': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'noisy-debug-loop' },
|
|
3374
|
+
'feature-implementer': { role: 'reasoner', contextMode: 'curated', reasonCode: 'broad-implementation-lane' },
|
|
3375
|
+
});
|
|
3376
|
+
|
|
3377
|
+
// Mirror of runtimeConfig.js resolveSubagentOrchestratorStage — resolves
|
|
3378
|
+
// `subagentOrchestrator.<key>.stage`; absent/malformed config or a bad key
|
|
3379
|
+
// resolves 'off' so a bad config can never promote a slice.
|
|
3380
|
+
function resolveSubagentOrchestratorStage(config = null, key = 'telemetry') {
|
|
3381
|
+
if (typeof key !== 'string' || key.trim() === '') return 'off';
|
|
3382
|
+
const node = config?.subagentOrchestrator?.[key];
|
|
3383
|
+
const stage = node?.stage;
|
|
3384
|
+
return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
/**
|
|
3388
|
+
* Typed delegation advice (SPEC §6 FR-04). Reads the deterministic
|
|
3389
|
+
* `deriveDelegationRecommendation` verdict and annotates it as the typed
|
|
3390
|
+
* shadow receipt {delegate, role, contextMode, confidence, reasonCodes,
|
|
3391
|
+
* policyVersion, enforcement} — plus the audit fields hint/stage.
|
|
3392
|
+
*
|
|
3393
|
+
* `enforcement` is 'advisory' on every shipped host: this lane never spawns
|
|
3394
|
+
* and C89-006 owns the host-capability check that could one day mark a lane
|
|
3395
|
+
* 'enforced'. Trivial work resolves local by the deterministic fast path —
|
|
3396
|
+
* the same `null` the owner returns, typed.
|
|
3397
|
+
*
|
|
3398
|
+
* @returns {object|null} null when the roleAdvice stage is off/malformed.
|
|
3399
|
+
*/
|
|
3400
|
+
export function deriveTypedDelegationAdvice({
|
|
3401
|
+
activeSkills = [],
|
|
3402
|
+
routingContext = {},
|
|
3403
|
+
contextRecommendation = null,
|
|
3404
|
+
verificationRecommendation = null,
|
|
3405
|
+
autonomyLevel = 'balanced',
|
|
3406
|
+
riskFloor = null,
|
|
3407
|
+
config = null,
|
|
3408
|
+
} = {}) {
|
|
3409
|
+
const stage = resolveSubagentOrchestratorStage(config, 'roleAdvice');
|
|
3410
|
+
if (stage === 'off') {
|
|
3411
|
+
return null;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
const recommendation = deriveDelegationRecommendation({
|
|
3415
|
+
activeSkills,
|
|
3416
|
+
routingContext,
|
|
3417
|
+
contextRecommendation,
|
|
3418
|
+
verificationRecommendation,
|
|
3419
|
+
autonomyLevel,
|
|
3420
|
+
});
|
|
3421
|
+
|
|
3422
|
+
const hint = recommendation?.hint ?? null;
|
|
3423
|
+
const reasonCodes = [];
|
|
3424
|
+
if (routingContext.taskType === 'trivial') {
|
|
3425
|
+
reasonCodes.push('trivial-fast-path');
|
|
3426
|
+
} else if (hint && DELEGATION_HINT_ROLES[hint]) {
|
|
3427
|
+
reasonCodes.push(DELEGATION_HINT_ROLES[hint].reasonCode);
|
|
3428
|
+
} else if (recommendation) {
|
|
3429
|
+
reasonCodes.push('owner-recommendation');
|
|
3430
|
+
} else {
|
|
3431
|
+
reasonCodes.push('no-delegation-signal');
|
|
3432
|
+
}
|
|
3433
|
+
if (riskFloor?.floor === 'high-risk') {
|
|
3434
|
+
reasonCodes.push('risk-floor-high-risk');
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
const delegate = hint !== null;
|
|
3438
|
+
let role = null;
|
|
3439
|
+
let contextMode = null;
|
|
3440
|
+
if (delegate) {
|
|
3441
|
+
if (routingContext.executionMode === 'review-release') {
|
|
3442
|
+
// SPEC §6: reviewer lanes default isolated — the reviewer receives the
|
|
3443
|
+
// task requirements/diff/tests/evidence, not implementer narrative.
|
|
3444
|
+
role = 'reviewer';
|
|
3445
|
+
contextMode = 'isolated';
|
|
3446
|
+
reasonCodes.push('review-release-lane');
|
|
3447
|
+
} else {
|
|
3448
|
+
const mapped = DELEGATION_HINT_ROLES[hint] ?? { role: 'reasoner', contextMode: 'curated' };
|
|
3449
|
+
role = mapped.role;
|
|
3450
|
+
contextMode = mapped.contextMode;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
const confidence = routingContext.taskType === 'trivial'
|
|
3455
|
+
? 0.95
|
|
3456
|
+
: delegate ? 0.9 : 0.6;
|
|
3457
|
+
|
|
3458
|
+
return {
|
|
3459
|
+
delegate,
|
|
3460
|
+
role,
|
|
3461
|
+
contextMode,
|
|
3462
|
+
confidence,
|
|
3463
|
+
reasonCodes,
|
|
3464
|
+
policyVersion: DELEGATION_ADVICE_POLICY_VERSION,
|
|
3465
|
+
enforcement: 'advisory',
|
|
3466
|
+
stage,
|
|
3467
|
+
hint,
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
function delegationRiskFloorSignals(routeSummary) {
|
|
3472
|
+
const codes = routeSummary?.riskFloor?.codes;
|
|
3473
|
+
return Array.isArray(codes) ? codes : [];
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
/**
|
|
3477
|
+
* Optional bounded consult + recorded override for the typed advice (SPEC §6).
|
|
3478
|
+
* The unic-decision question is one owner-bounded choice over the role
|
|
3479
|
+
* shortlist — enum-only, redacted, never tools/spawn. Any auth rejection,
|
|
3480
|
+
* timeout, invalid or off-shortlist answer lands on `advice.model` as a typed
|
|
3481
|
+
* outcome; the deterministic fields are never rewritten and no receipt write
|
|
3482
|
+
* is silent (appendReceipt seam fires whenever a consult ran).
|
|
3483
|
+
*
|
|
3484
|
+
* The main-agent override is recorded verbatim (role + reason) when the role
|
|
3485
|
+
* is in the enum — stored for the later accepting lane; it never mutates the
|
|
3486
|
+
* deterministic advice. The hard risk floor is deterministic state on the
|
|
3487
|
+
* advice itself, so conflicting model noise can never flip a local route.
|
|
3488
|
+
*
|
|
3489
|
+
* Returns true when the route was annotated (a consult ran or an override was
|
|
3490
|
+
* recorded); false when no advice exists or the consult never ran.
|
|
3491
|
+
*/
|
|
3492
|
+
export async function applyRoleAdviceConsult({
|
|
3493
|
+
routeSummary = null,
|
|
3494
|
+
config = null,
|
|
3495
|
+
ask = null,
|
|
3496
|
+
appendReceipt = null,
|
|
3497
|
+
override = null,
|
|
3498
|
+
now = () => Date.now(),
|
|
3499
|
+
} = {}) {
|
|
3500
|
+
const advice = routeSummary?.delegationAdvice;
|
|
3501
|
+
if (!advice || typeof advice !== 'object') {
|
|
3502
|
+
return false;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
if (override && typeof override === 'object'
|
|
3506
|
+
&& DELEGATION_ADVICE_ROLES.includes(override.role)
|
|
3507
|
+
&& typeof override.reason === 'string' && override.reason.trim()) {
|
|
3508
|
+
advice.override = { role: override.role, reason: override.reason.trim() };
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
const emit = (receipt) => {
|
|
3512
|
+
if (typeof appendReceipt !== 'function') return;
|
|
3513
|
+
try {
|
|
3514
|
+
const pending = appendReceipt(receipt);
|
|
3515
|
+
if (pending?.catch) pending.catch(() => {});
|
|
3516
|
+
} catch {
|
|
3517
|
+
// Receipt loss is advisory — the typed advice stands regardless.
|
|
3518
|
+
}
|
|
3519
|
+
};
|
|
3520
|
+
|
|
3521
|
+
// Local routes never consult — there is no role to suggest and the
|
|
3522
|
+
// deterministic fast path needs no shadow annotation.
|
|
3523
|
+
if (advice.delegate !== true || typeof advice.role !== 'string') {
|
|
3524
|
+
return true;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// An explicit ask seam is the consult consent; the ambient path only builds
|
|
3528
|
+
// a client when the decision plane itself is enabled, so a role-advice-only
|
|
3529
|
+
// config never triggers endpoint work.
|
|
3530
|
+
const explicitAsk = typeof ask === 'function';
|
|
3531
|
+
if (!explicitAsk && resolveDecisionPlaneStage(config) === 'off') {
|
|
3532
|
+
return true;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
// Owner-bounded shortlist: the model may only answer inside the
|
|
3536
|
+
// deterministic candidate set — a singleton when the owner has one role.
|
|
3537
|
+
const candidates = [advice.role];
|
|
3538
|
+
const batch = {
|
|
3539
|
+
batchId: `role-advice-${now().toString(36)}`,
|
|
3540
|
+
boundary: 'role-advice',
|
|
3541
|
+
deadlineMs: Math.max(
|
|
3542
|
+
500,
|
|
3543
|
+
Number.isFinite(config?.decisionPlane?.timeoutMs) ? config.decisionPlane.timeoutMs : 2000,
|
|
3544
|
+
),
|
|
3545
|
+
statePacket: {
|
|
3546
|
+
stateVersion: 1,
|
|
3547
|
+
taskClass: routeSummary?.routingContext?.taskType ?? 'unknown',
|
|
3548
|
+
execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
|
|
3549
|
+
riskSignals: delegationRiskFloorSignals(routeSummary),
|
|
3550
|
+
},
|
|
3551
|
+
questions: [
|
|
3552
|
+
{
|
|
3553
|
+
decisionKey: DELEGATION_ADVICE_DECISION_KEY,
|
|
3554
|
+
kind: 'choice',
|
|
3555
|
+
instruction: 'Pick the delegation role from the owner-bounded shortlist.',
|
|
3556
|
+
candidates,
|
|
3557
|
+
},
|
|
3558
|
+
],
|
|
3559
|
+
};
|
|
3560
|
+
|
|
3561
|
+
let result = null;
|
|
3562
|
+
try {
|
|
3563
|
+
result = await ask({ batch });
|
|
3564
|
+
} catch {
|
|
3565
|
+
result = null;
|
|
3566
|
+
}
|
|
3567
|
+
if (!result) {
|
|
3568
|
+
result = { status: 'unavailable', fallbackCode: 'ask-failed', answers: [] };
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
// Fold the typed outcome: only a valid in-shortlist answer becomes a
|
|
3572
|
+
// suggestion; everything else keeps suggestedRole null with a fallback code.
|
|
3573
|
+
const answers = Array.isArray(result.answers) ? result.answers : [];
|
|
3574
|
+
let suggestedRole = null;
|
|
3575
|
+
for (const answer of answers) {
|
|
3576
|
+
if (answer?.decisionKey !== DELEGATION_ADVICE_DECISION_KEY) continue;
|
|
3577
|
+
if (answer?.validationStatus !== 'valid') continue;
|
|
3578
|
+
const value = typeof answer?.value === 'string' ? answer.value.trim() : null;
|
|
3579
|
+
if (value && candidates.includes(value)) {
|
|
3580
|
+
suggestedRole = value;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
const outcomeClass = typeof result.status === 'string' ? result.status : 'unavailable';
|
|
3584
|
+
advice.model = {
|
|
3585
|
+
status: outcomeClass,
|
|
3586
|
+
suggestedRole,
|
|
3587
|
+
agreement: suggestedRole === null
|
|
3588
|
+
? 'unknown'
|
|
3589
|
+
: suggestedRole === advice.role ? 'agree' : 'disagree',
|
|
3590
|
+
fallbackCode: result.fallbackCode
|
|
3591
|
+
?? (suggestedRole === null && outcomeClass === 'accepted' ? 'off-shortlist' : null),
|
|
3592
|
+
};
|
|
3593
|
+
|
|
3594
|
+
emit({
|
|
3595
|
+
kind: outcomeClass === 'unavailable' ? 'fallback' : 'shadow',
|
|
3596
|
+
boundary: 'role-advice',
|
|
3597
|
+
stage: advice.stage,
|
|
3598
|
+
outcomeClass,
|
|
3599
|
+
checkpoint: `role-advice batch=${batch.batchId}`,
|
|
3600
|
+
latencyClass: result.latencyClass ?? 'unknown',
|
|
3601
|
+
decisionKeys: [DELEGATION_ADVICE_DECISION_KEY],
|
|
3602
|
+
agreement: advice.model.agreement,
|
|
3603
|
+
fallbackCode: advice.model.fallbackCode,
|
|
3604
|
+
now: now(),
|
|
3605
|
+
});
|
|
3606
|
+
return true;
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3355
3609
|
|
|
3356
3610
|
function buildRouteSummary({
|
|
3357
3611
|
activeSkills = [],
|
|
@@ -3363,6 +3617,9 @@ function buildRouteSummary({
|
|
|
3363
3617
|
routeSchemaStage = 'off',
|
|
3364
3618
|
runtimeConfig = null,
|
|
3365
3619
|
resolvedWorkflowPolicy = null,
|
|
3620
|
+
// BL-013: precomputed bounded history struct — merged additively via the
|
|
3621
|
+
// shared resolver below. Absent/null → no field, byte-identical route.
|
|
3622
|
+
historySignals = null,
|
|
3366
3623
|
} = {}) {
|
|
3367
3624
|
const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
|
|
3368
3625
|
const delegationRecommendation = deriveDelegationRecommendation({
|
|
@@ -3417,21 +3674,51 @@ function buildRouteSummary({
|
|
|
3417
3674
|
nextActionType: nextAction?.type ?? null,
|
|
3418
3675
|
completionState,
|
|
3419
3676
|
});
|
|
3420
|
-
//
|
|
3421
|
-
//
|
|
3422
|
-
//
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
const
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
: null
|
|
3677
|
+
// TASK-004 (BL-006): one shared resolver derives the deterministic route
|
|
3678
|
+
// fields — the identical object skill-router.sh merges into hook state.
|
|
3679
|
+
// The explicit routeSchemaStage param remains authoritative for the resolved
|
|
3680
|
+
// groups (callers may force it independent of runtimeConfig); every other
|
|
3681
|
+
// stage read comes from runtimeConfig inside deriveRouteFields.
|
|
3682
|
+
const routeFields = deriveRouteFields({
|
|
3683
|
+
promptText: routingContext.promptText,
|
|
3684
|
+
commandText: routingContext.commandText,
|
|
3685
|
+
targetFile: routingContext.targetFile ?? null,
|
|
3686
|
+
intentMode: routingContext.intentMode ?? null,
|
|
3687
|
+
taskType,
|
|
3688
|
+
executionMode,
|
|
3689
|
+
activeSkillIds: activeSkills.map((entry) => entry.id),
|
|
3690
|
+
verificationRecommendation,
|
|
3691
|
+
contextPreview: contextRecommendation?.preview ?? null,
|
|
3692
|
+
executionScores: routingContext.executionScores ?? {},
|
|
3693
|
+
lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? null,
|
|
3694
|
+
historySignals,
|
|
3695
|
+
config: routeSchemaStage === 'off'
|
|
3696
|
+
? runtimeConfig
|
|
3697
|
+
: {
|
|
3698
|
+
...(runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}),
|
|
3699
|
+
routing: {
|
|
3700
|
+
...(runtimeConfig?.routing && typeof runtimeConfig.routing === 'object'
|
|
3701
|
+
? runtimeConfig.routing
|
|
3702
|
+
: {}),
|
|
3703
|
+
routeSchema: { stage: routeSchemaStage },
|
|
3704
|
+
},
|
|
3705
|
+
},
|
|
3706
|
+
});
|
|
3707
|
+
const riskFloor = routeFields.riskFloor;
|
|
3708
|
+
const fastPath = routeFields.fastPath;
|
|
3709
|
+
const resolvedRouteFields = routeFields.resolved;
|
|
3710
|
+
// C89 TASK-005 (SPEC §6/§11): typed delegation advice shadow — additive,
|
|
3711
|
+
// gated on subagentOrchestrator.roleAdvice.stage ('off' → null, route
|
|
3712
|
+
// byte-identical). Wraps the deterministic owner verdict; never spawns.
|
|
3713
|
+
const delegationAdvice = deriveTypedDelegationAdvice({
|
|
3714
|
+
activeSkills,
|
|
3715
|
+
routingContext,
|
|
3716
|
+
contextRecommendation,
|
|
3717
|
+
verificationRecommendation,
|
|
3718
|
+
autonomyLevel,
|
|
3719
|
+
riskFloor,
|
|
3720
|
+
config: runtimeConfig,
|
|
3721
|
+
});
|
|
3435
3722
|
const riskSegment = formatRiskFloorSegment(riskFloor);
|
|
3436
3723
|
// FR-003 (M01.2' limits fragment): the rigor advisory — emitted whenever
|
|
3437
3724
|
// rigor.stage is on, independent of fastPath/escalation. Stage off → no
|
|
@@ -3439,36 +3726,7 @@ function buildRouteSummary({
|
|
|
3439
3726
|
const limitsSegment = resolveRouteStage(runtimeConfig, 'rigor') !== 'off'
|
|
3440
3727
|
? formatLimitsSegment(deriveCeremonyLimits(executionContract))
|
|
3441
3728
|
: null;
|
|
3442
|
-
// FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the field
|
|
3443
|
-
// is always set then (eligible or not) so telemetry/harness can read it; the
|
|
3444
|
-
// route-line segment prints only for eligible routes.
|
|
3445
|
-
const fastPathStage = resolveRouteStage(runtimeConfig, 'fastPath');
|
|
3446
|
-
const fastPath = fastPathStage !== 'off'
|
|
3447
|
-
? {
|
|
3448
|
-
eligible: false,
|
|
3449
|
-
suppressed: [],
|
|
3450
|
-
reasons: [],
|
|
3451
|
-
...deriveFastPath({
|
|
3452
|
-
executionMode,
|
|
3453
|
-
targetFile: routingContext.targetFile ?? null,
|
|
3454
|
-
riskFloor,
|
|
3455
|
-
verificationRecommendation,
|
|
3456
|
-
contextPreview: contextRecommendation?.preview ?? null,
|
|
3457
|
-
}),
|
|
3458
|
-
stage: fastPathStage,
|
|
3459
|
-
}
|
|
3460
|
-
: null;
|
|
3461
3729
|
const fastPathSegment = formatFastPathSegment(fastPath);
|
|
3462
|
-
const resolvedRouteFields = routeSchemaStage !== 'off'
|
|
3463
|
-
? buildResolvedRouteFields({
|
|
3464
|
-
routingContext,
|
|
3465
|
-
activeSkillIds: activeSkills.map((entry) => entry.id),
|
|
3466
|
-
executionMode,
|
|
3467
|
-
executionContract,
|
|
3468
|
-
completionState,
|
|
3469
|
-
riskFloor,
|
|
3470
|
-
})
|
|
3471
|
-
: null;
|
|
3472
3730
|
const helperHint = compactHelperHint(
|
|
3473
3731
|
compactHelperLane
|
|
3474
3732
|
? contextRecommendation?.command
|
|
@@ -3527,6 +3785,16 @@ function buildRouteSummary({
|
|
|
3527
3785
|
executionMode,
|
|
3528
3786
|
executionScores,
|
|
3529
3787
|
executionCandidates,
|
|
3788
|
+
// TASK-004 resolver fields — the schema-off contract requires these keys be
|
|
3789
|
+
// absent entirely, so they ride the same `resolved` gate (schema stage != off).
|
|
3790
|
+
...(resolvedRouteFields
|
|
3791
|
+
? {
|
|
3792
|
+
rigor: routeFields.rigor,
|
|
3793
|
+
resumable: routeFields.resumable,
|
|
3794
|
+
escalationTriggers: routeFields.escalationTriggers,
|
|
3795
|
+
decisionShadow: routeFields.decisionShadowFields,
|
|
3796
|
+
}
|
|
3797
|
+
: {}),
|
|
3530
3798
|
approachSelector,
|
|
3531
3799
|
executionContract,
|
|
3532
3800
|
tierLane,
|
|
@@ -3537,6 +3805,10 @@ function buildRouteSummary({
|
|
|
3537
3805
|
...(riskFloor ? { riskFloor } : {}),
|
|
3538
3806
|
// FR-004 additive top-level field — present only when fastPath stage is on.
|
|
3539
3807
|
...(fastPath ? { fastPath } : {}),
|
|
3808
|
+
// BL-013 additive top-level field — present only when the caller supplied
|
|
3809
|
+
// a history struct (extraction ran, possibly degraded). Null/absent keeps
|
|
3810
|
+
// the route byte-identical for callers without a transcript.
|
|
3811
|
+
...(routeFields.historySignals ? { historySignals: routeFields.historySignals } : {}),
|
|
3540
3812
|
continuationState,
|
|
3541
3813
|
autonomyLevel,
|
|
3542
3814
|
continuousExecution,
|
|
@@ -3562,6 +3834,10 @@ function buildRouteSummary({
|
|
|
3562
3834
|
...(routingContext.visionModel ? { visionModel: routingContext.visionModel } : {}),
|
|
3563
3835
|
...(routingContext.visionAdvisory ? { visionAdvisory: routingContext.visionAdvisory } : {}),
|
|
3564
3836
|
} : {}),
|
|
3837
|
+
// C89 TASK-005 additive field — typed delegation advice shadow; present
|
|
3838
|
+
// only when subagentOrchestrator.roleAdvice.stage != 'off' (conditional
|
|
3839
|
+
// spread keeps the stage-off route byte-identical).
|
|
3840
|
+
...(delegationAdvice ? { delegationAdvice } : {}),
|
|
3565
3841
|
line: line || 'task=unknown',
|
|
3566
3842
|
};
|
|
3567
3843
|
}
|
|
@@ -3680,33 +3956,225 @@ function isInformationalPrompt({
|
|
|
3680
3956
|
const strongQuestion = raw.includes('?')
|
|
3681
3957
|
|| /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
|
|
3682
3958
|
|| /(là\s+(?:[^\s]+\s+){0,2}gì|thế nào|như thế nào|bao nhiêu|khi nào|bao giờ|ở đâu)/.test(raw);
|
|
3959
|
+
// C85-019: consult-shaped questions overrule subject-matter signal scores.
|
|
3960
|
+
// "should I fix X?", "X chưa nhỉ?", "có nên/có cần sửa không?" mention
|
|
3961
|
+
// mutation vocabulary as SUBJECT — the ask is a decision/answer, not an
|
|
3962
|
+
// order. Tight on purpose: modal-subject inversion restricted to
|
|
3963
|
+
// should/shall (decision asks) — "how do I fix…?" and "bạn có thể sửa…
|
|
3964
|
+
// giúp tôi không?" are work-shaped questions, not consults.
|
|
3965
|
+
const folded = raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd');
|
|
3966
|
+
const politeAsk = /\b(?:giup|giuong|ho|dum)\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded)
|
|
3967
|
+
|| /\bcho\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded);
|
|
3968
|
+
const consultShape = /(?:^|[.!;\n]\s*)(?:should|shall)\s+(?:i|we)\s+(?!(?:i|we)\s)/.test(folded)
|
|
3969
|
+
|| /\b(?:co|có)\s+(?:nen|can|phai)\s+/.test(folded)
|
|
3970
|
+
|| /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
|
|
3971
|
+
|| (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
|
|
3972
|
+
|| /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
|
|
3683
3973
|
if (scores) {
|
|
3684
|
-
if (
|
|
3974
|
+
if (!consultShape && (
|
|
3685
3975
|
scores.implementSignal
|
|
3686
3976
|
|| scores.reviewSignal
|
|
3687
3977
|
|| scores.debugSignal
|
|
3688
3978
|
|| scores.impactSignal
|
|
3689
3979
|
|| scores.smallFixSignal
|
|
3690
3980
|
|| scores.directTransformSignal
|
|
3691
|
-
) {
|
|
3981
|
+
)) {
|
|
3692
3982
|
return false;
|
|
3693
3983
|
}
|
|
3694
|
-
if (scores.failureSignal && !strongQuestion) {
|
|
3984
|
+
if (scores.failureSignal && !strongQuestion && !consultShape) {
|
|
3695
3985
|
return false;
|
|
3696
3986
|
}
|
|
3697
3987
|
}
|
|
3988
|
+
// C85-019: consult-shape also overrides the vocabulary vetoes — the
|
|
3989
|
+
// mutation/debug/lỗi words are the question's SUBJECT, not the ask.
|
|
3698
3990
|
const implementWords = /(?<![A-Za-z0-9_])(implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|build|chạy|cài)(?![A-Za-z0-9_])/.test(raw);
|
|
3699
|
-
if (implementWords) return false;
|
|
3991
|
+
if (implementWords && !consultShape) return false;
|
|
3700
3992
|
const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
|
|
3701
|
-
if (investigationWords) return false;
|
|
3993
|
+
if (investigationWords && !consultShape) return false;
|
|
3702
3994
|
// \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
|
|
3703
3995
|
// substring: an error report is informational only when phrased as a question.
|
|
3704
|
-
if (raw.includes('lỗi') && !strongQuestion) return false;
|
|
3996
|
+
if (raw.includes('lỗi') && !strongQuestion && !consultShape) return false;
|
|
3705
3997
|
const reviewWords = /\b(review|audit|verify)\b/.test(raw);
|
|
3706
|
-
if (reviewWords) return false;
|
|
3707
|
-
|
|
3998
|
+
if (reviewWords && !consultShape) return false;
|
|
3999
|
+
// C85-019: consult shape is itself a question signal (Vietnamese tails like
|
|
4000
|
+
// "chưa"/"rồi" never match the Latin interrogative regex above).
|
|
4001
|
+
return questionSignal || consultShape;
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
// TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
|
|
4005
|
+
// scores. A report or advisory question carrying fix vocabulary as SUBJECT
|
|
4006
|
+
// MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
|
|
4007
|
+
// "chỉ tư vấn") still fires implementSignal/smallFixSignal/failureSignal, which
|
|
4008
|
+
// used to route the turn mutating so the completion gate demanded an Edit the
|
|
4009
|
+
// user explicitly declined. The override fires only when an assertion phrase is
|
|
4010
|
+
// present AND no unambiguous implement order survives outside the
|
|
4011
|
+
// negated/advisory/quoted/report clauses: "sửa file X nhưng giữ nguyên Y"
|
|
4012
|
+
// keeps its mutation lane because 'sửa file X' is outside the keep-clause,
|
|
4013
|
+
// while "hook cần được cấu hình để chỉ yêu cầu bằng chứng sửa file" is a
|
|
4014
|
+
// description of the hook's behavior, not an order. Folded (diacritic-free)
|
|
4015
|
+
// text is matched so both Vietnamese spellings work. Interrogative-deontic
|
|
4016
|
+
// forms ("should I…?", "có cần sửa…?") count as advisory requests, not orders;
|
|
4017
|
+
// a bare "không biết…chưa" uncertainty is NOT an assertion and stays on the
|
|
4018
|
+
// plain question path.
|
|
4019
|
+
function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
|
|
4020
|
+
const folded = `${promptText ?? ''}\n${commandText ?? ''}`
|
|
4021
|
+
.toLowerCase()
|
|
4022
|
+
.normalize('NFD')
|
|
4023
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
4024
|
+
.replace(/\u0111/g, 'd');
|
|
4025
|
+
const trimmed = folded.trim();
|
|
4026
|
+
if (!trimmed) return false;
|
|
4027
|
+
const advisoryAssertion = /\b(?:do\s+not|don't|dont)\s+(?:change|edit|fix|modify|alter|touch)\b/
|
|
4028
|
+
.test(trimmed)
|
|
4029
|
+
|| /\bno\s+(?:changes?|edits?|updates?|modifications?)\b/.test(trimmed)
|
|
4030
|
+
|| /\bkeep\s+[^\n.,;]{0,60}\b(?:unchanged|as\s+is|the\s+same|intact)\b/.test(trimmed)
|
|
4031
|
+
|| /\badvisory\s+only\b/.test(trimmed)
|
|
4032
|
+
|| /\bonly\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
|
|
4033
|
+
|| /\bjust\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
|
|
4034
|
+
|| /(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b/.test(trimmed)
|
|
4035
|
+
|| /\bgiu\s+nguyen\b/.test(trimmed)
|
|
4036
|
+
|| /\bmuon\s+giu\b/.test(trimmed)
|
|
4037
|
+
|| /\bkhong\s+(?:can\s+|muon\s+|phai\s+)?(?:sua|thay\s+doi|tao\s+thay\s+doi|edit|fix|chinh\s+sua|trien\s+khai|cap\s+nhat)\b/
|
|
4038
|
+
.test(trimmed)
|
|
4039
|
+
|| /\bdung\s+(?:sua|thay\s+doi|edit|chinh)\b/.test(trimmed)
|
|
4040
|
+
|| /\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi)\b/.test(trimmed)
|
|
4041
|
+
|| /\bco\s+(?:can|nen)\s+sua\b/.test(trimmed)
|
|
4042
|
+
// C89-008 (user-reported 9-stop loop): a waiting/no-change turn asserts the
|
|
4043
|
+
// answer is advisory — "đang chờ kết quả test QAS, không có gì cần sửa" —
|
|
4044
|
+
// but matches none of the shapes above and is not question-shaped, so it
|
|
4045
|
+
// escaped every informational gate and the stop hook demanded an Edit the
|
|
4046
|
+
// honest turn could never produce. 'không có gì' needs its own trigger —
|
|
4047
|
+
// the generic 'không (cần) sửa' pattern cannot skip the 'có gì' gap.
|
|
4048
|
+
|| /\bkhong\s+co\s+gi\s+(?:can|phai|de|dang|con)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
|
|
4049
|
+
|| /\bnothing\s+(?:to\s+(?:fix|edit|change|update|modify|do)|needs?\s+(?:fixing|editing|changing|updating))\b/.test(trimmed)
|
|
4050
|
+
|| /\bdang\s+(?:cho|doi)\b/.test(trimmed)
|
|
4051
|
+
|| /\b(?:waiting|awaiting)\s+(?:for|on)\b/.test(trimmed)
|
|
4052
|
+
|| /\bchua\s+(?:can|nen|phai)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
|
|
4053
|
+
|| /\b(?:cho|doi)\s+(?:ket\s+qua|bao\s+cao|feedback|phe\s+duyet|duyet|phien\s+ban|ci|test|qas|review|build|deploy|approval|xong)\b/.test(trimmed);
|
|
4054
|
+
if (!advisoryAssertion) return false;
|
|
4055
|
+
// Strip quoted spans and every clause class that can carry mutation
|
|
4056
|
+
// vocabulary without being an order: the advisory/negated assertions
|
|
4057
|
+
// themselves, conditional or purpose clauses (nếu/để/cho/if), reported
|
|
4058
|
+
// demands of another agent (yêu cầu/ép/bắt/requires/forced), passive
|
|
4059
|
+
// necessity descriptions (cần được cấu hình/must be configured), and the
|
|
4060
|
+
// ledger's own evidence-class names. Whatever mutation verb remains after
|
|
4061
|
+
// the strip is an actual implement order and vetoes the advisory route.
|
|
4062
|
+
// C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
|
|
4063
|
+
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
4064
|
+
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
4065
|
+
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
4066
|
+
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
4067
|
+
// C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
|
|
4068
|
+
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
|
|
4069
|
+
// to a mutation verb, or at punctuation/EOS — "đang chờ kết quả nhưng sửa
|
|
4070
|
+
// file này" keeps 'sửa file này' as a surviving order.
|
|
4071
|
+
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
4072
|
+
const residue = trimmed
|
|
4073
|
+
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
4074
|
+
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
4075
|
+
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
4076
|
+
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
4077
|
+
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
4078
|
+
.replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
|
|
4079
|
+
// C89-008: a waiting clause narrates what the TURN waits on, not what the
|
|
4080
|
+
// request orders — verbs inside it ("chờ sửa xong", "waiting for the fix")
|
|
4081
|
+
// belong to the wait, not to the implement order.
|
|
4082
|
+
.replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4083
|
+
.replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4084
|
+
.replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4085
|
+
.replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
|
|
4086
|
+
// 'chưa cần/nên/phải sửa' defers the change — same class as 'không cần
|
|
4087
|
+
// sửa' but 'chưa' is not covered by the 'không' strip above. WAIT_TAIL
|
|
4088
|
+
// bounds it so '…nhưng sửa phần này' keeps the surviving order.
|
|
4089
|
+
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
4090
|
+
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
4091
|
+
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
4092
|
+
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
4093
|
+
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
4094
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
4095
|
+
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
4096
|
+
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
4097
|
+
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
4098
|
+
.replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
|
|
4099
|
+
.replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
|
|
4100
|
+
.replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
|
|
4101
|
+
.replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ');
|
|
4102
|
+
const mutationOrder = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
|
|
4103
|
+
return !mutationOrder;
|
|
4104
|
+
}
|
|
4105
|
+
// C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
|
|
4106
|
+
// advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
|
|
4107
|
+
// reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must be),
|
|
4108
|
+
// evidence-class names — decides whether ANY surviving verb is an imperative.
|
|
4109
|
+
// Used by hasAdvisoryOnlyAssertion (assertion + no order → advisory) and by
|
|
4110
|
+
// the question-shape gate below (question + no order → consult, even when the
|
|
4111
|
+
// subject vocabulary fires debug/failure/implement signal scores).
|
|
4112
|
+
function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
|
|
4113
|
+
const folded = `${promptText ?? ''}\n${commandText ?? ''}`
|
|
4114
|
+
.toLowerCase()
|
|
4115
|
+
.normalize('NFD')
|
|
4116
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
4117
|
+
.replace(/\u0111/g, 'd');
|
|
4118
|
+
const trimmed = folded.trim();
|
|
4119
|
+
if (!trimmed) return false;
|
|
4120
|
+
// C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
|
|
4121
|
+
// — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
|
|
4122
|
+
// order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
|
|
4123
|
+
// TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
|
|
4124
|
+
const TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and)\\s+(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
4125
|
+
// C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
|
|
4126
|
+
// conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
|
|
4127
|
+
// to a mutation verb, or at punctuation/EOS.
|
|
4128
|
+
const WAIT_TAIL = '[^.,;\\n]*?(?=\\s+(?:va|and|nhung|roi|then|later|sau\\s+do)\\s+[^.,;\\n]*?\\b(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\\s+the|cap\\s+nhat|viet|chay|cai|chinh|trien\\s+khai|cau\\s+hinh)(?![A-Za-z0-9_])|[.,;]|\\s*$)';
|
|
4129
|
+
const residue = trimmed
|
|
4130
|
+
.replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
|
|
4131
|
+
.replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
|
|
4132
|
+
.replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
|
|
4133
|
+
.replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
|
|
4134
|
+
.replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
|
|
4135
|
+
.replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
|
|
4136
|
+
// C89-008: a waiting clause narrates what the TURN waits on, not what the
|
|
4137
|
+
// request orders — verbs inside it belong to the wait.
|
|
4138
|
+
.replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4139
|
+
.replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4140
|
+
.replace(new RegExp(`\\b(?:cho|doi)\\s+(?:ket\\s+qua|bao\\s+cao|feedback|phe\\s+duyet|duyet|phien\\s+ban|ci|test|qas|review|build|deploy|approval|xong)\\b${WAIT_TAIL}`, 'g'), ' ')
|
|
4141
|
+
.replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
|
|
4142
|
+
// 'chưa cần/nên/phải sửa' defers the change — WAIT_TAIL bounds it so a
|
|
4143
|
+
// '…nhưng sửa phần này' order survives.
|
|
4144
|
+
.replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
|
|
4145
|
+
.replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
|
|
4146
|
+
// C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
|
|
4147
|
+
// "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
|
|
4148
|
+
// inside it is never the order.
|
|
4149
|
+
.replace(new RegExp(`\\bkhi\\s+(?:toi|ta|ban|no|anh|chi|he|she|we)\\s+[^.,;\\n?]*?(?=\\s+(?:va|and)\\b|\\s*$)`, 'g'), ' ')
|
|
4150
|
+
.replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
|
|
4151
|
+
.replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
|
|
4152
|
+
.replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
|
|
4153
|
+
.replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
|
|
4154
|
+
.replace(new RegExp(`\\b(?:can|nen|phai|duoc)\\s+(?:duoc\\s+)?(?:sua|cau\\s+hinh|thay\\s+doi|cap\\s+nhat|edit|fix|config)\\b${TAIL}`, 'g'), ' ')
|
|
4155
|
+
.replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
|
|
4156
|
+
.replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
|
|
4157
|
+
.replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
|
|
4158
|
+
.replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
|
|
4159
|
+
.replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
|
|
4160
|
+
.replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ')
|
|
4161
|
+
// C85-019b: question-tagged mutation verbs are asks, not orders. English:
|
|
4162
|
+
// "<modal> I/we <verb>" (should I fix). Vietnamese: S-V-order status
|
|
4163
|
+
// questions — "<verb> … (chưa|được không|rồi|nhỉ)?" means "has it been
|
|
4164
|
+
// done", never "do it". EXCLUDED: "… giúp/cho/hộ/dùm tôi không?" — the
|
|
4165
|
+
// 'không' there is a polite request marker, not a status tag, and the
|
|
4166
|
+
// mutation verb is a real order; also EXCLUDED are wh-led modals
|
|
4167
|
+
// ("which files should I update") — a selection ask is a plan, not a
|
|
4168
|
+
// yes/no consult. These strips run LAST so a real imperative
|
|
4169
|
+
// mid-sentence ("sửa file X cho tôi") still survives.
|
|
4170
|
+
.replace(/\b(?<!\b(?:which|what|how)\s[^.,;?]{0,30})(?:should|shall)\s+(?:i|we)\s+[^\n?]*[?]?/g, ' ')
|
|
4171
|
+
.replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
|
|
4172
|
+
.replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
|
|
4173
|
+
.replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
|
|
4174
|
+
return /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
|
|
3708
4175
|
}
|
|
3709
4176
|
|
|
4177
|
+
|
|
3710
4178
|
function deriveExecutionMode({
|
|
3711
4179
|
promptText = '',
|
|
3712
4180
|
commandText = '',
|
|
@@ -3739,6 +4207,15 @@ function deriveExecutionMode({
|
|
|
3739
4207
|
return 'informational';
|
|
3740
4208
|
}
|
|
3741
4209
|
|
|
4210
|
+
// TASK-C85-019: an explicit no-change/advisory assertion beats every signal
|
|
4211
|
+
// score — the prompt mentions mutation vocabulary as subject matter, not as
|
|
4212
|
+
// an order. Checked before the review/delivery/consultation gates so an
|
|
4213
|
+
// advisory turn never mints write or verification debt; a surviving
|
|
4214
|
+
// imperative outside the negated clause still routes mutating.
|
|
4215
|
+
if (hasAdvisoryOnlyAssertion({ promptText, commandText })) {
|
|
4216
|
+
return 'informational';
|
|
4217
|
+
}
|
|
4218
|
+
|
|
3742
4219
|
if (
|
|
3743
4220
|
releaseVerificationContinuation
|
|
3744
4221
|
|| ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
|
|
@@ -3756,25 +4233,27 @@ function deriveExecutionMode({
|
|
|
3756
4233
|
|| /^(?:lam sao|the nao|nhu the nao|nhu nao|vi sao|tai sao|khi nao|bao gio|bao nhieu|o dau|phai lam gi|lam gi|lam cach nao)\b/.test(foldedPromptText);
|
|
3757
4234
|
const questionShapedPrompt = /\?\s*$/.test(trimmedPromptText)
|
|
3758
4235
|
|| leadingInterrogative
|
|
3759
|
-
|| /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
|
|
4236
|
+
|| /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
|
|
4237
|
+
// C85-019: Vietnamese status/consult tails without a '?' — "… chưa",
|
|
4238
|
+
// "… chưa nhỉ", "… không nhỉ", "… đúng không", "… à". A question about a
|
|
4239
|
+
// defect must not mint the bug-fix floor's write/repro debt (user-reported
|
|
4240
|
+
// lock on "Không biết … chưa nhỉ").
|
|
4241
|
+
|| /\b(?:chua|duoc khong|dung khong|khong nhi|khong|nhi)\s*[.!?]*\s*$/.test(foldedPromptText);
|
|
3760
4242
|
// Implement verbs (Vietnamese included — the English-only scores above cannot see
|
|
3761
4243
|
// them) mark an action order even when phrased as a question ("ban sua giup toi…?" is
|
|
3762
4244
|
// an edit order, not a consultation). A leading interrogative overrides them:
|
|
3763
4245
|
// "Lam sao ma do duoc khi toi cai…?" asks HOW something is done, it does not order
|
|
3764
4246
|
// the action performed.
|
|
3765
4247
|
const consultationImplementWords = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|sửa|thêm|tạo|xóa|đổi|thay thế|cập nhật|viết|chạy|cài)(?![A-Za-z0-9_])/.test(trimmedPromptText);
|
|
4248
|
+
// C85-019: question shape + no surviving imperative = consult. The
|
|
4249
|
+
// pre-C85-019 all-zero-scores clause consulted only when NOTHING in the
|
|
4250
|
+
// prompt resembled work — impossible for a question about code. Replacing
|
|
4251
|
+
// it with hasMutationOrder keeps "sửa file cho tôi?" (an order wearing a
|
|
4252
|
+
// question mark) mutating while "lỗi này chưa nhỉ" / "should I fix the
|
|
4253
|
+
// hook?" / "Không biết … đã khắc phục chưa" consult. A targetFile still
|
|
4254
|
+
// vetoes: a named artifact is work, not a question.
|
|
3766
4255
|
const consultationOnlySignal = questionShapedPrompt
|
|
3767
|
-
&& (
|
|
3768
|
-
&& scores.editCertainty === 0
|
|
3769
|
-
&& !scores.implementSignal
|
|
3770
|
-
&& !scores.reviewSignal
|
|
3771
|
-
&& !scores.debugSignal
|
|
3772
|
-
&& !scores.failureSignal
|
|
3773
|
-
&& !scores.impactSignal
|
|
3774
|
-
&& !scores.buildSignal
|
|
3775
|
-
&& !scores.directTransformSignal
|
|
3776
|
-
&& !scores.smallFixSignal
|
|
3777
|
-
&& !scores.sharedRisk
|
|
4256
|
+
&& !hasMutationOrder({ promptText, commandText })
|
|
3778
4257
|
&& !targetFile;
|
|
3779
4258
|
|
|
3780
4259
|
if (deliveryOnlySignal || consultationOnlySignal) {
|
|
@@ -3930,117 +4409,6 @@ function executionModeRank(mode = '') {
|
|
|
3930
4409
|
return index >= 0 ? index : orderedModes.length;
|
|
3931
4410
|
}
|
|
3932
4411
|
|
|
3933
|
-
function buildExecutionContract(executionMode = null) {
|
|
3934
|
-
if (!executionMode) {
|
|
3935
|
-
return null;
|
|
3936
|
-
}
|
|
3937
|
-
|
|
3938
|
-
const contracts = {
|
|
3939
|
-
'tiny-fix': {
|
|
3940
|
-
modelTier: 'lite',
|
|
3941
|
-
maxReadPasses: 0,
|
|
3942
|
-
maxContextPulls: 0,
|
|
3943
|
-
verificationPolicy: 'minimal-or-targeted',
|
|
3944
|
-
completionRule: 'never-claim-done-without-write',
|
|
3945
|
-
delegationPolicy: 'disallow',
|
|
3946
|
-
completionEvidence: ['write-evidence'],
|
|
3947
|
-
},
|
|
3948
|
-
'local-fix': {
|
|
3949
|
-
modelTier: 'code',
|
|
3950
|
-
maxReadPasses: 1,
|
|
3951
|
-
maxContextPulls: 1,
|
|
3952
|
-
verificationPolicy: 'targeted-if-covered',
|
|
3953
|
-
completionRule: 'require-write',
|
|
3954
|
-
delegationPolicy: 'disallow',
|
|
3955
|
-
completionEvidence: ['write-evidence'],
|
|
3956
|
-
},
|
|
3957
|
-
'local-build': {
|
|
3958
|
-
modelTier: 'code',
|
|
3959
|
-
maxReadPasses: 2,
|
|
3960
|
-
maxContextPulls: 1,
|
|
3961
|
-
verificationPolicy: 'targeted-if-covered',
|
|
3962
|
-
completionRule: 'require-write-and-verification',
|
|
3963
|
-
delegationPolicy: 'disallow-by-default',
|
|
3964
|
-
completionEvidence: ['write-evidence', 'verification-evidence'],
|
|
3965
|
-
},
|
|
3966
|
-
'find-cause': {
|
|
3967
|
-
modelTier: 'code',
|
|
3968
|
-
maxReadPassesBeforeReassess: 3,
|
|
3969
|
-
verificationPolicy: 'root-cause-then-targeted',
|
|
3970
|
-
completionRule: 'never-claim-fixed-without-write-and-verification',
|
|
3971
|
-
delegationPolicy: 'allow-specialized-debug-lane',
|
|
3972
|
-
completionEvidence: ['write-evidence', 'verification-evidence'],
|
|
3973
|
-
},
|
|
3974
|
-
'shared-edit': {
|
|
3975
|
-
modelTier: 'code',
|
|
3976
|
-
maxReadPasses: 2,
|
|
3977
|
-
maxContextPulls: 2,
|
|
3978
|
-
verificationPolicy: 'targeted-then-widen-on-risk',
|
|
3979
|
-
completionRule: 'require-write-and-verification',
|
|
3980
|
-
delegationPolicy: 'allow-qualified-sidecar',
|
|
3981
|
-
completionEvidence: ['write-evidence', 'verification-evidence'],
|
|
3982
|
-
mirrorConsistencyRequired: true,
|
|
3983
|
-
},
|
|
3984
|
-
'map-impact': {
|
|
3985
|
-
modelTier: 'code',
|
|
3986
|
-
maxReadPasses: 3,
|
|
3987
|
-
maxContextPulls: 3,
|
|
3988
|
-
verificationPolicy: 'impact-first-then-targeted-then-widen-on-risk',
|
|
3989
|
-
completionRule: 'require-impact-evidence-before-edit-claim',
|
|
3990
|
-
delegationPolicy: 'allow-impact-sidecar',
|
|
3991
|
-
completionEvidence: ['impact-evidence', 'write-evidence', 'verification-evidence'],
|
|
3992
|
-
mirrorConsistencyRequired: true,
|
|
3993
|
-
},
|
|
3994
|
-
'review-release': {
|
|
3995
|
-
modelTier: 'smart',
|
|
3996
|
-
verificationPolicy: 'evidence-first',
|
|
3997
|
-
completionRule: 'report-findings-not-implementation',
|
|
3998
|
-
delegationPolicy: 'allow-review-sidecar',
|
|
3999
|
-
completionEvidence: ['verification-evidence'],
|
|
4000
|
-
},
|
|
4001
|
-
};
|
|
4002
|
-
|
|
4003
|
-
return contracts[executionMode] ? { ...contracts[executionMode] } : null;
|
|
4004
|
-
}
|
|
4005
|
-
|
|
4006
|
-
// Decision table v2 (V3_RESHAPE §5): the contracts table's modelTier column is the
|
|
4007
|
-
// base row; resolveModelTier adds the (riskFloor, hostCapabilities) columns.
|
|
4008
|
-
// Deterministic — no registry, no leases, no outbound capability calls.
|
|
4009
|
-
// Literal mirror of src/core/executionContracts.js resolveModelTier; parity is
|
|
4010
|
-
// locked by tests/consistency/executionContractSync.test.js.
|
|
4011
|
-
const MODEL_TIER_ORDER = ['lite', 'code', 'smart'];
|
|
4012
|
-
const MODEL_TIER_EFFORT = {
|
|
4013
|
-
lite: 'low',
|
|
4014
|
-
code: 'medium',
|
|
4015
|
-
smart: 'high',
|
|
4016
|
-
};
|
|
4017
|
-
|
|
4018
|
-
/**
|
|
4019
|
-
* Resolves {tier, effort, advisoryOnly} for a route. A 'high-risk' floor escalates
|
|
4020
|
-
* the tier one band (lite→code→smart, capped at smart) and forces effort 'high'.
|
|
4021
|
-
* Unknown mode → {tier:null, effort:null, advisoryOnly:true}. Missing/unknown
|
|
4022
|
-
* riskFloor → 'none'. Missing hostCapabilities → advisoryOnly:true (the route text
|
|
4023
|
-
* is all the host gets when it cannot bind model and effort).
|
|
4024
|
-
*/
|
|
4025
|
-
export function resolveModelTier({
|
|
4026
|
-
executionMode = null,
|
|
4027
|
-
riskFloor = null,
|
|
4028
|
-
hostCapabilities = null,
|
|
4029
|
-
} = {}) {
|
|
4030
|
-
const baseTier = buildExecutionContract(executionMode)?.modelTier ?? null;
|
|
4031
|
-
if (!baseTier) {
|
|
4032
|
-
return { tier: null, effort: null, advisoryOnly: true };
|
|
4033
|
-
}
|
|
4034
|
-
const highRisk = riskFloor?.floor === 'high-risk';
|
|
4035
|
-
const tier = highRisk
|
|
4036
|
-
? MODEL_TIER_ORDER[Math.min(MODEL_TIER_ORDER.indexOf(baseTier) + 1, MODEL_TIER_ORDER.length - 1)]
|
|
4037
|
-
: baseTier;
|
|
4038
|
-
return {
|
|
4039
|
-
tier,
|
|
4040
|
-
effort: highRisk ? 'high' : MODEL_TIER_EFFORT[tier],
|
|
4041
|
-
advisoryOnly: !(hostCapabilities?.canBindModel === true && hostCapabilities?.canBindEffort === true),
|
|
4042
|
-
};
|
|
4043
|
-
}
|
|
4044
4412
|
|
|
4045
4413
|
function buildApproachSelectorResult({
|
|
4046
4414
|
executionMode = null,
|
|
@@ -4105,37 +4473,6 @@ function buildApproachSelectorResult({
|
|
|
4105
4473
|
};
|
|
4106
4474
|
}
|
|
4107
4475
|
|
|
4108
|
-
function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
|
|
4109
|
-
if (!executionMode || executionMode === 'informational') {
|
|
4110
|
-
return null;
|
|
4111
|
-
}
|
|
4112
|
-
|
|
4113
|
-
const contract = buildExecutionContract(executionMode);
|
|
4114
|
-
const missingEvidence = [...(contract?.completionEvidence ?? [])];
|
|
4115
|
-
const requiresVerification = missingEvidence.includes('verification-evidence');
|
|
4116
|
-
if (
|
|
4117
|
-
requiresVerification
|
|
4118
|
-
&& verificationRecommendation
|
|
4119
|
-
&& !(verificationRecommendation.commands?.length || verificationRecommendation.fallbackCommands?.length)
|
|
4120
|
-
) {
|
|
4121
|
-
missingEvidence.push('verification-plan');
|
|
4122
|
-
}
|
|
4123
|
-
|
|
4124
|
-
let reason = 'completion evidence is still required';
|
|
4125
|
-
if (['tiny-fix', 'local-fix', 'local-build', 'shared-edit'].includes(executionMode)) {
|
|
4126
|
-
reason = 'implement request has not produced an edit yet';
|
|
4127
|
-
} else if (executionMode === 'review-release') {
|
|
4128
|
-
reason = 'review/release evidence is still required before final claim';
|
|
4129
|
-
} else if (executionMode === 'map-impact') {
|
|
4130
|
-
reason = 'impact evidence is still required before safe completion claim';
|
|
4131
|
-
}
|
|
4132
|
-
|
|
4133
|
-
return {
|
|
4134
|
-
claimAllowed: false,
|
|
4135
|
-
missingEvidence,
|
|
4136
|
-
reason,
|
|
4137
|
-
};
|
|
4138
|
-
}
|
|
4139
4476
|
|
|
4140
4477
|
function buildContinuationState({
|
|
4141
4478
|
nextActionType = null,
|
|
@@ -4588,26 +4925,6 @@ function buildHelperCommand({ commandNamespace = '.claude', scriptName, intent =
|
|
|
4588
4925
|
return parts.join(' ');
|
|
4589
4926
|
}
|
|
4590
4927
|
|
|
4591
|
-
const SHARED_IMPACT_PATTERNS = [
|
|
4592
|
-
/^\.claude\/hooks\//,
|
|
4593
|
-
/^\.claude\/ukit\//,
|
|
4594
|
-
/^\.codex\//,
|
|
4595
|
-
/^src\/index\//,
|
|
4596
|
-
/^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
|
|
4597
|
-
/^src\/core\/(output|token|compact)\//,
|
|
4598
|
-
/^template_project\/\.claude\/hooks\//,
|
|
4599
|
-
/^template_project\/\.claude\/ukit\//,
|
|
4600
|
-
/^manifests\/platform\.full\.yaml$/,
|
|
4601
|
-
/^template_project\//,
|
|
4602
|
-
];
|
|
4603
|
-
|
|
4604
|
-
function isSharedImpactFile(filePath) {
|
|
4605
|
-
const normalized = String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
4606
|
-
if (!normalized) {
|
|
4607
|
-
return false;
|
|
4608
|
-
}
|
|
4609
|
-
return SHARED_IMPACT_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
4610
|
-
}
|
|
4611
4928
|
|
|
4612
4929
|
function isTestLikeFile(filePath) {
|
|
4613
4930
|
return /\.(test|spec)\.[a-z0-9]+$/i.test(filePath)
|
|
@@ -4851,7 +5168,7 @@ function isFlagOrValue(argv, index) {
|
|
|
4851
5168
|
const arg = argv[index];
|
|
4852
5169
|
if (!arg.startsWith('--')) {
|
|
4853
5170
|
const prev = argv[index - 1];
|
|
4854
|
-
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter'].includes(prev));
|
|
5171
|
+
return Boolean(prev && ['--root', '--target', '--type', '--tool-command', '--last-prompt', '--session-id', '--adapter', '--transcript'].includes(prev));
|
|
4855
5172
|
}
|
|
4856
5173
|
return true;
|
|
4857
5174
|
}
|
|
@@ -4942,21 +5259,17 @@ function buildStructuredRouteFingerprintPayload(route = {}) {
|
|
|
4942
5259
|
approachContextPolicy: routeSummary?.approachSelector?.contextPolicy ?? null,
|
|
4943
5260
|
approachVerificationPolicy: routeSummary?.approachSelector?.verificationPolicy ?? null,
|
|
4944
5261
|
approachCompletionRule: routeSummary?.approachSelector?.completionRule ?? null,
|
|
4945
|
-
continuationRequired: routeSummary?.continuationState?.required ?? null,
|
|
4946
|
-
continuationReasons: unique(routeSummary?.continuationState?.reasons ?? []),
|
|
4947
|
-
continuationMilestone: routeSummary?.continuationState?.nextMilestone ?? null,
|
|
4948
|
-
continuationRepeatCount: routeSummary?.continuationState?.repeatCount ?? null,
|
|
4949
|
-
continuationStuckRisk: routeSummary?.continuationState?.stuckRisk ?? null,
|
|
4950
|
-
continuationRescueMode: routeSummary?.continuationState?.rescueMode ?? null,
|
|
4951
|
-
delegateHint: routeSummary?.delegateHint ?? null,
|
|
4952
|
-
nextActionType: routeSummary?.nextActionType ?? null,
|
|
4953
|
-
nextActionCommand: routeSummary?.nextActionCommand ?? null,
|
|
4954
|
-
helperHint: routeSummary?.helperHint ?? null,
|
|
4955
5262
|
completionRule: routeSummary?.executionContract?.completionRule ?? null,
|
|
4956
|
-
completionMissingEvidence: unique(routeSummary?.completionState?.missingEvidence ?? []),
|
|
4957
|
-
primaryCommands: unique(routeSummary?.primaryCommands ?? []),
|
|
4958
|
-
fallbackCommands: unique(routeSummary?.fallbackCommands ?? []),
|
|
4959
5263
|
preferredOrder: unique(routeSummary?.preferredOrder ?? []),
|
|
5264
|
+
// Fingerprint payload ends here — everything below was REMOVED (C85 fix
|
|
5265
|
+
// round): continuation/next-action/helper/command fields derive from the
|
|
5266
|
+
// LIVE session (prior context, ledger state, previousRouteSummary), so the
|
|
5267
|
+
// same re-asked prompt fingerprinted differently every emission. An
|
|
5268
|
+
// unstable routefp-v3 breaks every downstream join keyed on it —
|
|
5269
|
+
// historySignals.fixLoopCount (BL-013), ledger routeFingerprint joins
|
|
5270
|
+
// (BL-003/004), feedbackEvents re-route joins (BL-018), and the escalation
|
|
5271
|
+
// same-loop key itself. The fingerprint is a "same-symptom" join key, not
|
|
5272
|
+
// a dedupe key — stable request features only.
|
|
4960
5273
|
};
|
|
4961
5274
|
}
|
|
4962
5275
|
|
|
@@ -5113,6 +5426,17 @@ export function compactRouteSummary(routeSummary = null) {
|
|
|
5113
5426
|
nextActionType: routeSummary.nextActionType ?? null,
|
|
5114
5427
|
nextActionCommand: routeSummary.nextActionCommand ?? null,
|
|
5115
5428
|
helperHint: routeSummary.helperHint ?? null,
|
|
5429
|
+
// TASK-007 (BL-009): playbook fields survive compaction so
|
|
5430
|
+
// skill-router-state.json and cached route states carry the identical
|
|
5431
|
+
// playbookId/workGroup the helper emitted. BL-010 adds the direct/handoff
|
|
5432
|
+
// path + the ARCH fast-path enum — the guard + eval fixtures read them
|
|
5433
|
+
// off persisted route state.
|
|
5434
|
+
playbookId: routeSummary.playbookId ?? null,
|
|
5435
|
+
workGroup: routeSummary.workGroup ?? null,
|
|
5436
|
+
path: routeSummary.path ?? null,
|
|
5437
|
+
fastPathKind: routeSummary.fastPathKind ?? null,
|
|
5438
|
+
...(routeSummary.playbookReason ? { playbookReason: routeSummary.playbookReason } : {}),
|
|
5439
|
+
...(routeSummary.playbookLoad ? { playbookLoad: routeSummary.playbookLoad } : {}),
|
|
5116
5440
|
// M04.1 (TASK-007): the resumable-run pointer survives compaction so
|
|
5117
5441
|
// reinject-context resumes from the same taskId/boundary. Conditional
|
|
5118
5442
|
// spread — stage-off output stays byte-identical (no key when absent).
|
|
@@ -5130,6 +5454,24 @@ export function compactRouteSummary(routeSummary = null) {
|
|
|
5130
5454
|
escalatedTier: routeSummary.escalatedTier,
|
|
5131
5455
|
escalationReason: routeSummary.escalationReason ?? null,
|
|
5132
5456
|
} : {}),
|
|
5457
|
+
// BL-019: the applied measured tier survives compaction — additive so
|
|
5458
|
+
// stage-off/no-map routes keep the pre-BL-019 compact shape.
|
|
5459
|
+
...(routeSummary.measuredTier != null ? { measuredTier: routeSummary.measuredTier } : {}),
|
|
5460
|
+
// BL-018 (SPEC FR-006): the fix-loop escalation fields survive compaction
|
|
5461
|
+
// so persisted route state carries the deeper lane — the next emission's
|
|
5462
|
+
// exhaustion check and the stop gate read them off this record.
|
|
5463
|
+
// escalatedRouteFingerprint is the same-loop join key: the prior record
|
|
5464
|
+
// only binds when its stamped fingerprint equals the next emission's.
|
|
5465
|
+
...(routeSummary.escalatedLane ? {
|
|
5466
|
+
escalatedLane: routeSummary.escalatedLane,
|
|
5467
|
+
escalateReason: routeSummary.escalateReason ?? null,
|
|
5468
|
+
...(routeSummary.escalatedRouteFingerprint != null ? {
|
|
5469
|
+
escalatedRouteFingerprint: routeSummary.escalatedRouteFingerprint,
|
|
5470
|
+
} : {}),
|
|
5471
|
+
...(routeSummary.escalatedVerificationDepth != null ? {
|
|
5472
|
+
escalatedVerificationDepth: routeSummary.escalatedVerificationDepth,
|
|
5473
|
+
} : {}),
|
|
5474
|
+
} : {}),
|
|
5133
5475
|
// FR-005: riskEscalation is the Stop-gate carrier (FR-006) — it must survive
|
|
5134
5476
|
// skill-router-state.json compaction. Conditional spread keeps stage-off
|
|
5135
5477
|
// output byte-identical (no key emitted when the field is absent).
|
|
@@ -5138,6 +5480,14 @@ export function compactRouteSummary(routeSummary = null) {
|
|
|
5138
5480
|
// persisted route state keeps the redacted outcome. Conditional spread —
|
|
5139
5481
|
// stage-off output stays byte-identical (no key emitted when absent).
|
|
5140
5482
|
...(routeSummary.decisionPlane ? { decisionPlane: routeSummary.decisionPlane } : {}),
|
|
5483
|
+
// C89 TASK-005: the typed delegation advice survives compaction so
|
|
5484
|
+
// persisted route state keeps the shadow verdict. Conditional spread —
|
|
5485
|
+
// stage-off output stays byte-identical (no key emitted when absent).
|
|
5486
|
+
...(routeSummary.delegationAdvice ? { delegationAdvice: routeSummary.delegationAdvice } : {}),
|
|
5487
|
+
// BL-013: bounded history counters survive compaction so persisted route
|
|
5488
|
+
// state + cached routes keep the identical struct. Conditional spread —
|
|
5489
|
+
// absent keeps output byte-identical.
|
|
5490
|
+
...(routeSummary.historySignals ? { historySignals: routeSummary.historySignals } : {}),
|
|
5141
5491
|
// M01.1 compact whitelist (C01): grouped fields survive compaction minus verbose
|
|
5142
5492
|
// evidence; absent entirely when the route schema stage is off.
|
|
5143
5493
|
...(compactResolvedRoute(routeSummary) ?? {}),
|
|
@@ -5204,6 +5554,15 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
5204
5554
|
|
|
5205
5555
|
return {
|
|
5206
5556
|
ts: Date.now(),
|
|
5557
|
+
// TASK-007 (BL-009): playbook route fields — join key for the playbook
|
|
5558
|
+
// spine (BL-010/011/012 read playbookId off the route row). BL-010 adds
|
|
5559
|
+
// the direct/handoff path + fast-path enum so the eval join can assert
|
|
5560
|
+
// `direct, fastPath=small-feature` (E-01).
|
|
5561
|
+
playbookId: routeSummary.playbookId ?? null,
|
|
5562
|
+
workGroup: routeSummary.workGroup ?? null,
|
|
5563
|
+
playbookLoad: routeSummary.playbookLoad ?? null,
|
|
5564
|
+
path: routeSummary.path ?? null,
|
|
5565
|
+
fastPathKind: routeSummary.fastPathKind ?? null,
|
|
5207
5566
|
source: state?.source ?? 'task-route',
|
|
5208
5567
|
requestKey: state?.requestKey ?? null,
|
|
5209
5568
|
adapter: routingContext.adapter ?? 'claude',
|
|
@@ -5213,13 +5572,21 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
5213
5572
|
}),
|
|
5214
5573
|
targetFile: route?.routingContext?.targetFile ?? null,
|
|
5215
5574
|
taskType: routingContext.taskType ?? null,
|
|
5575
|
+
// BL-003: the ledger-visible join key — execution-ledger.mjs stamps
|
|
5576
|
+
// `routeFingerprint: routeState.fingerprint`, the same routefp-v3 digest
|
|
5577
|
+
// this state's `fingerprint` field carries.
|
|
5578
|
+
routeFingerprint: state?.fingerprint ?? null,
|
|
5216
5579
|
// FR-202a (TASK-228): active-skill ids for phase-2 skill-accuracy roll-ups.
|
|
5217
5580
|
// Telemetry only — deliberately excluded from dedupeKey/fingerprint inputs.
|
|
5218
5581
|
skillIds: (route?.activeSkills ?? state?.activeSkills ?? [])
|
|
5219
|
-
.map((s) => s
|
|
5220
|
-
.filter(
|
|
5582
|
+
.map((s) => (s && typeof s === 'object' ? s.id : s))
|
|
5583
|
+
.filter((id) => typeof id === 'string' && id)
|
|
5221
5584
|
.slice(0, 8),
|
|
5222
5585
|
executionMode: routeSummary.executionMode ?? null,
|
|
5586
|
+
// BL-019: the effective tier the route ran under — the join buckets
|
|
5587
|
+
// per-(mode,tier) reliability off this column. Telemetry only.
|
|
5588
|
+
modelTier: routeSummary?.executionContract?.modelTier ?? null,
|
|
5589
|
+
measuredTier: routeSummary?.measuredTier ?? null,
|
|
5223
5590
|
competingMode: routeSummary?.approachSelector?.competingMode ?? null,
|
|
5224
5591
|
competingScoreGap: routeSummary?.approachSelector?.competingScoreGap ?? null,
|
|
5225
5592
|
candidateModes: candidateModes.slice(0, 3),
|
|
@@ -5228,6 +5595,14 @@ function buildRouteAuditEntry({ route = null, state = null } = {}) {
|
|
|
5228
5595
|
repeatCount: routeSummary?.continuationState?.repeatCount ?? null,
|
|
5229
5596
|
rescueMode: routeSummary?.continuationState?.rescueMode ?? null,
|
|
5230
5597
|
wideningBlocked: routeSummary?.continuationState?.wideningBlocked ?? null,
|
|
5598
|
+
// BL-018: the deeper lane the fix-loop escalation chose (or 'blocked') —
|
|
5599
|
+
// the re-route feedback join keys on routeFingerprint, so a lane change
|
|
5600
|
+
// like this one is exactly the event that collector was built to see.
|
|
5601
|
+
escalatedLane: routeSummary?.escalatedLane ?? null,
|
|
5602
|
+
escalateReason: routeSummary?.escalateReason ?? null,
|
|
5603
|
+
// BL-013: bounded history counters on the audit row — same struct as the
|
|
5604
|
+
// route record, identical to the hook's audit emission.
|
|
5605
|
+
historySignals: routeSummary?.historySignals ?? null,
|
|
5231
5606
|
};
|
|
5232
5607
|
}
|
|
5233
5608
|
|
|
@@ -5296,7 +5671,39 @@ function mergeRouteAuditEntries(staged, parsed, newEntry) {
|
|
|
5296
5671
|
seen.add(key);
|
|
5297
5672
|
merged.push(item);
|
|
5298
5673
|
}
|
|
5299
|
-
|
|
5674
|
+
// BL-004: rows past the 40-entry cap are spilled to
|
|
5675
|
+
// route-audit.segments.jsonl (append-only sidecar) instead of being dropped —
|
|
5676
|
+
// consumers read ring + segments with requestKey dedupe.
|
|
5677
|
+
return { entries: merged.slice(0, 40), evicted: merged.slice(40) };
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5680
|
+
function routeAuditSegmentsPath(filePath) {
|
|
5681
|
+
return path.join(path.dirname(filePath), 'route-audit.segments.jsonl');
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5684
|
+
async function spillRouteAuditEntries(filePath, evicted) {
|
|
5685
|
+
if (!evicted?.length) return;
|
|
5686
|
+
try {
|
|
5687
|
+
await fs.appendFile(
|
|
5688
|
+
routeAuditSegmentsPath(filePath),
|
|
5689
|
+
evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
|
|
5690
|
+
);
|
|
5691
|
+
} catch {
|
|
5692
|
+
// Spill write failure degrades to the pre-BL-004 drop — the audit merge
|
|
5693
|
+
// itself must never fail on telemetry sidecar I/O.
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
|
|
5697
|
+
function spillRouteAuditEntriesSync(filePath, evicted) {
|
|
5698
|
+
if (!evicted?.length) return;
|
|
5699
|
+
try {
|
|
5700
|
+
fsSync.appendFileSync(
|
|
5701
|
+
routeAuditSegmentsPath(filePath),
|
|
5702
|
+
evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
|
|
5703
|
+
);
|
|
5704
|
+
} catch {
|
|
5705
|
+
// same degrade posture as the async spill above
|
|
5706
|
+
}
|
|
5300
5707
|
}
|
|
5301
5708
|
|
|
5302
5709
|
function parseRouteAuditSidecar(raw) {
|
|
@@ -5391,10 +5798,10 @@ function foldRouteAuditSidecarSync(filePath) {
|
|
|
5391
5798
|
} catch {
|
|
5392
5799
|
parsed = { entries: [] };
|
|
5393
5800
|
}
|
|
5801
|
+
const { entries: folded, evicted } = mergeRouteAuditEntries(staged, parsed, null);
|
|
5802
|
+
spillRouteAuditEntriesSync(filePath, evicted);
|
|
5394
5803
|
const tmpPath = `${filePath}.exitfold-${process.pid}`;
|
|
5395
|
-
fsSync.writeFileSync(tmpPath, JSON.stringify({
|
|
5396
|
-
entries: mergeRouteAuditEntries(staged, parsed, null),
|
|
5397
|
-
}));
|
|
5804
|
+
fsSync.writeFileSync(tmpPath, JSON.stringify({ entries: folded }));
|
|
5398
5805
|
fsSync.renameSync(tmpPath, filePath);
|
|
5399
5806
|
} finally {
|
|
5400
5807
|
try {
|
|
@@ -5456,9 +5863,9 @@ export async function appendRouteAuditEntry(filePath, entry) {
|
|
|
5456
5863
|
}
|
|
5457
5864
|
|
|
5458
5865
|
const parsed = await readJson(filePath, { entries: [] });
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
});
|
|
5866
|
+
const { entries: mergedEntries, evicted } = mergeRouteAuditEntries(sidecarEntries, parsed, entry);
|
|
5867
|
+
await spillRouteAuditEntries(filePath, evicted);
|
|
5868
|
+
await writeJsonAtomic(filePath, { entries: mergedEntries });
|
|
5462
5869
|
});
|
|
5463
5870
|
if (!result.ok) {
|
|
5464
5871
|
// Fail-closed on the merge (CX-8): the entry stays staged in the sidecar —
|