@ngockhoale/ukit 3.3.2 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/manifests/engineConformance.yaml +17 -1
  3. package/manifests/hostCapabilities.yaml +68 -1
  4. package/manifests/platform.full.yaml +138 -0
  5. package/manifests/platform.user.yaml +255 -3
  6. package/package.json +1 -1
  7. package/scripts/bench/subagent-orchestrator-corpus.mjs +275 -0
  8. package/scripts/bench/subagent-orchestrator-eval.mjs +565 -0
  9. package/scripts/probe/codex-capability-probe.mjs +169 -0
  10. package/src/cli/commands/doctor.js +168 -0
  11. package/src/cli/commands/indexTools.js +7 -0
  12. package/src/cli/commands/metrics.js +66 -2
  13. package/src/cli/commands/playbook.js +4 -4
  14. package/src/cli/commands/vm.js +49 -8
  15. package/src/core/agentRuntime/adapters.js +328 -27
  16. package/src/core/agentRuntime/artifacts.js +89 -0
  17. package/src/core/agentRuntime/context.js +345 -1
  18. package/src/core/agentRuntime/contract.js +296 -0
  19. package/src/core/agentRuntime/eventStore.js +176 -0
  20. package/src/core/agentRuntime/shadowRun.js +481 -5
  21. package/src/core/agentRuntime/telemetry.js +121 -0
  22. package/src/core/observability/emit/lifecycle.js +68 -1
  23. package/src/core/observability/emit/sessionBoot.js +393 -0
  24. package/src/core/observability/privacy/allowlist.js +10 -1
  25. package/src/core/observability/schema/registry.js +10 -0
  26. package/src/core/runtimeConfig.js +133 -0
  27. package/src/core/userPlaybooks.js +18 -3
  28. package/src/decision/registry.js +19 -0
  29. package/src/diagnostics/feedbackEvents.js +7 -4
  30. package/src/diagnostics/routeOutcomes.js +51 -6
  31. package/src/diagnostics/skillAccuracy.js +43 -3
  32. package/src/index/crossCheckMatrix.js +412 -0
  33. package/src/index/fixLoopEscalation.js +453 -0
  34. package/src/index/playbookRegistry.js +691 -0
  35. package/src/index/reviewPolicy.js +368 -0
  36. package/src/index/routeResolver.js +915 -0
  37. package/src/index/sessionHistoryExtractor.js +359 -0
  38. package/src/index/taskRouting.js +764 -581
  39. package/src/index/tierSelection.js +308 -0
  40. package/src/index/verificationMap.js +404 -0
  41. package/template_project/.claude/hooks/observability-emit.mjs +14 -0
  42. package/template_project/.claude/hooks/record-execution.mjs +19 -1
  43. package/template_project/.claude/hooks/skill-router.sh +691 -25
  44. package/template_project/.claude/hooks/verification-guard.sh +230 -1
  45. package/template_project/.claude/settings.json +2 -2
  46. package/template_project/.claude/ukit/index/cross-check-matrix.mjs +415 -0
  47. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +456 -0
  48. package/template_project/.claude/ukit/index/playbook-registry.mjs +690 -0
  49. package/template_project/.claude/ukit/index/review-panel-aggregate.mjs +20 -2
  50. package/template_project/.claude/ukit/index/review-policy.mjs +376 -0
  51. package/template_project/.claude/ukit/index/route-resolver.mjs +1059 -0
  52. package/template_project/.claude/ukit/index/route-task.mjs +1253 -846
  53. package/template_project/.claude/ukit/index/session-history-extractor.mjs +362 -0
  54. package/template_project/.claude/ukit/index/tier-selection.mjs +309 -0
  55. package/template_project/.claude/ukit/index/verification-map.mjs +403 -0
  56. package/template_project/.claude/ukit/index/worktree-sweep.mjs +195 -0
  57. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +789 -11
  58. package/template_project/.claude/ukit/runtime/observability-emit.mjs +1102 -0
  59. package/template_project/.claude/ukit/runtime/reinject-context.mjs +9 -1
  60. package/template_project/.claude/ukit/runtime/resumable-run.mjs +149 -5
  61. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +323 -6
  62. package/template_project/.codex/README.md +8 -0
  63. package/template_project/.omp/hooks/pre/ukit-bridge.js +8 -1
  64. package/template_project/ukit/README.md +1 -1
  65. package/template_project/ukit/storage/config.json +20 -0
  66. package/template_user/playbooks/architecture-decision.md +28 -0
  67. package/template_user/playbooks/autonomous-run.md +43 -0
  68. package/template_user/playbooks/autopilot-full.md +59 -0
  69. package/template_user/playbooks/autopilot-stack.md +54 -0
  70. package/template_user/playbooks/babysit.md +39 -0
  71. package/template_user/playbooks/bug-fix.md +3 -1
  72. package/template_user/playbooks/{issue-implementation.md → feature-implementation.md} +4 -2
  73. package/template_user/playbooks/hillclimb.md +44 -0
  74. package/template_user/playbooks/investigation.md +21 -0
  75. package/template_user/playbooks/migration.md +21 -0
  76. package/template_user/playbooks/open-pr.md +48 -0
  77. package/template_user/playbooks/orchestrate.md +45 -0
  78. package/template_user/playbooks/performance.md +33 -0
  79. package/template_user/playbooks/prototype.md +28 -0
  80. package/template_user/playbooks/refactor.md +19 -0
  81. package/template_user/playbooks/release.md +28 -0
  82. package/template_user/playbooks/runtime-forensics.md +23 -0
  83. package/template_user/playbooks/session-pickup.md +31 -0
  84. package/template_user/playbooks/shipping.md +53 -0
  85. package/template_user/playbooks/skill-evaluation.md +48 -0
  86. package/template_user/playbooks/small-feature.md +20 -0
  87. package/template_user/playbooks/verification-map.json +153 -0
  88. package/template_user/playbooks/verification.md +22 -0
  89. package/template_user/playbooks/worktree-cleanup.md +37 -0
@@ -10,13 +10,73 @@ import {
10
10
  CONTRACT_RISK_PROFILES,
11
11
  EXECUTION_CONTRACTS,
12
12
  EXECUTION_MODE_ORDER,
13
- MODEL_TIER_BY_CONTRACT,
14
- ROUTE_EFFORTS,
15
- resolveModelTier,
16
13
  } from '../core/executionContracts.js';
17
- import { resolveModelRoles, readMergedRuntimeConfig, resolveConfigStage } from '../core/runtimeConfig.js';
14
+ import { resolveModelRoles, readMergedRuntimeConfig, resolveConfigStage, resolveSubagentOrchestratorStage } from '../core/runtimeConfig.js';
18
15
  import { runShadowDecisions } from '../decision/shadow.js';
19
16
  import { resolvePlaybook } from '../core/userPlaybooks.js';
17
+ import { resolvePlaybookRoute, loadRegistry } from './playbookRegistry.js';
18
+ import { extractHistorySignalsAsync } from './sessionHistoryExtractor.js';
19
+ import { applyMeasuredTier } from './tierSelection.js';
20
+ import { createDecisionClient } from '../decision/client.js';
21
+ import {
22
+ applyFixLoopEscalation,
23
+ isFixLoopEscalationDue,
24
+ } from './fixLoopEscalation.js';
25
+ import {
26
+ ROUTE_VERSION,
27
+ ROUTE_CONTRACT_VERSION,
28
+ ROUTE_SCHEMA_STAGES,
29
+ ROUTE_INTENT_KINDS,
30
+ ROUTE_MUTABILITIES,
31
+ ROUTE_RIGOR_LEVELS,
32
+ ROUTE_MODEL_TIERS,
33
+ ROUTE_RISK_FLOORS,
34
+ ROUTE_RISK_REASON_CODES,
35
+ ROUTE_EXECUTION_MODES,
36
+ resolveRouteSchemaStage,
37
+ resolveRouteStage,
38
+ deriveRiskFloor,
39
+ formatRiskFloorSegment,
40
+ deriveCeremonyLimits,
41
+ formatLimitsSegment,
42
+ deriveFastPath,
43
+ formatFastPathSegment,
44
+ isDeliveryOnlyRequest,
45
+ buildCompletionState,
46
+ validateResolvedRoute,
47
+ compactResolvedRoute,
48
+ deriveRouteFields,
49
+ resolveDecisionPlaneStage,
50
+ } from './routeResolver.js';
51
+
52
+ // TASK-004 (BL-006): the derivation primitives this file used to carry inline
53
+ // now live in routeResolver.js — the canonical half of the shared resolver the
54
+ // hook path consumes via route-resolver.mjs. Exported names stay identical via
55
+ // re-export (tests and the Codex instruction path import them from here).
56
+ export {
57
+ ROUTE_VERSION,
58
+ ROUTE_CONTRACT_VERSION,
59
+ ROUTE_SCHEMA_STAGES,
60
+ ROUTE_INTENT_KINDS,
61
+ ROUTE_MUTABILITIES,
62
+ ROUTE_RIGOR_LEVELS,
63
+ ROUTE_MODEL_TIERS,
64
+ ROUTE_RISK_FLOORS,
65
+ ROUTE_RISK_REASON_CODES,
66
+ ROUTE_EXECUTION_MODES,
67
+ resolveRouteSchemaStage,
68
+ resolveRouteStage,
69
+ deriveRiskFloor,
70
+ formatRiskFloorSegment,
71
+ deriveCeremonyLimits,
72
+ formatLimitsSegment,
73
+ deriveFastPath,
74
+ formatFastPathSegment,
75
+ buildCompletionState,
76
+ validateResolvedRoute,
77
+ compactResolvedRoute,
78
+ deriveRouteFields,
79
+ };
20
80
 
21
81
  // --- v3 route-side advisory blocks (docs/pstack/SPEC-playbook-todo.md, ------------------
22
82
  // SPEC-principle-index.md, SPEC-model-roles.md). All three are additive stdout text —
@@ -38,29 +98,33 @@ If the user says "new task", re-route — do not treat the message as the next s
38
98
  "might help" is a hypothesis, not a fix; it does not ship.
39
99
  4. Verify on the same surface: the original repro now passes. "Inconclusive" or
40
100
  wrong-surface is not a pass. A unit test shows branch behavior, not bug absence.
41
- 5. Keep the rejected hypotheses — one line each, why ruled out.
101
+ 5. Keep the rejected hypotheses — one line each, why ruled out. When rejections on
102
+ this same defect cross the fix-loop threshold, stop retrying this lane —
103
+ escalate to runtime-forensics (live symptom) or a deeper debug/verify lane.
42
104
  Reply: what was broken, root cause, fix, how verified — paste failing-then-passing
43
105
  repro output verbatim.
44
106
  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.`,
45
- 'issue-implementation': `You own this task. Normalize the goal, build, verify.
107
+ 'feature-implementation': `You own this task. Normalize the goal, build, verify.
46
108
  If the user says "new task", re-route — do not treat the message as the next step.
47
109
  1. State the done condition as a checkable predicate before writing code.
48
110
  2. Find the established analog — follow it unless you name why it does not fit.
111
+ (Skippable only when you can name why no analog exists.)
49
112
  3. Name the data shape and its organizing structure before writing logic.
50
113
  4. Implement the smallest change satisfying the predicate.
51
114
  5. Verify against the predicate on the real artifact — not "it compiles".
52
115
  6. Widen once: check the impact surface the route named, no broader.
53
116
  Reply: what changed, the predicate, the evidence it now holds.
54
- 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.`,
117
+ Ask the human only for: irreversible writes, a genuine preference call no experiment
118
+ settles, or a real dead end. Everything else: do it, report it.`,
55
119
  });
56
120
 
57
121
  // Lane → policy (§2.2). tiny-fix/local-fix stay lean (Fast Path); review-release and
58
122
  // informational carry no policy.
59
123
  export const WORKFLOW_POLICY_BY_MODE = Object.freeze({
60
124
  'find-cause': 'bug-fix',
61
- 'local-build': 'issue-implementation',
62
- 'shared-edit': 'issue-implementation',
63
- 'map-impact': 'issue-implementation',
125
+ 'local-build': 'feature-implementation',
126
+ 'shared-edit': 'feature-implementation',
127
+ 'map-impact': 'feature-implementation',
64
128
  });
65
129
 
66
130
  // SPEC-principle-index §2.1: ~16-line index, one line per principle; group headers are
@@ -147,480 +211,6 @@ function deriveContextDocs({ taskType = null, intentMode = null } = {}) {
147
211
  return unique(docs).slice(0, CONTEXT_DOCS_MAX);
148
212
  }
149
213
 
150
- // --- M01.1: additive ResolvedTaskRoute v1 (docs/pstack/CONTRACTS.md C01) -------------------
151
- // Emitted only when `routing.routeSchema.stage` (runtime config, default "off") is not
152
- // "off". All fields are additive on top of the existing routeSummary shape — legacy
153
- // top-level fields are never removed or renamed, so older consumers keep working.
154
- export const ROUTE_VERSION = 1;
155
- export const ROUTE_CONTRACT_VERSION = 1;
156
- export const ROUTE_SCHEMA_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
157
- export const ROUTE_INTENT_KINDS = new Set(['informational', 'delivery', 'mutation', 'investigation', 'review']);
158
- export const ROUTE_MUTABILITIES = new Set(['read-only', 'mutating', 'mixed']);
159
- export const ROUTE_RIGOR_LEVELS = new Set(['R0', 'R1', 'R2', 'R3', 'R4']);
160
- export const ROUTE_MODEL_TIERS = new Set(['lite', 'code', 'smart']);
161
- export const ROUTE_RISK_FLOORS = new Set(['none', 'high-risk']);
162
- // SPEC §5 FR-003: fixed table order — codes are emitted and printed in this order.
163
- // The first six raise the floor to 'high-risk'; the last two are informational only.
164
- export const ROUTE_RISK_REASON_CODES = Object.freeze([
165
- 'security-sensitive',
166
- 'shared-impact',
167
- 'public-contract',
168
- 'schema-change',
169
- 'destructive-action',
170
- 'cross-engine',
171
- 'established-precedent',
172
- 'explicit-local-target',
173
- ]);
174
- const ROUTE_RISK_FLOOR_RAISING_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(0, 6));
175
- const ROUTE_RISK_ENGINES = ['claude code', 'codex', 'omp'];
176
-
177
- // FR-004 (M01.3'): Fast Path eligibility vocabulary. The suppressed list is fixed
178
- // text per SPEC — deliberately not configurable. Reasons are the informational
179
- // risk codes (the last two ROUTE_RISK_REASON_CODES entries) present on the route.
180
- const FAST_PATH_MODES = new Set(['tiny-fix', 'local-fix']);
181
- const FAST_PATH_SUPPRESSED = Object.freeze(['design', 'smart', 'subagents', 'broad-verify']);
182
- const FAST_PATH_REASON_CODES = new Set(ROUTE_RISK_REASON_CODES.slice(6));
183
-
184
- // 'informational' is a real router emission (no completion contract) even though it is
185
- // not part of the seven-lane EXECUTION_MODE_ORDER ladder.
186
- export const ROUTE_EXECUTION_MODES = [...EXECUTION_MODE_ORDER, 'informational'];
187
- const ROUTE_ESCALATION_CEILING = 'review-release';
188
- const ROUTE_GOAL_MAX_LENGTH = 240;
189
-
190
- // Stage keys treat absence as "off" (MIGRATION_ROLLBACK named-keys table). Unknown or
191
- // malformed values degrade to "off" — the conservative reading that keeps the route
192
- // byte-identical to the pre-M01.1 shape.
193
- export function resolveRouteSchemaStage(config = null) {
194
- const stage = config?.routing?.routeSchema?.stage;
195
- return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
196
- }
197
-
198
- // FR-002: generic stage resolver — every routing.<key>.stage shares the same
199
- // absent/malformed → 'off' contract. resolveRouteSchemaStage stays as the
200
- // routeSchema-specific spelling of this helper.
201
- export function resolveRouteStage(config = null, key) {
202
- const stage = config?.routing?.[key]?.stage;
203
- return ROUTE_SCHEMA_STAGES.has(stage) ? stage : 'off';
204
- }
205
-
206
- // FR-003 (M01.2'): derive the additive riskFloor from hard signals. Codes are
207
- // collected in ROUTE_RISK_REASON_CODES table order; the floor is 'high-risk' iff
208
- // any of the first six (floor-raising) codes fired — informational codes never
209
- // raise it. Detectors read the normalized signal text (same source the mode
210
- // ladder uses) plus the raw target path and context preview.
211
- export function deriveRiskFloor({
212
- promptText = '',
213
- commandText = '',
214
- targetFile = null,
215
- executionMode = null,
216
- activeSkillIds = [],
217
- contextPreview = null,
218
- } = {}) {
219
- const signalText = buildRouteSignalText(promptText, commandText);
220
- const target = String(targetFile || '');
221
- const skillIds = Array.isArray(activeSkillIds) ? activeSkillIds : [];
222
- const codes = [];
223
- if (
224
- /\b(auth|security|token|permission|secret|credential|password|vulnerab|exploit|xss|injection)\b/i.test(signalText)
225
- || skillIds.includes('discover-security')
226
- ) {
227
- codes.push('security-sensitive');
228
- }
229
- if (isSharedImpactFile(targetFile) || executionMode === 'shared-edit' || executionMode === 'map-impact') {
230
- codes.push('shared-impact');
231
- }
232
- if (
233
- /(^|\/)(package\.json|manifests\/|.*\.d\.ts$|(^|\/)api\/|openapi|swagger)/i.test(target)
234
- || /\b(public api|breaking change|api contract|semver)\b/i.test(signalText)
235
- ) {
236
- codes.push('public-contract');
237
- }
238
- if (
239
- /(^|\/)(migrations?|db|database|prisma|schema)/i.test(target)
240
- || /\b(migration|migrate|schema|alter table|add column|drop column)\b/i.test(signalText)
241
- ) {
242
- codes.push('schema-change');
243
- }
244
- if (/\b(delete|drop|truncate|destroy|wipe|uninstall|rm -rf|purge)\b/i.test(signalText)) {
245
- codes.push('destructive-action');
246
- }
247
- const engineHits = ROUTE_RISK_ENGINES.filter(
248
- (name) => new RegExp(`\\b${name}\\b`, 'i').test(signalText),
249
- ).length;
250
- if (
251
- /\bcross[- ]engine\b|\ball engines\b/i.test(signalText)
252
- || engineHits >= 2
253
- || /^template_project\/\.(claude|codex|omp)\//i.test(target)
254
- ) {
255
- codes.push('cross-engine');
256
- }
257
- if ((contextPreview?.analogFiles?.length ?? 0) > 0 || (contextPreview?.styleFiles?.length ?? 0) > 0) {
258
- codes.push('established-precedent');
259
- }
260
- if (target && !isSharedImpactFile(targetFile)) {
261
- codes.push('explicit-local-target');
262
- }
263
- return {
264
- floor: codes.some((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code)) ? 'high-risk' : 'none',
265
- codes,
266
- };
267
- }
268
-
269
- // Route-line segment (FR-003): on 'high-risk' only the floor-raising codes print;
270
- // on 'none' the informational codes do. Empty list → no segment at all.
271
- function formatRiskFloorSegment(riskFloor = null) {
272
- if (!riskFloor) {
273
- return null;
274
- }
275
- const printed = riskFloor.floor === 'high-risk'
276
- ? riskFloor.codes.filter((code) => ROUTE_RISK_FLOOR_RAISING_CODES.has(code))
277
- : riskFloor.codes;
278
- return printed.length > 0 ? `risk=${riskFloor.floor}(${printed.join(',')})` : null;
279
- }
280
-
281
- // FR-001 (M01.2' limits fragment): compile the contract's numeric budget keys
282
- // into an advisory map. Only finite numbers survive — a non-numeric or missing
283
- // key is simply absent. Zero is a real budget ("no read passes"), never
284
- // filtered. Same key set as the ceremonyBudget.limits builder below.
285
- export function deriveCeremonyLimits(executionContract = null) {
286
- if (executionContract === null || typeof executionContract !== 'object') {
287
- return {};
288
- }
289
- return Object.fromEntries(
290
- ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
291
- .filter((key) => Number.isFinite(executionContract[key]))
292
- .map((key) => [key, executionContract[key]]),
293
- );
294
- }
295
-
296
- // Route-line segment (FR-002): fixed reads,ctx,reassess order; only present
297
- // keys print. Empty/absent map → null so the segment never appears.
298
- export function formatLimitsSegment(limits = null) {
299
- if (limits === null || typeof limits !== 'object') {
300
- return null;
301
- }
302
- const parts = [
303
- ['reads', 'maxReadPasses'],
304
- ['ctx', 'maxContextPulls'],
305
- ['reassess', 'maxReadPassesBeforeReassess'],
306
- ]
307
- .filter(([, key]) => Number.isFinite(limits[key]))
308
- .map(([label, key]) => `${label}:${limits[key]}`);
309
- return parts.length > 0 ? `limits=${parts.join(',')}` : null;
310
- }
311
-
312
- // FR-004 (M01.3'): Fast Path eligibility predicate. Returns null when no riskFloor
313
- // was supplied — eligibility must never be derived without the floor check, so a
314
- // missing floor means "not computed", not "none". Eligible iff the lane is
315
- // tiny-fix/local-fix, exactly one local target is known, the floor is 'none',
316
- // a bounded verification path exists (targeted commands or the tiny-fix
317
- // 'minimal-or-targeted' contract policy), and the target is not shared-impact.
318
- export function deriveFastPath({
319
- executionMode = null,
320
- targetFile = null,
321
- riskFloor = null,
322
- verificationRecommendation = null,
323
- contextPreview = null,
324
- } = {}) {
325
- if (!riskFloor) {
326
- return null;
327
- }
328
- const reasons = (riskFloor.codes ?? []).filter((code) => FAST_PATH_REASON_CODES.has(code));
329
- const hasLocalTarget = Boolean(targetFile) || contextPreview?.primaryTargets?.length === 1;
330
- const hasBoundedVerification = (verificationRecommendation?.commands?.length ?? 0) > 0
331
- || buildExecutionContract(executionMode)?.verificationPolicy === 'minimal-or-targeted';
332
- const eligible = FAST_PATH_MODES.has(executionMode)
333
- && hasLocalTarget
334
- && riskFloor.floor === 'none'
335
- && hasBoundedVerification
336
- && !isSharedImpactFile(targetFile);
337
- return {
338
- eligible,
339
- suppressed: eligible ? [...FAST_PATH_SUPPRESSED] : [],
340
- reasons,
341
- };
342
- }
343
-
344
- // Route-line segment (FR-004): emitted only for eligible routes — ineligible
345
- // routes keep the routeSummary.fastPath field for telemetry but stay silent.
346
- function formatFastPathSegment(fastPath = null) {
347
- if (!fastPath?.eligible) {
348
- return null;
349
- }
350
- const reasons = fastPath.reasons?.length ? ` (${fastPath.reasons.join(',')})` : '';
351
- return `fastPath=on | suppress: ${FAST_PATH_SUPPRESSED.join(',')}${reasons}`;
352
- }
353
-
354
-
355
-
356
- // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
357
- // repository mutation the ledger could ever receipt. With no edit/review/debug/build
358
- // signal present, routing it to an investigation lane fabricates write debt and the
359
- // completion gate then demands an edit that cannot exist. Extracted from
360
- // deriveExecutionMode so the C01 intent.kind mapping can reuse the identical predicate
361
- // (delivery-only → 'delivery') instead of duplicating the regexes.
362
- function isDeliveryOnlyRequest({ signalText = '', scores = {}, targetFile = null } = {}) {
363
- const signalRaw = String(signalText || '').toLowerCase();
364
- const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
365
- || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
366
- || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
367
- || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
368
- return deliveryWordSignal
369
- && scores.editCertainty === 0
370
- && !scores.implementSignal
371
- && !scores.reviewSignal
372
- && !scores.debugSignal
373
- && !scores.failureSignal
374
- && !scores.impactSignal
375
- && !scores.buildSignal
376
- && !scores.directTransformSignal
377
- && !scores.smallFixSignal
378
- && !scores.sharedRisk
379
- && !targetFile;
380
- }
381
-
382
-
383
- // @decision-point route.intent-kind.v1 — intent kind is a registered
384
- // consequential decision (DECISION_REGISTRY); this deterministic mapping is
385
- // its fallback policy implementation.
386
- // CONTRACTS.md "Intent vocabulary mapping": taskType informs mode priors, not
387
- // intent.kind; intentMode maps to kind as question/explanation → informational,
388
- // ship/deliver → delivery, code change → mutation, root-cause/diagnosis →
389
- // investigation, review/audit → review.
390
- function deriveRouteIntentKind({ executionMode = null, intentMode = null, deliveryOnly = false } = {}) {
391
- if (executionMode === 'find-cause') return 'investigation';
392
- if (executionMode === 'review-release') return 'review';
393
- if (executionMode === 'informational') return deliveryOnly ? 'delivery' : 'informational';
394
- if (executionMode) return 'mutation';
395
- if (intentMode === 'review-specific') return 'review';
396
- if (intentMode === 'debug-specific') return 'investigation';
397
- if (intentMode === 'implement-specific' || intentMode === 'docs-specific') return 'mutation';
398
- return 'informational';
399
- }
400
-
401
- // Existing read-only vs mutating classification: only the informational lane carries
402
- // no write debt; every contract lane is mutating.
403
- function deriveRouteMutability(executionMode = null) {
404
- return executionMode && executionMode !== 'informational' ? 'mutating' : 'read-only';
405
- }
406
-
407
- function compactRouteGoal(text = '') {
408
- const goal = String(text || '').trim();
409
- if (!goal) return null;
410
- return goal.length > ROUTE_GOAL_MAX_LENGTH ? `${goal.slice(0, ROUTE_GOAL_MAX_LENGTH)}…` : goal;
411
- }
412
-
413
- // Builds the additive C01 groups for one resolved route. rigor stays null until
414
- // M01.2 derives it; ceremonyBudget/capabilityPolicy are empty shaped objects M02/M01.2
415
- // populate; escalation.current mirrors the selected mode.
416
- function buildResolvedRouteFields({
417
- routingContext = {},
418
- activeSkillIds = [],
419
- executionMode = null,
420
- executionContract = null,
421
- completionState = null,
422
- riskFloor = null,
423
- } = {}) {
424
- // Decision table v2 (FR-001/FR-002): tier + effort resolve together from the
425
- // contract lane and the additive riskFloor. The router cannot observe host
426
- // binding capabilities, so the emitted pair is advisory text by definition.
427
- const tierDecision = resolveModelTier({ executionMode, riskFloor });
428
- const signalText = buildRouteSignalText(routingContext.promptText, routingContext.commandText);
429
- const deliveryOnly = isDeliveryOnlyRequest({
430
- signalText,
431
- scores: routingContext.executionScores ?? {},
432
- targetFile: routingContext.targetFile ?? null,
433
- });
434
- return {
435
- routeVersion: ROUTE_VERSION,
436
- intent: {
437
- kind: deriveRouteIntentKind({
438
- executionMode,
439
- intentMode: routingContext.intentMode ?? null,
440
- deliveryOnly,
441
- }),
442
- mutability: deriveRouteMutability(executionMode),
443
- goal: compactRouteGoal(routingContext.lastExplicitUserPromptText ?? routingContext.promptText),
444
- doneConditions: [],
445
- },
446
- execution: {
447
- mode: executionMode,
448
- rigor: null,
449
- riskFloor: riskFloor?.floor ?? null,
450
- phase: null,
451
- contractVersion: ROUTE_CONTRACT_VERSION,
452
- modelTier: tierDecision.tier,
453
- effort: tierDecision.effort,
454
- },
455
- evidence: {
456
- observations: [],
457
- riskSignals: riskFloor?.codes ?? [],
458
- activationReasons: [],
459
- suppressionReasons: [],
460
- completionRequirements: unique(completionState?.missingEvidence ?? []),
461
- },
462
- ceremonyBudget: {
463
- policyVersion: ROUTE_CONTRACT_VERSION,
464
- rigor: null,
465
- limits: riskFloor
466
- ? Object.fromEntries(
467
- ['maxReadPasses', 'maxContextPulls', 'maxReadPassesBeforeReassess']
468
- .filter((key) => typeof executionContract?.[key] === 'number')
469
- .map((key) => [key, executionContract[key]]),
470
- )
471
- : {},
472
- consumed: {},
473
- exceptions: [],
474
- },
475
- capabilityPolicy: {
476
- policyVersion: ROUTE_CONTRACT_VERSION,
477
- required: [],
478
- recommended: [],
479
- suppressed: [],
480
- activeSkillIds: unique(activeSkillIds),
481
- },
482
- escalation: {
483
- current: { mode: executionMode, rigor: null },
484
- ceiling: ROUTE_ESCALATION_CEILING,
485
- triggers: [],
486
- history: [],
487
- },
488
- };
489
- }
490
-
491
- // Plain-JS validator for the additive C01 groups. Returns { valid, errors }; it never
492
- // throws and never inspects legacy fields — old consumers may carry anything else.
493
- export function validateResolvedRoute(route = null) {
494
- const errors = [];
495
- const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
496
- if (!isObject(route)) {
497
- return { valid: false, errors: ['route must be an object.'] };
498
- }
499
- if (route.routeVersion !== ROUTE_VERSION) {
500
- errors.push(`routeVersion must be ${ROUTE_VERSION}.`);
501
- }
502
- if (!isObject(route.intent)) {
503
- errors.push('intent must be an object.');
504
- } else {
505
- if (!ROUTE_INTENT_KINDS.has(route.intent.kind)) {
506
- errors.push(`intent.kind must be one of: ${[...ROUTE_INTENT_KINDS].join(', ')}.`);
507
- }
508
- if (!ROUTE_MUTABILITIES.has(route.intent.mutability)) {
509
- errors.push(`intent.mutability must be one of: ${[...ROUTE_MUTABILITIES].join(', ')}.`);
510
- }
511
- if (route.intent.goal !== null && typeof route.intent.goal !== 'string') {
512
- errors.push('intent.goal must be a string or null.');
513
- }
514
- if (!Array.isArray(route.intent.doneConditions)) {
515
- errors.push('intent.doneConditions must be an array.');
516
- }
517
- }
518
- if (!isObject(route.execution)) {
519
- errors.push('execution must be an object.');
520
- } else {
521
- if (route.execution.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.execution.mode)) {
522
- errors.push(`execution.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
523
- }
524
- if (route.execution.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.execution.rigor)) {
525
- errors.push(`execution.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
526
- }
527
- if (route.execution.riskFloor !== null && !ROUTE_RISK_FLOORS.has(route.execution.riskFloor)) {
528
- errors.push(`execution.riskFloor must be null or one of: ${[...ROUTE_RISK_FLOORS].join(', ')}.`);
529
- }
530
- if (route.execution.contractVersion !== ROUTE_CONTRACT_VERSION) {
531
- errors.push(`execution.contractVersion must be ${ROUTE_CONTRACT_VERSION}.`);
532
- }
533
- if (route.execution.modelTier !== null && !ROUTE_MODEL_TIERS.has(route.execution.modelTier)) {
534
- errors.push(`execution.modelTier must be null or one of: ${[...ROUTE_MODEL_TIERS].join(', ')}.`);
535
- }
536
- if (route.execution.effort !== null && route.execution.effort !== undefined
537
- && !ROUTE_EFFORTS.has(route.execution.effort)) {
538
- errors.push(`execution.effort must be null or one of: ${[...ROUTE_EFFORTS].join(', ')}.`);
539
- }
540
- }
541
- if (!isObject(route.evidence)) {
542
- errors.push('evidence must be an object.');
543
- } else {
544
- for (const key of ['observations', 'riskSignals', 'activationReasons', 'suppressionReasons', 'completionRequirements']) {
545
- if (!Array.isArray(route.evidence[key])) {
546
- errors.push(`evidence.${key} must be an array.`);
547
- }
548
- }
549
- if (Array.isArray(route.evidence.riskSignals)
550
- && route.evidence.riskSignals.some(
551
- (code) => typeof code !== 'string' || !ROUTE_RISK_REASON_CODES.includes(code),
552
- )) {
553
- errors.push(`evidence.riskSignals entries must be one of: ${ROUTE_RISK_REASON_CODES.join(', ')}.`);
554
- }
555
- }
556
- if (!isObject(route.ceremonyBudget)) {
557
- errors.push('ceremonyBudget must be an object.');
558
- } else {
559
- if (route.ceremonyBudget.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.ceremonyBudget.rigor)) {
560
- errors.push(`ceremonyBudget.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
561
- }
562
- if (!isObject(route.ceremonyBudget.limits)) {
563
- errors.push('ceremonyBudget.limits must be an object.');
564
- }
565
- if (!isObject(route.ceremonyBudget.consumed)) {
566
- errors.push('ceremonyBudget.consumed must be an object.');
567
- }
568
- if (!Array.isArray(route.ceremonyBudget.exceptions)) {
569
- errors.push('ceremonyBudget.exceptions must be an array.');
570
- }
571
- }
572
- if (!isObject(route.capabilityPolicy)) {
573
- errors.push('capabilityPolicy must be an object.');
574
- } else {
575
- for (const key of ['required', 'recommended', 'suppressed', 'activeSkillIds']) {
576
- if (!Array.isArray(route.capabilityPolicy[key])) {
577
- errors.push(`capabilityPolicy.${key} must be an array.`);
578
- }
579
- }
580
- }
581
- if (!isObject(route.escalation)) {
582
- errors.push('escalation must be an object.');
583
- } else {
584
- if (!isObject(route.escalation.current)) {
585
- errors.push('escalation.current must be an object.');
586
- } else {
587
- if (route.escalation.current.mode !== null && !ROUTE_EXECUTION_MODES.includes(route.escalation.current.mode)) {
588
- errors.push(`escalation.current.mode must be null or one of: ${ROUTE_EXECUTION_MODES.join(', ')}.`);
589
- }
590
- if (route.escalation.current.rigor !== null && !ROUTE_RIGOR_LEVELS.has(route.escalation.current.rigor)) {
591
- errors.push(`escalation.current.rigor must be null or one of: ${[...ROUTE_RIGOR_LEVELS].join(', ')}.`);
592
- }
593
- }
594
- if (!Array.isArray(route.escalation.triggers)) {
595
- errors.push('escalation.triggers must be an array.');
596
- }
597
- if (!Array.isArray(route.escalation.history)) {
598
- errors.push('escalation.history must be an array.');
599
- }
600
- }
601
- return { valid: errors.length === 0, errors };
602
- }
603
-
604
- // Compact serialization whitelist (C01 compatibility rule): adapter/runtime consumers
605
- // get a bounded view that may omit verbose evidence but never mode, rigor, required
606
- // completion evidence, or escalation state. Returns null for legacy (stage-off)
607
- // summaries so compact output stays byte-identical when the schema stage is off.
608
- export function compactResolvedRoute(routeSummary = null) {
609
- if (!routeSummary || typeof routeSummary !== 'object' || routeSummary.routeVersion == null) {
610
- return null;
611
- }
612
- return {
613
- routeVersion: routeSummary.routeVersion,
614
- intent: routeSummary.intent ?? null,
615
- execution: routeSummary.execution ?? null,
616
- evidence: {
617
- completionRequirements: unique(routeSummary.evidence?.completionRequirements ?? []),
618
- },
619
- ceremonyBudget: routeSummary.ceremonyBudget ?? null,
620
- capabilityPolicy: routeSummary.capabilityPolicy ?? null,
621
- escalation: routeSummary.escalation ?? null,
622
- };
623
- }
624
214
 
625
215
 
626
216
  export async function deriveTaskRoute({
@@ -637,6 +227,24 @@ export async function deriveTaskRoute({
637
227
  // TASK-004 (FR-008/FR-009): decision-plane runtime seam — transport/env/
638
228
  // hostCapabilities overrides for the shadow evaluator. Unused at stage off.
639
229
  decisionPlaneOptions = null,
230
+ // BL-013: optional transcript pointer + caller-known route keys for the
231
+ // bounded history extractor. Absent → historySignals degrades (zeroed struct
232
+ // with degraded:true), never throws, ≤50ms tail read.
233
+ transcriptPath = null,
234
+ // BL-018: prior emission for the same route — the fix-loop escalation's
235
+ // exhaustion check reads its escalatedLane. Absent → first emission.
236
+ previousRouteSummary = null,
237
+ // BL-018: injected seams for the lane-deepening decision + registry check
238
+ // ({ask, appendReceipt, runtimeForensicsPresent}); absent → real client +
239
+ // real registry. decisions.tsv rows are a caller concern on this surface.
240
+ escalationDecision = null,
241
+ // C89 TASK-005 (SPEC §6): injected seams for the typed delegation-advice
242
+ // consult ({ask, appendReceipt, override}); absent → real decision client
243
+ // when the decision plane is enabled, otherwise a typed local outcome.
244
+ // decisions.tsv rows are a caller concern on this surface.
245
+ roleAdviceDecision = null,
246
+ requestKey = null,
247
+ routeFingerprint = null,
640
248
  } = {}) {
641
249
  const absoluteRoot = path.resolve(rootDir);
642
250
  // M01.1: additive route-schema stage. Absent config / absent key / unknown value all
@@ -768,6 +376,14 @@ export async function deriveTaskRoute({
768
376
  ? await checkHandoffBudget(absoluteRoot)
769
377
  : null;
770
378
  const worklogBudget = await checkWorklogBudget(absoluteRoot);
379
+ // BL-013: bounded session-history signals — extracted once per route on the
380
+ // hook-provided transcript path and merged into the route record. Degraded
381
+ // input yields the zeroed struct; the route still resolves.
382
+ const historySignals = await extractHistorySignalsAsync({
383
+ transcriptPath,
384
+ routeFingerprint,
385
+ requestKey,
386
+ });
771
387
  // SPEC FR-009: the lane's workflow policy resolves through the playbook layer —
772
388
  // project playbook > user playbook > builtin. resolvePlaybook already falls back to
773
389
  // the builtin table, so a null result only means the lane has no policy at all.
@@ -798,9 +414,100 @@ export async function deriveTaskRoute({
798
414
  routeSchemaStage,
799
415
  runtimeConfig: resolvedRuntimeConfig,
800
416
  resolvedWorkflowPolicy,
417
+ historySignals,
801
418
  });
419
+ // TASK-007 (BL-009): playbook resolution rides the same route signals the
420
+ // shared resolver just derived — the registry degrades to playbookId: null +
421
+ // reason and never blocks the route. Merged at resolve time so hook and
422
+ // helper emit the identical playbookId/workGroup.
423
+ const playbookResolution = await resolvePlaybookRoute({
424
+ promptText: normalizedPrompt,
425
+ commandText: normalizedCommand,
426
+ targetFile: normalizedTarget,
427
+ executionMode,
428
+ escalationTriggers: routeSummary?.escalationTriggers ?? [],
429
+ projectRoot: absoluteRoot,
430
+ homeDir,
431
+ });
432
+ routeSummary.playbookId = playbookResolution.playbookId;
433
+ routeSummary.workGroup = playbookResolution.workGroup;
434
+ routeSummary.playbookReason = playbookResolution.reason;
435
+ routeSummary.playbookLoad = playbookResolution.playbookLoad;
436
+ // BL-010 (ARCH Task Contract): `path` = direct|handoff; `fastPathKind` is
437
+ // the ARCH `none|small-feature|bug-fix` enum — named Kind because
438
+ // routeSummary.fastPath already carries the FR-004 eligibility object.
439
+ // The keys only land when a routing-table row claimed the prompt (workGroup
440
+ // non-null): no-match/degraded routes keep the PLAYBOOK_ROUTE_KEYS-only
441
+ // shape so stage-off routes never grow stray keys.
442
+ if (playbookResolution.workGroup != null) {
443
+ routeSummary.path = playbookResolution.path;
444
+ routeSummary.fastPathKind = playbookResolution.fastPath;
445
+ } else {
446
+ delete routeSummary.path;
447
+ delete routeSummary.fastPathKind;
448
+ }
802
449
  const approachSelector = routeSummary?.approachSelector ?? null;
803
450
 
451
+ // BL-018 (SPEC FR-006): fix-loop escalation — the same transition the hook
452
+ // path runs in route-task.mjs. historySignals.fixLoopCount is
453
+ // fingerprint-joined (same routeFingerprint/requestKey, never session-wide);
454
+ // at debugLoopThreshold the lane deepens via the laneDeepening/
455
+ // verificationDepth question, endpoint-down → deterministic fallback
456
+ // (fallback:tier-bump), deeper-lane exhaustion → BLOCKED. Additive
457
+ // escalatedLane/escalateReason/escalatedVerificationDepth fields — identical
458
+ // names on both surfaces.
459
+ const laneEscalationDue = isFixLoopEscalationDue({
460
+ historySignals: routeSummary.historySignals,
461
+ config: resolvedRuntimeConfig,
462
+ });
463
+ if (laneEscalationDue) {
464
+ const escalationClient = escalationDecision?.ask
465
+ ? null
466
+ : createDecisionClient({
467
+ config: resolvedRuntimeConfig,
468
+ transport: decisionPlaneOptions?.transport,
469
+ env: decisionPlaneOptions?.env,
470
+ projectRoot: absoluteRoot,
471
+ homeDir,
472
+ now: decisionPlaneOptions?.now,
473
+ });
474
+ await applyFixLoopEscalation({
475
+ routeSummary,
476
+ routingContext: {
477
+ promptText: normalizedPrompt,
478
+ commandText: normalizedCommand,
479
+ targetFile: normalizedTarget,
480
+ taskType: inferredTaskType,
481
+ intentMode,
482
+ },
483
+ previousRouteSummary,
484
+ // Same-loop join key for the escalation record — the caller supplies it
485
+ // (the same routeFingerprint the history extractor joins on); absent →
486
+ // the record is stamped fingerprint-less and can never bind a prior
487
+ // emission's exhaustion (escalate fresh, never fabricate 'blocked').
488
+ routeFingerprint,
489
+ config: resolvedRuntimeConfig,
490
+ ask: escalationDecision?.ask
491
+ ?? (escalationClient ? ({ batch }) => escalationClient.requestBatch(batch) : null),
492
+ appendReceipt: escalationDecision?.appendReceipt ?? null,
493
+ runtimeForensicsPresent: escalationDecision?.runtimeForensicsPresent
494
+ ?? await (async () => {
495
+ try {
496
+ const records = await loadRegistry({ projectRoot: absoluteRoot, homeDir });
497
+ return records.some((record) => record?.frontmatterValid && record?.playbookId === 'runtime-forensics');
498
+ } catch {
499
+ return false;
500
+ }
501
+ })(),
502
+ });
503
+ }
504
+
505
+ // BL-019 (SPEC FR-001): fold the persisted measured map into the route —
506
+ // measuredTier is always emitted (nullable); the authoritative modelTier
507
+ // follows the map ONLY when tier-map.json says 'promote'. Absent/corrupt
508
+ // artifact → static stays; never throws.
509
+ await applyMeasuredTier({ projectRoot: absoluteRoot, routeSummary });
510
+
804
511
  // TASK-004 (SPEC §5 FR-008/FR-009): decision-plane hook. Stage resolves via
805
512
  // the shared resolver — absent/malformed → 'off' → zero cost, byte-identical
806
513
  // route. shadow → redacted receipt only (deterministic policy authoritative);
@@ -840,6 +547,40 @@ export async function deriveTaskRoute({
840
547
  }
841
548
 
842
549
 
550
+ // C89 TASK-005 (SPEC §6 FR-04): typed delegation advice consult — the
551
+ // optional unic-decision round only ever answers the owner-bounded role
552
+ // question; the deterministic fields the route already carries stay
553
+ // authoritative and every failure records a typed advice.model outcome.
554
+ // The consult seam defaults to the same decision client the plane uses.
555
+ if (routeSummary?.delegationAdvice) {
556
+ try {
557
+ const roleAdviceClient = !roleAdviceDecision?.ask
558
+ && resolveDecisionPlaneStage(resolvedRuntimeConfig) !== 'off'
559
+ ? createDecisionClient({
560
+ config: resolvedRuntimeConfig,
561
+ transport: decisionPlaneOptions?.transport,
562
+ env: decisionPlaneOptions?.env,
563
+ projectRoot: absoluteRoot,
564
+ homeDir,
565
+ now: decisionPlaneOptions?.now,
566
+ })
567
+ : null;
568
+ await applyRoleAdviceConsult({
569
+ routeSummary,
570
+ config: resolvedRuntimeConfig,
571
+ ask: roleAdviceDecision?.ask
572
+ ?? (roleAdviceClient ? ({ batch }) => roleAdviceClient.requestBatch(batch) : null),
573
+ appendReceipt: roleAdviceDecision?.appendReceipt ?? null,
574
+ override: roleAdviceDecision?.override ?? null,
575
+ now: decisionPlaneOptions?.now ?? (() => Date.now()),
576
+ });
577
+ } catch (error) {
578
+ degradedWarnings.push(
579
+ `role-advice consult: unavailable (${error?.message ?? String(error)})`,
580
+ );
581
+ }
582
+ }
583
+
843
584
  return {
844
585
  activeSkills,
845
586
  routingContext: {
@@ -867,6 +608,9 @@ export async function deriveTaskRoute({
867
608
  }
868
609
 
869
610
  export function buildRouteSummary({
611
+ // BL-013: precomputed bounded history struct — merged additively via the
612
+ // shared resolver below. Absent/null → no field, byte-identical route.
613
+ historySignals = null,
870
614
  activeSkills = [],
871
615
  routingContext = {},
872
616
  contextRecommendation = null,
@@ -926,21 +670,51 @@ export function buildRouteSummary({
926
670
  nextActionType: nextAction?.type ?? null,
927
671
  completionState,
928
672
  });
929
- // FR-003 (M01.2'): riskFloor is a top-level additive field emitted whenever ANY
930
- // of rigor/fastPath/escalation stages is on — fastPath/escalation consumers read
931
- // it even when routeSchema is off. All three off → absent, byte-identical route.
932
- const riskStageOn = ['rigor', 'fastPath', 'escalation']
933
- .some((key) => resolveRouteStage(runtimeConfig, key) !== 'off');
934
- const riskFloor = riskStageOn
935
- ? deriveRiskFloor({
936
- promptText: routingContext.promptText,
937
- commandText: routingContext.commandText,
938
- targetFile: routingContext.targetFile ?? null,
939
- executionMode,
940
- activeSkillIds: activeSkills.map((entry) => entry.id),
941
- contextPreview: contextRecommendation?.preview ?? null,
942
- })
943
- : null;
673
+ // TASK-004 (BL-006): one shared resolver derives the deterministic route
674
+ // fields — the identical object skill-router.sh merges into hook state.
675
+ // The explicit routeSchemaStage param remains authoritative for the resolved
676
+ // groups (callers may force it independent of runtimeConfig); every other
677
+ // stage read comes from runtimeConfig inside deriveRouteFields.
678
+ const routeFields = deriveRouteFields({
679
+ promptText: routingContext.promptText,
680
+ commandText: routingContext.commandText,
681
+ targetFile: routingContext.targetFile ?? null,
682
+ intentMode: routingContext.intentMode ?? null,
683
+ taskType,
684
+ executionMode,
685
+ activeSkillIds: activeSkills.map((entry) => entry.id),
686
+ verificationRecommendation,
687
+ contextPreview: contextRecommendation?.preview ?? null,
688
+ executionScores: routingContext.executionScores ?? {},
689
+ lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? null,
690
+ historySignals,
691
+ config: routeSchemaStage === 'off'
692
+ ? runtimeConfig
693
+ : {
694
+ ...(runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}),
695
+ routing: {
696
+ ...(runtimeConfig?.routing && typeof runtimeConfig.routing === 'object'
697
+ ? runtimeConfig.routing
698
+ : {}),
699
+ routeSchema: { stage: routeSchemaStage },
700
+ },
701
+ },
702
+ });
703
+ const riskFloor = routeFields.riskFloor;
704
+ const fastPath = routeFields.fastPath;
705
+ const resolvedRouteFields = routeFields.resolved;
706
+ // C89 TASK-005 (SPEC §6/§11): typed delegation advice shadow — additive,
707
+ // gated on subagentOrchestrator.roleAdvice.stage ('off' → null, route
708
+ // byte-identical). Wraps the deterministic owner verdict; never spawns.
709
+ const delegationAdvice = deriveTypedDelegationAdvice({
710
+ activeSkills,
711
+ routingContext,
712
+ contextRecommendation,
713
+ verificationRecommendation,
714
+ autonomyLevel,
715
+ riskFloor,
716
+ config: runtimeConfig,
717
+ });
944
718
  const riskSegment = formatRiskFloorSegment(riskFloor);
945
719
  // FR-003 (M01.2' limits fragment): the rigor advisory — emitted whenever
946
720
  // rigor.stage is on, independent of fastPath/escalation. Stage off → no
@@ -948,36 +722,7 @@ export function buildRouteSummary({
948
722
  const limitsSegment = resolveRouteStage(runtimeConfig, 'rigor') !== 'off'
949
723
  ? formatLimitsSegment(deriveCeremonyLimits(executionContract))
950
724
  : null;
951
- // FR-004 (M01.3'): fastPath is emitted whenever its own stage is on — the field
952
- // is always set then (eligible or not) so telemetry/harness can read it; the
953
- // route-line segment prints only for eligible routes.
954
- const fastPathStage = resolveRouteStage(runtimeConfig, 'fastPath');
955
- const fastPath = fastPathStage !== 'off'
956
- ? {
957
- eligible: false,
958
- suppressed: [],
959
- reasons: [],
960
- ...deriveFastPath({
961
- executionMode,
962
- targetFile: routingContext.targetFile ?? null,
963
- riskFloor,
964
- verificationRecommendation,
965
- contextPreview: contextRecommendation?.preview ?? null,
966
- }),
967
- stage: fastPathStage,
968
- }
969
- : null;
970
725
  const fastPathSegment = formatFastPathSegment(fastPath);
971
- const resolvedRouteFields = routeSchemaStage !== 'off'
972
- ? buildResolvedRouteFields({
973
- routingContext,
974
- activeSkillIds: activeSkills.map((entry) => entry.id),
975
- executionMode,
976
- executionContract,
977
- completionState,
978
- riskFloor,
979
- })
980
- : null;
981
726
  const helperHint = compactHelperHint(
982
727
  compactHelperLane
983
728
  ? contextRecommendation?.command
@@ -1040,12 +785,26 @@ export function buildRouteSummary({
1040
785
  approachSelector,
1041
786
  executionContract,
1042
787
  completionState,
788
+ // TASK-004 resolver fields — the schema-off contract requires these keys be
789
+ // absent entirely, so they ride the same `resolved` gate (schema stage != off).
790
+ ...(resolvedRouteFields
791
+ ? {
792
+ rigor: routeFields.rigor,
793
+ resumable: routeFields.resumable,
794
+ escalationTriggers: routeFields.escalationTriggers,
795
+ decisionShadow: routeFields.decisionShadowFields,
796
+ }
797
+ : {}),
1043
798
  // M01.1 additive C01 groups — present only when routing.routeSchema.stage != "off".
1044
799
  ...(resolvedRouteFields ?? {}),
1045
800
  // FR-003 additive top-level field — present only when a risk stage is on.
1046
801
  ...(riskFloor ? { riskFloor } : {}),
1047
802
  // FR-004 additive top-level field — present only when fastPath stage is on.
1048
803
  ...(fastPath ? { fastPath } : {}),
804
+ // BL-013 additive top-level field — present only when the caller supplied
805
+ // a history struct (extraction ran, possibly degraded). Null/absent keeps
806
+ // the route byte-identical for callers without a transcript.
807
+ ...(routeFields.historySignals ? { historySignals: routeFields.historySignals } : {}),
1049
808
  continuationState,
1050
809
  autonomyLevel,
1051
810
  continuousExecution,
@@ -1069,6 +828,10 @@ export function buildRouteSummary({
1069
828
  // TASK-004 additive field — present only when a caller supplies a
1070
829
  // decision-plane result (stage != 'off').
1071
830
  ...(decisionPlane ? { decisionPlane } : {}),
831
+ // C89 TASK-005 additive field — typed delegation advice shadow; present
832
+ // only when subagentOrchestrator.roleAdvice.stage != 'off' (conditional
833
+ // spread keeps the stage-off route byte-identical).
834
+ ...(delegationAdvice ? { delegationAdvice } : {}),
1072
835
  line: summaryLine || 'task=unknown',
1073
836
  };
1074
837
  }
@@ -1195,34 +958,226 @@ function isInformationalPrompt({
1195
958
  const strongQuestion = raw.includes('?')
1196
959
  || /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1197
960
  || /(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);
961
+ // C85-019: consult-shaped questions overrule subject-matter signal scores.
962
+ // "should I fix X?", "X chưa nhỉ?", "có nên/có cần sửa không?" mention
963
+ // mutation vocabulary as SUBJECT — the ask is a decision/answer, not an
964
+ // order. Tight on purpose: modal-subject inversion restricted to
965
+ // should/shall (decision asks) — "how do I fix…?" and "bạn có thể sửa…
966
+ // giúp tôi không?" are work-shaped questions, not consults. A bare
967
+ // imperative wearing a question mark ("sửa file cho tôi?") keeps its lane.
968
+ const folded = raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd');
969
+ const politeAsk = /\b(?:giup|giuong|ho|dum)\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded)
970
+ || /\bcho\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded);
971
+ const consultShape = /(?:^|[.!;\n]\s*)(?:should|shall)\s+(?:i|we)\s+(?!(?:i|we)\s)/.test(folded)
972
+ || /\b(?:co|có)\s+(?:nen|can|phai)\s+/.test(folded)
973
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
974
+ || (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
975
+ || /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
1198
976
  if (scores) {
1199
- if (
977
+ if (!consultShape && (
1200
978
  scores.implementSignal
1201
979
  || scores.reviewSignal
1202
980
  || scores.debugSignal
1203
981
  || scores.impactSignal
1204
982
  || scores.smallFixSignal
1205
983
  || scores.directTransformSignal
1206
- ) {
984
+ )) {
1207
985
  return false;
1208
986
  }
1209
- if (scores.failureSignal && !strongQuestion) {
987
+ if (scores.failureSignal && !strongQuestion && !consultShape) {
1210
988
  return false;
1211
989
  }
1212
990
  }
991
+ // C85-019: consult-shape also overrides the vocabulary vetoes — the
992
+ // mutation/debug/lỗi words are the question's SUBJECT, not the ask.
1213
993
  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);
1214
- if (implementWords) return false;
994
+ if (implementWords && !consultShape) return false;
1215
995
  const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
1216
- if (investigationWords) return false;
996
+ if (investigationWords && !consultShape) return false;
1217
997
  // \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
1218
998
  // substring: an error report is informational only when phrased as a question.
1219
- if (raw.includes('lỗi') && !strongQuestion) return false;
999
+ if (raw.includes('lỗi') && !strongQuestion && !consultShape) return false;
1220
1000
  const reviewWords = /\b(review|audit|verify)\b/.test(raw);
1221
- if (reviewWords) return false;
1222
- return questionSignal;
1001
+ if (reviewWords && !consultShape) return false;
1002
+ // C85-019: consult shape is itself a question signal (Vietnamese tails like
1003
+ // "chưa"/"rồi" never match the Latin interrogative regex above).
1004
+ return questionSignal || consultShape;
1005
+ }
1006
+
1007
+
1008
+ // TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
1009
+ // scores. A report or advisory question carrying fix vocabulary as SUBJECT
1010
+ // MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
1011
+ // "chỉ tư vấn") still fires implementSignal/smallFixSignal/failureSignal, which
1012
+ // used to route the turn mutating so the completion gate demanded an Edit the
1013
+ // user explicitly declined. The override fires only when an assertion phrase is
1014
+ // present AND no unambiguous implement order survives outside the
1015
+ // negated/advisory/quoted/report clauses: "sửa file X nhưng giữ nguyên Y"
1016
+ // keeps its mutation lane because 'sửa file X' is outside the keep-clause,
1017
+ // while "hook cần được cấu hình để chỉ yêu cầu bằng chứng sửa file" is a
1018
+ // description of the hook's behavior, not an order. Folded (diacritic-free)
1019
+ // text is matched so both Vietnamese spellings work. Interrogative-deontic
1020
+ // forms ("should I…?", "có cần sửa…?") count as advisory requests, not orders;
1021
+ // a bare "không biết…chưa" uncertainty is NOT an assertion and stays on the
1022
+ // plain question path.
1023
+ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
1024
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
1025
+ .toLowerCase()
1026
+ .normalize('NFD')
1027
+ .replace(/[\u0300-\u036f]/g, '')
1028
+ .replace(/\u0111/g, 'd');
1029
+ const trimmed = folded.trim();
1030
+ if (!trimmed) return false;
1031
+ const advisoryAssertion = /\b(?:do\s+not|don't|dont)\s+(?:change|edit|fix|modify|alter|touch)\b/
1032
+ .test(trimmed)
1033
+ || /\bno\s+(?:changes?|edits?|updates?|modifications?)\b/.test(trimmed)
1034
+ || /\bkeep\s+[^\n.,;]{0,60}\b(?:unchanged|as\s+is|the\s+same|intact)\b/.test(trimmed)
1035
+ || /\badvisory\s+only\b/.test(trimmed)
1036
+ || /\bonly\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
1037
+ || /\bjust\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
1038
+ || /(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b/.test(trimmed)
1039
+ || /\bgiu\s+nguyen\b/.test(trimmed)
1040
+ || /\bmuon\s+giu\b/.test(trimmed)
1041
+ || /\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/
1042
+ .test(trimmed)
1043
+ || /\bdung\s+(?:sua|thay\s+doi|edit|chinh)\b/.test(trimmed)
1044
+ || /\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi)\b/.test(trimmed)
1045
+ || /\bco\s+(?:can|nen)\s+sua\b/.test(trimmed)
1046
+ // C89-008 (user-reported 9-stop loop): a waiting/no-change turn asserts the
1047
+ // answer is advisory — "đang chờ kết quả test QAS, không có gì cần sửa" —
1048
+ // but matches none of the shapes above and is not question-shaped, so it
1049
+ // escaped every informational gate and the stop hook demanded an Edit the
1050
+ // honest turn could never produce. 'không có gì' needs its own trigger —
1051
+ // the generic 'không (cần) sửa' pattern cannot skip the 'có gì' gap.
1052
+ || /\bkhong\s+co\s+gi\s+(?:can|phai|de|dang|con)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
1053
+ || /\bnothing\s+(?:to\s+(?:fix|edit|change|update|modify|do)|needs?\s+(?:fixing|editing|changing|updating))\b/.test(trimmed)
1054
+ || /\bdang\s+(?:cho|doi)\b/.test(trimmed)
1055
+ || /\b(?:waiting|awaiting)\s+(?:for|on)\b/.test(trimmed)
1056
+ || /\bchua\s+(?:can|nen|phai)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
1057
+ || /\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);
1058
+ if (!advisoryAssertion) return false;
1059
+ // Strip quoted spans and every clause class that can carry mutation
1060
+ // vocabulary without being an order: the advisory/negated assertions
1061
+ // themselves, conditional or purpose clauses (nếu/để/cho/if), reported
1062
+ // demands of another agent (yêu cầu/ép/bắt/requires/forced), passive
1063
+ // necessity descriptions (cần được cấu hình/must be configured), and the
1064
+ // ledger's own evidence-class names. Whatever mutation verb remains after
1065
+ // the strip is an actual implement order and vetoes the advisory route.
1066
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
1067
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
1068
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
1069
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
1070
+ 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*$)';
1071
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
1072
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
1073
+ // to a mutation verb, or at punctuation/EOS — "đang chờ kết quả nhưng sửa
1074
+ // file này" keeps 'sửa file này' as a surviving order.
1075
+ 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*$)';
1076
+ const residue = trimmed
1077
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1078
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
1079
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
1080
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
1081
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
1082
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
1083
+ // C89-008: a waiting clause narrates what the TURN waits on, not what the
1084
+ // request orders — verbs inside it ("chờ sửa xong", "waiting for the fix")
1085
+ // belong to the wait, not to the implement order.
1086
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
1087
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
1088
+ .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'), ' ')
1089
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
1090
+ // 'chưa cần/nên/phải sửa' defers the change — same class as 'không cần
1091
+ // sửa' but 'chưa' is not covered by the 'không' strip above. WAIT_TAIL
1092
+ // bounds it so '…nhưng sửa phần này' keeps the surviving order.
1093
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
1094
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
1095
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
1096
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
1097
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
1098
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
1099
+ .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'), ' ')
1100
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
1101
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
1102
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
1103
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
1104
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
1105
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ');
1106
+ 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);
1107
+ return !mutationOrder;
1108
+ }
1109
+ // C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
1110
+ // advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
1111
+ // reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must be),
1112
+ // evidence-class names — decides whether ANY surviving verb is an imperative.
1113
+ // Used by hasAdvisoryOnlyAssertion (assertion + no order → advisory) and by
1114
+ // the question-shape gate below (question + no order → consult, even when the
1115
+ // subject vocabulary fires debug/failure/implement signal scores).
1116
+ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
1117
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
1118
+ .toLowerCase()
1119
+ .normalize('NFD')
1120
+ .replace(/[\u0300-\u036f]/g, '')
1121
+ .replace(/\u0111/g, 'd');
1122
+ const trimmed = folded.trim();
1123
+ if (!trimmed) return false;
1124
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
1125
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
1126
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
1127
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
1128
+ 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*$)';
1129
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
1130
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything up
1131
+ // to a mutation verb, or at punctuation/EOS.
1132
+ 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*$)';
1133
+ const residue = trimmed
1134
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
1135
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
1136
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
1137
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
1138
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
1139
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
1140
+ // C89-008: a waiting clause narrates what the TURN waits on, not what the
1141
+ // request orders — verbs inside it belong to the wait.
1142
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
1143
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
1144
+ .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'), ' ')
1145
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
1146
+ // 'chưa cần/nên/phải sửa' defers the change — WAIT_TAIL bounds it so a
1147
+ // '…nhưng sửa phần này' order survives.
1148
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
1149
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
1150
+ // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
1151
+ // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
1152
+ // inside it is never the order.
1153
+ .replace(new RegExp(`\\bkhi\\s+(?:toi|ta|ban|no|anh|chi|he|she|we)\\s+[^.,;\\n?]*?(?=\\s+(?:va|and)\\b|\\s*$)`, 'g'), ' ')
1154
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
1155
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
1156
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
1157
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
1158
+ .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'), ' ')
1159
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
1160
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
1161
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
1162
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
1163
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
1164
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ')
1165
+ // C85-019b: question-tagged mutation verbs are asks, not orders. English:
1166
+ // "<modal> I/we <verb>" (should I fix). Vietnamese: S-V-order status
1167
+ // questions — "<verb> … (chưa|được không|rồi|nhỉ)?" means "has it been
1168
+ // done", never "do it". EXCLUDED: "… giúp/cho/hộ/dùm tôi không?" — the
1169
+ // 'không' there is a polite request marker, not a status tag, and the
1170
+ // mutation verb is a real order. These strips run LAST so a real
1171
+ // imperative mid-sentence ("sửa file X cho tôi") still survives.
1172
+ .replace(/\b(?<!\b(?:which|what|how)\s[^.,;?]{0,30})(?:should|shall)\s+(?:i|we)\s+[^\n?]*[?]?/g, ' ')
1173
+ .replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
1174
+ .replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
1175
+ .replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
1176
+ 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);
1223
1177
  }
1224
1178
 
1225
1179
 
1180
+
1226
1181
  // @decision-point preflight.execution-lane.v1 — the execution lane is a
1227
1182
  // registered preflight decision; this deterministic ladder is its
1228
1183
  // 'deterministic-route' fallback policy implementation.
@@ -1274,31 +1229,43 @@ function deriveExecutionMode({
1274
1229
  || /^(?: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);
1275
1230
  const questionShapedPrompt = /\?\s*$/.test(trimmedPromptText)
1276
1231
  || leadingInterrogative
1277
- || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText);
1232
+ || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
1233
+ // C85-019: Vietnamese status/consult tails without a '?' — "… chưa",
1234
+ // "… chưa nhỉ", "… không nhỉ", "… đúng không". A question about a defect
1235
+ // must not mint the bug-fix floor's write/repro debt.
1236
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|khong|nhi)\s*[.!?]*\s*$/.test(foldedPromptText);
1278
1237
  // Implement verbs (Vietnamese included — the English-only scores above cannot see
1279
1238
  // them) mark an action order even when phrased as a question ("ban sua giup toi…?" is
1280
1239
  // an edit order, not a consultation). A leading interrogative overrides them:
1281
1240
  // "Lam sao ma do duoc khi toi cai…?" asks HOW something is done, it does not order
1282
1241
  // the action performed.
1283
- 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);
1242
+ // C85-019: question shape + no surviving imperative = consult. The
1243
+ // pre-C85-019 all-zero-scores clause consulted only when NOTHING in the
1244
+ // prompt resembled work — impossible for a question about code. Replacing
1245
+ // it with hasMutationOrder keeps "sửa file cho tôi?" (an order wearing a
1246
+ // question mark) mutating while "lỗi này chưa nhỉ" / "should I fix the
1247
+ // hook?" consult. A targetFile still vetoes.
1248
+ // C85-019b: leading interrogative alone does NOT consult — "How do I fix X?"
1249
+ // still carries the verb (a how-to ask is handled below by the no-mutation
1250
+ // residue path when the verb is genuinely absent). Consult requires either
1251
+ // a leading interrogative or a question tail AND no surviving imperative.
1284
1252
  const consultationOnlySignal = questionShapedPrompt
1285
- && (leadingInterrogative || !consultationImplementWords)
1286
- && scores.editCertainty === 0
1287
- && !scores.implementSignal
1288
- && !scores.reviewSignal
1289
- && !scores.debugSignal
1290
- && !scores.failureSignal
1291
- && !scores.impactSignal
1292
- && !scores.buildSignal
1293
- && !scores.directTransformSignal
1294
- && !scores.smallFixSignal
1295
- && !scores.sharedRisk
1253
+ && !hasMutationOrder({ promptText, commandText })
1296
1254
  && !targetFile;
1297
1255
 
1298
1256
  if (isInformationalPrompt({ promptText, commandText, scores })) {
1299
1257
  return 'informational';
1300
1258
  }
1301
1259
 
1260
+ // TASK-C85-019: an explicit no-change/advisory assertion beats every signal
1261
+ // score — the prompt mentions mutation vocabulary as subject matter, not as
1262
+ // an order. Checked before the review/delivery/consultation gates so an
1263
+ // advisory turn never mints write or verification debt; a surviving
1264
+ // imperative outside the negated clause still routes mutating.
1265
+ if (hasAdvisoryOnlyAssertion({ promptText, commandText })) {
1266
+ return 'informational';
1267
+ }
1268
+
1302
1269
  if (
1303
1270
  releaseVerificationContinuation
1304
1271
  || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
@@ -1492,38 +1459,6 @@ function buildApproachSelectorResult({
1492
1459
  };
1493
1460
  }
1494
1461
 
1495
- function buildCompletionState({ executionMode = null, verificationRecommendation = null } = {}) {
1496
- if (!executionMode) {
1497
- return null;
1498
- }
1499
-
1500
- const contract = buildExecutionContract(executionMode);
1501
- const missingEvidence = [...(contract?.completionEvidence ?? [])];
1502
- const requiresVerification = missingEvidence.includes('verification-evidence');
1503
- if (
1504
- requiresVerification
1505
- && verificationRecommendation
1506
- && !(verificationRecommendation.commands?.length || verificationRecommendation.fallbackCommands?.length)
1507
- ) {
1508
- missingEvidence.push('verification-plan');
1509
- }
1510
-
1511
- let reason = 'completion evidence is still required';
1512
- if (['tiny-fix', 'local-fix', 'local-build', 'shared-edit'].includes(executionMode)) {
1513
- reason = 'implement request has not produced an edit yet';
1514
- } else if (executionMode === 'review-release') {
1515
- reason = 'review/release evidence is still required before final claim';
1516
- } else if (executionMode === 'map-impact') {
1517
- reason = 'impact evidence is still required before safe completion claim';
1518
- }
1519
-
1520
- return {
1521
- claimAllowed: false,
1522
- missingEvidence,
1523
- reason,
1524
- };
1525
- }
1526
-
1527
1462
  function buildContinuationState({
1528
1463
  nextActionType = null,
1529
1464
  completionState = null,
@@ -2105,6 +2040,254 @@ function deriveDelegationRecommendation({
2105
2040
  return null;
2106
2041
  }
2107
2042
 
2043
+ // --- C89 TASK-005 (SPEC §6 FR-04, §11): typed delegation advice (shadow) ------
2044
+ // Wraps the single route owner (deriveDelegationRecommendation above) — never
2045
+ // a second router, never a spawn path. Gated on
2046
+ // `subagentOrchestrator.roleAdvice.stage` ('off'/malformed → null, zero cost);
2047
+ // any other stage emits the typed advisory struct. The optional unic-decision
2048
+ // consult (applyRoleAdviceConsult) only ever answers an owner-bounded enum
2049
+ // question; deterministic fields stay authoritative and every failure records
2050
+ // a typed outcome on advice.model — never silent.
2051
+ export const DELEGATION_ADVICE_POLICY_VERSION = 'role-advice.v1';
2052
+ export const DELEGATION_ADVICE_DECISION_KEY = 'route.delegation-role.v1';
2053
+ // SPEC §6 initial role vocabulary — maps onto existing frontmatter agents,
2054
+ // no new agent file (retriever/reviewer reserved for role-bearing lanes;
2055
+ // C89-006 consumes role+contextMode only as advisory unless host-verified).
2056
+ export const DELEGATION_ADVICE_ROLES = Object.freeze(['retriever', 'reasoner', 'reviewer']);
2057
+ export const DELEGATION_ADVICE_CONTEXT_MODES = Object.freeze(['isolated', 'curated']);
2058
+
2059
+ // Delegation hint → {role, contextMode, reasonCode}. The owner hints all carry
2060
+ // reasoning work; the noisy debug lane needs isolation from the noisy loop,
2061
+ // the plan/feature lanes need the curated indexed bundle.
2062
+ const DELEGATION_HINT_ROLES = Object.freeze({
2063
+ 'ukit-small-task-maintainer': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'small-task-maintenance' },
2064
+ 'subagent-driven-development': { role: 'reasoner', contextMode: 'curated', reasonCode: 'planned-batch-execution' },
2065
+ 'bug-debugger': { role: 'reasoner', contextMode: 'isolated', reasonCode: 'noisy-debug-loop' },
2066
+ 'feature-implementer': { role: 'reasoner', contextMode: 'curated', reasonCode: 'broad-implementation-lane' },
2067
+ });
2068
+
2069
+ /**
2070
+ * Typed delegation advice (SPEC §6 FR-04). Reads the deterministic
2071
+ * `deriveDelegationRecommendation` verdict and annotates it as the typed
2072
+ * shadow receipt {delegate, role, contextMode, confidence, reasonCodes,
2073
+ * policyVersion, enforcement} — plus the audit fields hint/stage.
2074
+ *
2075
+ * `enforcement` is 'advisory' on every shipped host: this lane never spawns
2076
+ * and C89-006 owns the host-capability check that could one day mark a lane
2077
+ * 'enforced'. Trivial work resolves local by the deterministic fast path —
2078
+ * the same `null` the owner returns, typed.
2079
+ *
2080
+ * @returns {object|null} null when the roleAdvice stage is off/malformed.
2081
+ */
2082
+ export function deriveTypedDelegationAdvice({
2083
+ activeSkills = [],
2084
+ routingContext = {},
2085
+ contextRecommendation = null,
2086
+ verificationRecommendation = null,
2087
+ autonomyLevel = 'balanced',
2088
+ riskFloor = null,
2089
+ config = null,
2090
+ } = {}) {
2091
+ const stage = resolveSubagentOrchestratorStage(config, 'roleAdvice');
2092
+ if (stage === 'off') {
2093
+ return null;
2094
+ }
2095
+
2096
+ const recommendation = deriveDelegationRecommendation({
2097
+ activeSkills,
2098
+ routingContext,
2099
+ contextRecommendation,
2100
+ verificationRecommendation,
2101
+ autonomyLevel,
2102
+ });
2103
+
2104
+ const hint = recommendation?.hint ?? null;
2105
+ const reasonCodes = [];
2106
+ if (routingContext.taskType === 'trivial') {
2107
+ reasonCodes.push('trivial-fast-path');
2108
+ } else if (hint && DELEGATION_HINT_ROLES[hint]) {
2109
+ reasonCodes.push(DELEGATION_HINT_ROLES[hint].reasonCode);
2110
+ } else if (recommendation) {
2111
+ reasonCodes.push('owner-recommendation');
2112
+ } else {
2113
+ reasonCodes.push('no-delegation-signal');
2114
+ }
2115
+ if (riskFloor?.floor === 'high-risk') {
2116
+ reasonCodes.push('risk-floor-high-risk');
2117
+ }
2118
+
2119
+ const delegate = hint !== null;
2120
+ let role = null;
2121
+ let contextMode = null;
2122
+ if (delegate) {
2123
+ if (routingContext.executionMode === 'review-release') {
2124
+ // SPEC §6: reviewer lanes default isolated — the reviewer receives the
2125
+ // task requirements/diff/tests/evidence, not implementer narrative.
2126
+ role = 'reviewer';
2127
+ contextMode = 'isolated';
2128
+ reasonCodes.push('review-release-lane');
2129
+ } else {
2130
+ const mapped = DELEGATION_HINT_ROLES[hint] ?? { role: 'reasoner', contextMode: 'curated' };
2131
+ role = mapped.role;
2132
+ contextMode = mapped.contextMode;
2133
+ }
2134
+ }
2135
+
2136
+ const confidence = routingContext.taskType === 'trivial'
2137
+ ? 0.95
2138
+ : delegate ? 0.9 : 0.6;
2139
+
2140
+ return {
2141
+ delegate,
2142
+ role,
2143
+ contextMode,
2144
+ confidence,
2145
+ reasonCodes,
2146
+ policyVersion: DELEGATION_ADVICE_POLICY_VERSION,
2147
+ enforcement: 'advisory',
2148
+ stage,
2149
+ hint,
2150
+ };
2151
+ }
2152
+
2153
+ function delegationRiskFloorSignals(routeSummary) {
2154
+ const codes = routeSummary?.riskFloor?.codes;
2155
+ return Array.isArray(codes) ? codes : [];
2156
+ }
2157
+
2158
+ /**
2159
+ * Optional bounded consult + recorded override for the typed advice (SPEC §6).
2160
+ * The unic-decision question is one owner-bounded choice over the role
2161
+ * shortlist — enum-only, redacted, never tools/spawn. Any auth rejection,
2162
+ * timeout, invalid or off-shortlist answer lands on `advice.model` as a typed
2163
+ * outcome; the deterministic fields are never rewritten and no receipt write
2164
+ * is silent (appendReceipt seam fires whenever a consult ran).
2165
+ *
2166
+ * The main-agent override is recorded verbatim (role + reason) when the role
2167
+ * is in the enum — stored for the later accepting lane; it never mutates the
2168
+ * deterministic advice. The hard risk floor is deterministic state on the
2169
+ * advice itself, so conflicting model noise can never flip a local route.
2170
+ *
2171
+ * Returns true when the route was annotated (a consult ran or an override was
2172
+ * recorded); false when no advice exists or the consult never ran.
2173
+ */
2174
+ export async function applyRoleAdviceConsult({
2175
+ routeSummary = null,
2176
+ config = null,
2177
+ ask = null,
2178
+ appendReceipt = null,
2179
+ override = null,
2180
+ now = () => Date.now(),
2181
+ } = {}) {
2182
+ const advice = routeSummary?.delegationAdvice;
2183
+ if (!advice || typeof advice !== 'object') {
2184
+ return false;
2185
+ }
2186
+
2187
+ if (override && typeof override === 'object'
2188
+ && DELEGATION_ADVICE_ROLES.includes(override.role)
2189
+ && typeof override.reason === 'string' && override.reason.trim()) {
2190
+ advice.override = { role: override.role, reason: override.reason.trim() };
2191
+ }
2192
+
2193
+ const emit = (receipt) => {
2194
+ if (typeof appendReceipt !== 'function') return;
2195
+ try {
2196
+ const pending = appendReceipt(receipt);
2197
+ if (pending?.catch) pending.catch(() => {});
2198
+ } catch {
2199
+ // Receipt loss is advisory — the typed advice stands regardless.
2200
+ }
2201
+ };
2202
+
2203
+ // Local routes never consult — there is no role to suggest and the
2204
+ // deterministic fast path needs no shadow annotation.
2205
+ if (advice.delegate !== true || typeof advice.role !== 'string') {
2206
+ return true;
2207
+ }
2208
+
2209
+ // An explicit ask seam is the consult consent; the ambient path only builds
2210
+ // a client when the decision plane itself is enabled, so a role-advice-only
2211
+ // config never triggers endpoint work.
2212
+ const explicitAsk = typeof ask === 'function';
2213
+ if (!explicitAsk && resolveDecisionPlaneStage(config) === 'off') {
2214
+ return true;
2215
+ }
2216
+
2217
+ // Owner-bounded shortlist: the model may only answer inside the
2218
+ // deterministic candidate set — a singleton when the owner has one role.
2219
+ const candidates = [advice.role];
2220
+ const batch = {
2221
+ batchId: `role-advice-${now().toString(36)}`,
2222
+ boundary: 'role-advice',
2223
+ deadlineMs: Math.max(
2224
+ 500,
2225
+ Number.isFinite(config?.decisionPlane?.timeoutMs) ? config.decisionPlane.timeoutMs : 2000,
2226
+ ),
2227
+ statePacket: {
2228
+ stateVersion: 1,
2229
+ taskClass: routeSummary?.routingContext?.taskType ?? 'unknown',
2230
+ execution: routeSummary?.executionMode ? { mode: routeSummary.executionMode } : {},
2231
+ riskSignals: delegationRiskFloorSignals(routeSummary),
2232
+ },
2233
+ questions: [
2234
+ {
2235
+ decisionKey: DELEGATION_ADVICE_DECISION_KEY,
2236
+ kind: 'choice',
2237
+ instruction: 'Pick the delegation role from the owner-bounded shortlist.',
2238
+ candidates,
2239
+ },
2240
+ ],
2241
+ };
2242
+
2243
+ let result = null;
2244
+ try {
2245
+ result = await ask({ batch });
2246
+ } catch {
2247
+ result = null;
2248
+ }
2249
+ if (!result) {
2250
+ result = { status: 'unavailable', fallbackCode: 'ask-failed', answers: [] };
2251
+ }
2252
+
2253
+ // Fold the typed outcome: only a valid in-shortlist answer becomes a
2254
+ // suggestion; everything else keeps suggestedRole null with a fallback code.
2255
+ const answers = Array.isArray(result.answers) ? result.answers : [];
2256
+ let suggestedRole = null;
2257
+ for (const answer of answers) {
2258
+ if (answer?.decisionKey !== DELEGATION_ADVICE_DECISION_KEY) continue;
2259
+ if (answer?.validationStatus !== 'valid') continue;
2260
+ const value = typeof answer?.value === 'string' ? answer.value.trim() : null;
2261
+ if (value && candidates.includes(value)) {
2262
+ suggestedRole = value;
2263
+ }
2264
+ }
2265
+ const outcomeClass = typeof result.status === 'string' ? result.status : 'unavailable';
2266
+ advice.model = {
2267
+ status: outcomeClass,
2268
+ suggestedRole,
2269
+ agreement: suggestedRole === null
2270
+ ? 'unknown'
2271
+ : suggestedRole === advice.role ? 'agree' : 'disagree',
2272
+ fallbackCode: result.fallbackCode
2273
+ ?? (suggestedRole === null && outcomeClass === 'accepted' ? 'off-shortlist' : null),
2274
+ };
2275
+
2276
+ emit({
2277
+ kind: outcomeClass === 'unavailable' ? 'fallback' : 'shadow',
2278
+ boundary: 'role-advice',
2279
+ stage: advice.stage,
2280
+ outcomeClass,
2281
+ checkpoint: `role-advice batch=${batch.batchId}`,
2282
+ latencyClass: result.latencyClass ?? 'unknown',
2283
+ decisionKeys: [DELEGATION_ADVICE_DECISION_KEY],
2284
+ agreement: advice.model.agreement,
2285
+ fallbackCode: advice.model.fallbackCode,
2286
+ now: now(),
2287
+ });
2288
+ return true;
2289
+ }
2290
+
2108
2291
  function deriveContextIntent({ promptText, commandText, targetFile, selectedIds }) {
2109
2292
  if (promptText.trim()) {
2110
2293
  return promptText.trim();