@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.
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
@@ -174,7 +174,9 @@ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FI
174
174
  const fs = require('fs');
175
175
  const path = require('path');
176
176
  const crypto = require('crypto');
177
+ const os = require('os');
177
178
  const { pathToFileURL } = require('url');
179
+ const { spawn } = require('child_process');
178
180
 
179
181
  // Wall-clock watchdog. This hook runs on the UserPromptSubmit/PreToolUse hot path but has
180
182
  // no host-side process-group kill: when indexed-context resolution hangs (a wedged import,
@@ -342,6 +344,282 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
342
344
  };
343
345
  }
344
346
 
347
+ // TASK-004 (BL-006): shared route resolver — the same module route-task.mjs
348
+ // uses, so hook state and helper state compute identical rigor/fastPath/
349
+ // riskFloor/resumable/decision-shadow fields. Loaded lazily via pathToFileURL
350
+ // (cache-utils pattern); any load/derive failure degrades to null so routing
351
+ // never breaks over advisory fields. Path is resolved lazily because
352
+ // `projectRoot` is declared below (TDZ).
353
+ let routeResolverModule = null;
354
+ let routeResolverLoadAttempted = false;
355
+ async function loadRouteResolver() {
356
+ if (!routeResolverLoadAttempted) {
357
+ routeResolverLoadAttempted = true;
358
+ try {
359
+ const routeResolverPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'route-resolver.mjs');
360
+ if (await pathExists(routeResolverPath)) {
361
+ routeResolverModule = await import(pathToFileURL(routeResolverPath).href);
362
+ }
363
+ } catch {
364
+ routeResolverModule = null;
365
+ }
366
+ }
367
+ return routeResolverModule;
368
+ }
369
+
370
+ // TASK-007 (BL-009): playbook registry — same lazy pathToFileURL pattern as
371
+ // the shared resolver. Loaded once per hook run; failure degrades to null so
372
+ // routing never blocks on playbook resolution.
373
+ let playbookRegistryModule = null;
374
+ let playbookRegistryLoadAttempted = false;
375
+ async function loadPlaybookRegistry() {
376
+ if (!playbookRegistryLoadAttempted) {
377
+ playbookRegistryLoadAttempted = true;
378
+ try {
379
+ const registryPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'playbook-registry.mjs');
380
+ if (await pathExists(registryPath)) {
381
+ playbookRegistryModule = await import(pathToFileURL(registryPath).href);
382
+ }
383
+ } catch {
384
+ playbookRegistryModule = null;
385
+ }
386
+ }
387
+ return playbookRegistryModule;
388
+ }
389
+
390
+ // BL-019 (SPEC FR-001): measured tier module — same lazy pathToFileURL
391
+ // pattern. A missing module/failure degrades to null so the static map
392
+ // stays authoritative and routing never blocks on the artifact read.
393
+ let tierSelectionModule = null;
394
+ let tierSelectionLoadAttempted = false;
395
+ async function loadTierSelection() {
396
+ if (!tierSelectionLoadAttempted) {
397
+ tierSelectionLoadAttempted = true;
398
+ try {
399
+ const modulePath = path.join(projectRoot, '.claude', 'ukit', 'index', 'tier-selection.mjs');
400
+ if (await pathExists(modulePath)) {
401
+ tierSelectionModule = await import(pathToFileURL(modulePath).href);
402
+ }
403
+ } catch {
404
+ tierSelectionModule = null;
405
+ }
406
+ }
407
+ return tierSelectionModule;
408
+ }
409
+
410
+ // BL-013: session-history extractor — same lazy pathToFileURL pattern as the
411
+ // shared resolver. Async extraction (fsp tail read ≤16KiB) so a stalled mount
412
+ // never parks the event loop under the armed deadline (BUG-C21-04 posture);
413
+ // a missing module or any failure degrades to null and routing continues.
414
+ let historyExtractorModule = null;
415
+ let historyExtractorLoadAttempted = false;
416
+ async function loadHistoryExtractor() {
417
+ if (!historyExtractorLoadAttempted) {
418
+ historyExtractorLoadAttempted = true;
419
+ try {
420
+ const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'session-history-extractor.mjs');
421
+ if (await pathExists(extractorPath)) {
422
+ historyExtractorModule = await import(pathToFileURL(extractorPath).href);
423
+ }
424
+ } catch {
425
+ historyExtractorModule = null;
426
+ }
427
+ }
428
+ return historyExtractorModule;
429
+ }
430
+
431
+ // One bounded extraction per route, reused by provisional/cached/final
432
+ // emissions. routeFingerprint is only known post-route; markers whose only
433
+ // key is the fingerprint of a rerun-identical route match via the previous
434
+ // state's fingerprint when requestKey still matches.
435
+ async function extractRouteHistorySignals({ requestKey: routeRequestKey = null, routeFingerprint = null } = {}) {
436
+ try {
437
+ const extractor = await loadHistoryExtractor();
438
+ if (!extractor || typeof extractor.extractHistorySignalsAsync !== 'function') {
439
+ return null;
440
+ }
441
+ return await extractor.extractHistorySignalsAsync({
442
+ transcriptPath,
443
+ routeFingerprint: routeFingerprint
444
+ ?? (routeRequestKey && previous?.requestKey === routeRequestKey
445
+ ? previous?.fingerprint ?? null
446
+ : null),
447
+ requestKey: routeRequestKey,
448
+ });
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ // BL-017 (SPEC FR-005): hook-path decision-shadow emission. The decision
455
+ // plane must see every route, not just helper-path ones — a detached
456
+ // `route-task.mjs --decision-shadow` child replays the same batched shadow
457
+ // the helper runs inline and appends decisions.tsv receipts. Fire-and-
458
+ // forget (`detached` + `unref`, stdio ignored): route output is already
459
+ // persisted by the caller, the child inherits the 3s adapter cap via
460
+ // config, and every typed failure degrades to deterministic fallback rows.
461
+ // Only ever spawned when the resolved plane stage is not 'off'; a spawn
462
+ // error is advisory — the route stands.
463
+ function fireRouteDecisionShadow({ decisionShadowFields = null, targetFile = null } = {}) {
464
+ try {
465
+ if (!decisionShadowFields || decisionShadowFields.stage === 'off') {
466
+ return;
467
+ }
468
+ const routeTaskPath = process.env.HOOK_DIR
469
+ ? path.resolve(process.env.HOOK_DIR, '..', 'ukit', 'index', 'route-task.mjs')
470
+ : path.join(projectRoot, '.claude', 'ukit', 'index', 'route-task.mjs');
471
+ const args = [
472
+ routeTaskPath,
473
+ '--decision-shadow',
474
+ '--root', projectRoot,
475
+ ];
476
+ if (targetFile) {
477
+ args.push('--target-file', String(targetFile));
478
+ }
479
+ const child = spawn(process.execPath, args, {
480
+ detached: true,
481
+ stdio: 'ignore',
482
+ env: process.env,
483
+ });
484
+ child.unref();
485
+ } catch {
486
+ // Fire-and-forget: a spawn failure must never block or break routing.
487
+ }
488
+ }
489
+
490
+
491
+ async function buildSharedRouteFields({
492
+ historySignals = null,
493
+ routingContext = {},
494
+ activeSkills = [],
495
+ contextRecommendation = null,
496
+ verificationRecommendation = null,
497
+ } = {}) {
498
+ try {
499
+ const resolver = await loadRouteResolver();
500
+ if (!resolver || typeof resolver.deriveRouteFields !== 'function') {
501
+ // BL-013: a missing resolver must not drop the extracted history
502
+ // struct — return the bounded bundle alone so the merge below still
503
+ // lands counters/enums on route state.
504
+ return historySignals ? { historySignals } : null;
505
+ }
506
+ const fields = resolver.deriveRouteFields({
507
+ promptText: routingContext.promptText || '',
508
+ commandText: routingContext.commandText || '',
509
+ targetFile: routingContext.targetFile || null,
510
+ intentMode: routingContext.intentMode || null,
511
+ taskType: routingContext.taskType || null,
512
+ executionMode: routingContext.executionMode || null,
513
+ riskSignals: Array.isArray(routingContext.riskSignals)
514
+ ? routingContext.riskSignals
515
+ : [],
516
+ activeSkillIds: activeSkills
517
+ .map((item) => (item && typeof item === 'object' ? item.id : item))
518
+ .filter((id) => typeof id === 'string' && id),
519
+ verificationRecommendation,
520
+ contextPreview: contextRecommendation?.preview ?? contextRecommendation ?? null,
521
+ executionScores: routingContext.executionScores ?? {},
522
+ historySignals,
523
+ lastExplicitUserPromptText: routingContext.lastExplicitUserPromptText ?? null,
524
+ config: runtimeConfig,
525
+ });
526
+ // TASK-007 (BL-009): playbook resolution merges into the same field
527
+ // bundle the hook spreads into routeSummary — hook and helper emit the
528
+ // identical playbookId/workGroup. Missing registry degrades to
529
+ // playbookId: null (the state still carries the field for parity).
530
+ const playbook = { playbookId: null, workGroup: null, playbookReason: null, playbookLoad: null, path: null, fastPathKind: null };
531
+ try {
532
+ const registry = await loadPlaybookRegistry();
533
+ if (registry && typeof registry.resolvePlaybookRoute === 'function') {
534
+ const resolution = await registry.resolvePlaybookRoute({
535
+ promptText: routingContext.promptText || '',
536
+ commandText: routingContext.commandText || '',
537
+ targetFile: routingContext.targetFile || null,
538
+ executionMode: routingContext.executionMode || null,
539
+ intentMode: routingContext.intentMode || null,
540
+ escalationTriggers: fields?.escalationTriggers ?? [],
541
+ projectRoot,
542
+ homeDir: os.homedir(),
543
+ });
544
+ playbook.playbookId = resolution.playbookId ?? null;
545
+ playbook.workGroup = resolution.workGroup ?? null;
546
+ playbook.playbookReason = resolution.reason ?? null;
547
+ playbook.playbookLoad = resolution.playbookLoad ?? null;
548
+ // BL-010: direct/handoff path + the ARCH fast-path enum —
549
+ // `fastPathKind`, never `fastPath`: the spread above must leave the
550
+ // resolver's FR-004 fastPath eligibility object intact (M03).
551
+ playbook.path = resolution.path ?? null;
552
+ playbook.fastPathKind = resolution.fastPath ?? null;
553
+ }
554
+ } catch {
555
+ playbook.playbookReason = 'registry-error';
556
+ }
557
+ return { ...fields, ...playbook };
558
+ } catch {
559
+ return null; // resolver failure must never break routing
560
+ }
561
+ }
562
+ // TASK-004 (BL-006): merge the shared-resolver output into a routeSummary
563
+ // with the same key-presence semantics the helper path emits:
564
+ // - resolved groups + rigor/resumable/escalationTriggers/decisionShadow ride
565
+ // the schema-stage gate (absent entirely when routeSchema is off);
566
+ // - fastPath/riskFloor only land when non-null (their own stage gates).
567
+ function mergeResolverFields(routeSummary, fields, { onlyMissing = false } = {}) {
568
+ if (!routeSummary || !fields) {
569
+ return;
570
+ }
571
+ const merged = fields.resolved && typeof fields.resolved === 'object'
572
+ ? {
573
+ ...fields.resolved,
574
+ rigor: fields.rigor ?? null,
575
+ resumable: fields.resumable ?? null,
576
+ escalationTriggers: fields.escalationTriggers ?? [],
577
+ decisionShadow: fields.decisionShadowFields ?? null,
578
+ }
579
+ : null;
580
+ if (merged && (!onlyMissing || routeSummary.routeVersion === undefined)) {
581
+ Object.assign(routeSummary, merged);
582
+ }
583
+ for (const key of ['fastPath', 'riskFloor']) {
584
+ if (fields[key] != null
585
+ && (!onlyMissing || routeSummary[key] === undefined || routeSummary[key] === null)) {
586
+ routeSummary[key] = fields[key];
587
+ }
588
+ }
589
+ // BL-013: bounded history counters land whenever extraction produced a
590
+ // struct — counters/enums only, additive-nullable, never transcript text.
591
+ if (fields.historySignals != null
592
+ && (!onlyMissing || routeSummary.historySignals === undefined || routeSummary.historySignals === null)) {
593
+ routeSummary.historySignals = fields.historySignals;
594
+ }
595
+ // TASK-007 (BL-009): playbook fields always land (null included) so hook
596
+ // state and helper state carry the identical playbookId/workGroup shape.
597
+ if (fields.playbookId !== undefined || fields.workGroup !== undefined) {
598
+ if (!onlyMissing || routeSummary.playbookId === undefined) {
599
+ routeSummary.playbookId = fields.playbookId ?? null;
600
+ routeSummary.workGroup = fields.workGroup ?? null;
601
+ // BL-010: route path + ARCH fast-path enum (`fastPathKind` — the
602
+ // `fastPath` key already carries the FR-004 eligibility object).
603
+ // Keys land only when a routing-table row claimed the prompt
604
+ // (workGroup non-null) — identical key-presence to the helper's
605
+ // deriveTaskRoute merge so schema-off/no-match routes stay clean.
606
+ if (fields.workGroup != null) {
607
+ routeSummary.path = fields.path ?? null;
608
+ routeSummary.fastPathKind = fields.fastPathKind ?? null;
609
+ } else {
610
+ delete routeSummary.path;
611
+ delete routeSummary.fastPathKind;
612
+ }
613
+ }
614
+ if (routeSummary.playbookReason === undefined) {
615
+ routeSummary.playbookReason = fields.playbookReason ?? null;
616
+ }
617
+ if (routeSummary.playbookLoad === undefined) {
618
+ routeSummary.playbookLoad = fields.playbookLoad ?? null;
619
+ }
620
+ }
621
+ }
622
+
345
623
  async function existsSkill(projectRoot, relativePath) {
346
624
  return pathExists(path.join(projectRoot, relativePath));
347
625
  }
@@ -1661,31 +1939,222 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1661
1939
  const strongQuestion = raw.includes('?')
1662
1940
  || /^(what|how|when|which|where|who|is|are|does|do|did|can|could|would|should|will)\b/.test(raw)
1663
1941
  || /(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);
1942
+ // C85-019: consult-shaped questions overrule subject-matter signal scores.
1943
+ // "should I fix X?", "X chưa nhỉ?", "có nên/có cần sửa không?" mention
1944
+ // mutation vocabulary as SUBJECT — the ask is a decision/answer, not an
1945
+ // order. Tight on purpose: modal-subject inversion restricted to
1946
+ // should/shall (decision asks) — "how do I fix…?" and "bạn có thể sửa…
1947
+ // giúp tôi không?" are work-shaped questions, not consults.
1948
+ const folded = raw.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\u0111/g, 'd');
1949
+ const politeAsk = /\b(?:giup|giuong|ho|dum)\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded)
1950
+ || /\bcho\b[^\n?,;]{0,60}\b(?:khong|không)\s*[?!.]?\s*$/.test(folded);
1951
+ const consultShape = /(?:^|[.!;\n]\s*)(?:should|shall)\s+(?:i|we)\s+(?!(?:i|we)\s)/.test(folded)
1952
+ || /\b(?:co|có)\s+(?:nen|can|phai)\s+/.test(folded)
1953
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi)\s*[.!?]*\s*$/.test(folded)
1954
+ || (!politeAsk && /\b(?:khong|không)\s*[?.!]?\s*$/.test(folded))
1955
+ || /\bchi\s+(?:hoi|tu\s+van|giai\s+thich|xem)\b/.test(folded);
1664
1956
  if (scores) {
1665
- if (
1957
+ if (!consultShape && (
1666
1958
  scores.implementSignal
1667
1959
  || scores.reviewSignal
1668
1960
  || scores.debugSignal
1669
1961
  || scores.impactSignal
1670
1962
  || scores.smallFixSignal
1671
1963
  || scores.directTransformSignal
1672
- ) {
1964
+ )) {
1673
1965
  return false;
1674
1966
  }
1675
- if (scores.failureSignal && !strongQuestion) {
1967
+ if (scores.failureSignal && !strongQuestion && !consultShape) {
1676
1968
  return false;
1677
1969
  }
1678
1970
  }
1971
+ // C85-019: consult-shape also overrides the vocabulary vetoes — the
1972
+ // mutation/debug/lỗi words are the question's SUBJECT, not the ask.
1679
1973
  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);
1680
- if (implementWords) return false;
1974
+ if (implementWords && !consultShape) return false;
1681
1975
  const investigationWords = /\b(why|debug|triage|root cause|investigate|tại sao)\b/.test(raw);
1682
- if (investigationWords) return false;
1976
+ if (investigationWords && !consultShape) return false;
1683
1977
  // \b never matches around 'lỗi' (diacritics are not \w), so test it as a plain
1684
1978
  // substring: an error report is informational only when phrased as a question.
1685
- if (raw.includes('lỗi') && !strongQuestion) return false;
1979
+ if (raw.includes('lỗi') && !strongQuestion && !consultShape) return false;
1686
1980
  const reviewWords = /\b(review|audit|verify)\b/.test(raw);
1687
- if (reviewWords) return false;
1688
- return questionSignal;
1981
+ if (reviewWords && !consultShape) return false;
1982
+ // C85-019: consult shape is itself a question signal (Vietnamese tails like
1983
+ // "chưa"/"rồi" never match the Latin interrogative regex above).
1984
+ return questionSignal || consultShape;
1985
+ }
1986
+
1987
+ // TASK-C85-019: an explicit no-change/advisory assertion overrides the signal
1988
+ // scores. A report or advisory question carrying fix vocabulary as SUBJECT
1989
+ // MATTER ("không tạo thay đổi", "giữ nguyên mã", "do not change anything",
1990
+ // "chỉ tư vấn") still fires implementSignal/smallFixSignal/failureSignal,
1991
+ // which used to route the turn mutating so the completion gate demanded an
1992
+ // Edit the user explicitly declined. The override fires only when an
1993
+ // assertion phrase is present AND no unambiguous implement order survives
1994
+ // outside the negated/advisory/quoted/report clauses: "sửa file X nhưng giữ
1995
+ // nguyên Y" keeps its mutation lane because 'sửa file X' is outside the
1996
+ // keep-clause, while "hook cần được cấu hình để chỉ yêu cầu bằng chứng sửa
1997
+ // file" is a description of the hook's behavior, not an order. Folded
1998
+ // (diacritic-free) text is matched so both Vietnamese spellings work.
1999
+ // Interrogative-deontic forms ("should I…?", "có cần sửa…?") count as
2000
+ // advisory requests, not orders; a bare "không biết…chưa" uncertainty is NOT
2001
+ // an assertion and stays on the plain question path.
2002
+ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
2003
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
2004
+ .toLowerCase()
2005
+ .normalize('NFD')
2006
+ .replace(/[\u0300-\u036f]/g, '')
2007
+ .replace(/\u0111/g, 'd');
2008
+ const trimmed = folded.trim();
2009
+ if (!trimmed) return false;
2010
+ const advisoryAssertion = /\b(?:do\s+not|don't|dont)\s+(?:change|edit|fix|modify|alter|touch)\b/
2011
+ .test(trimmed)
2012
+ || /\bno\s+(?:changes?|edits?|updates?|modifications?)\b/.test(trimmed)
2013
+ || /\bkeep\s+[^\n.,;]{0,60}\b(?:unchanged|as\s+is|the\s+same|intact)\b/.test(trimmed)
2014
+ || /\badvisory\s+only\b/.test(trimmed)
2015
+ || /\bonly\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
2016
+ || /\bjust\s+(?:advis|ask|question|answer|explain|consult|report)/.test(trimmed)
2017
+ || /(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b/.test(trimmed)
2018
+ || /\bgiu\s+nguyen\b/.test(trimmed)
2019
+ || /\bmuon\s+giu\b/.test(trimmed)
2020
+ || /\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/
2021
+ .test(trimmed)
2022
+ || /\bdung\s+(?:sua|thay\s+doi|edit|chinh)\b/.test(trimmed)
2023
+ || /\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi)\b/.test(trimmed)
2024
+ || /\bco\s+(?:can|nen)\s+sua\b/.test(trimmed)
2025
+ // C89-008 (user-reported 9-stop loop): a waiting/no-change turn asserts
2026
+ // the answer is advisory — "đang chờ kết quả test QAS, không có gì cần
2027
+ // sửa" — but matches none of the shapes above and is not question-shaped,
2028
+ // so it escaped every informational gate and the stop hook demanded an
2029
+ // Edit the honest turn could never produce. 'không có gì' needs its own
2030
+ // trigger — the generic 'không (cần) sửa' pattern cannot skip the 'có
2031
+ // gì' gap.
2032
+ || /\bkhong\s+co\s+gi\s+(?:can|phai|de|dang|con)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
2033
+ || /\bnothing\s+(?:to\s+(?:fix|edit|change|update|modify|do)|needs?\s+(?:fixing|editing|changing|updating))\b/.test(trimmed)
2034
+ || /\bdang\s+(?:cho|doi)\b/.test(trimmed)
2035
+ || /\b(?:waiting|awaiting)\s+(?:for|on)\b/.test(trimmed)
2036
+ || /\bchua\s+(?:can|nen|phai)\s+(?:sua|thay\s+doi|lam|chinh|edit|fix)\b/.test(trimmed)
2037
+ || /\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);
2038
+ if (!advisoryAssertion) return false;
2039
+ // Strip quoted spans and every clause class that can carry mutation
2040
+ // vocabulary without being an order: the advisory/negated assertions
2041
+ // themselves, conditional or purpose clauses (nếu/để/cho/if), reported
2042
+ // demands of another agent (yêu cầu/ép/bắt/requires/forced), passive
2043
+ // necessity descriptions (cần được cấu hình/must be configured), and the
2044
+ // ledger's own evidence-class names. Whatever mutation verb remains after
2045
+ // the strip is an actual implement order and vetoes the advisory route.
2046
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
2047
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
2048
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
2049
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
2050
+ 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*$)';
2051
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before any coordinating
2052
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
2053
+ // up to a mutation verb, or at punctuation/EOS — "đang chờ kết quả nhưng
2054
+ // sửa file này" keeps 'sửa file này' as a surviving order.
2055
+ 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*$)';
2056
+ const residue = trimmed
2057
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2058
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2059
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2060
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2061
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2062
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
2063
+ // C89-008: a waiting clause narrates what the TURN waits on, not what
2064
+ // the request orders — verbs inside it ("chờ sửa xong", "waiting for
2065
+ // the fix") belong to the wait, not to the implement order.
2066
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
2067
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
2068
+ .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'), ' ')
2069
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
2070
+ // 'chưa cần/nên/phải sửa' defers the change — same class as 'không cần
2071
+ // sửa' but 'chưa' is not covered by the 'không' strip above. WAIT_TAIL
2072
+ // bounds it so '…nhưng sửa phần này' keeps the surviving order.
2073
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
2074
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
2075
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2076
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2077
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2078
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
2079
+ .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'), ' ')
2080
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2081
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
2082
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
2083
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
2084
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
2085
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ');
2086
+ 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);
2087
+ return !mutationOrder;
2088
+ }
2089
+
2090
+ // C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
2091
+ // advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
2092
+ // reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must
2093
+ // be), evidence-class names — decides whether ANY surviving verb is an
2094
+ // imperative. Used by hasAdvisoryOnlyAssertion and by the question-shape
2095
+ // gate below (question + no order → consult).
2096
+ function hasMutationOrder({ promptText = '', commandText = '' } = {}) {
2097
+ const folded = `${promptText ?? ''}\n${commandText ?? ''}`
2098
+ .toLowerCase()
2099
+ .normalize('NFD')
2100
+ .replace(/[\u0300-\u036f]/g, '')
2101
+ .replace(/\u0111/g, 'd');
2102
+ const trimmed = folded.trim();
2103
+ if (!trimmed) return false;
2104
+ // C85-019c: clause strips must stop BEFORE a conjunction-joined imperative
2105
+ // — "giữ nguyên header và sửa lỗi login" keeps 'và sửa lỗi' as a surviving
2106
+ // order; an unbounded [^.,;\n]* swallows it and the order escapes the gate.
2107
+ // TAIL = lazy strip ending before 'và'/'and' (folded va|and) or EOS.
2108
+ 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*$)';
2109
+ // C89-008: WAIT_TAIL ends a waiting-clause strip before a coordinating
2110
+ // conjunction ('và/and/nhưng/rồi/then/later/sau đó') followed by anything
2111
+ // up to a mutation verb, or at punctuation/EOS.
2112
+ 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*$)';
2113
+ const residue = trimmed
2114
+ .replace(/"[^"\n]*"|«[^»\n]*»/g, ' ')
2115
+ .replace(/\bwrite-evidence\b|\bverification-evidence\b|\bimpact-evidence\b|\broot-cause\b/g, ' ')
2116
+ .replace(new RegExp(`\\bgiu\\s+nguyen\\b${TAIL}`, 'g'), ' ')
2117
+ .replace(new RegExp(`\\bmuon\\s+giu\\b${TAIL}`, 'g'), ' ')
2118
+ .replace(new RegExp(`\\bkhong\\s+(?!chi\\b)${TAIL}`, 'g'), ' ')
2119
+ .replace(new RegExp(`\\bdung\\s+${TAIL}`, 'g'), ' ')
2120
+ // C89-008: a waiting clause narrates what the TURN waits on, not what
2121
+ // the request orders — verbs inside it belong to the wait.
2122
+ .replace(new RegExp(`\\bdang\\s+(?:cho|doi)\\b${WAIT_TAIL}`, 'g'), ' ')
2123
+ .replace(new RegExp(`\\b(?:waiting|awaiting)\\s+(?:for|on)\\b${WAIT_TAIL}`, 'g'), ' ')
2124
+ .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'), ' ')
2125
+ .replace(new RegExp(`\\bnothing\\s+(?:to|needs?)\\b${TAIL}`, 'g'), ' ')
2126
+ // 'chưa cần/nên/phải sửa' defers the change — WAIT_TAIL bounds it so a
2127
+ // '…nhưng sửa phần này' order survives.
2128
+ .replace(new RegExp(`\\bchua\\s+(?:can|nen|phai)\\s+${WAIT_TAIL}`, 'g'), ' ')
2129
+ .replace(new RegExp(`\\b(?:neu|if)\\s+${TAIL}`, 'g'), ' ')
2130
+ // C85-019b: a "khi <subject> <verb>" when-clause is temporal context —
2131
+ // "khi tôi cài hệ thống này" narrates WHEN something happens; the verb
2132
+ // inside it is never the order.
2133
+ .replace(new RegExp(`\\bkhi\\s+(?:toi|ta|ban|no|anh|chi|he|she|we)\\s+[^.,;\\n?]*?(?=\\s+(?:va|and)\\b|\\s*$)`, 'g'), ' ')
2134
+ .replace(new RegExp(`\\b(?:de|cho)\\s+${TAIL}`, 'g'), ' ')
2135
+ .replace(new RegExp(`\\b(?:yeu\\s+cau|doi\\s+hoi|ep\\s+phai|bat\\s+buoc|bat\\s+phai)\\b${TAIL}`, 'g'), ' ')
2136
+ .replace(new RegExp(`\\b(?:demand(?:ed|s)?|require(?:d|s)?|forcing|forced|forcibly|must|has\\s+to|have\\s+to)\\b${TAIL}`, 'g'), ' ')
2137
+ .replace(new RegExp(`\\b(?:do\\s+not|don't|dont|not|never|no)\\s+(?!only\\b)${TAIL}`, 'g'), ' ')
2138
+ .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'), ' ')
2139
+ .replace(/\bco\s+(?:can|nen)\s+sua\b[^.,;\n?]*/g, ' ')
2140
+ .replace(/(?:^|[.,;\n]\s*)(?:should|shall)\s+(?:i|we|you)\b[^.,;\n?]*/g, ' ')
2141
+ .replace(/\bchi\s+(?:tu\s+van|hoi|giai\s+thich|xem|la\s+cau\s+hoi|can|la)\b[^.,;\n]*/g, ' ')
2142
+ .replace(/\b(?:just|only|simply)\s+(?:advis\w*|asking?|a\s+question|answering?|explaining?|consulting|reporting)\b[^.,;\n]*/g, ' ')
2143
+ .replace(/\badvisory\s+only\b[^.,;\n]*/g, ' ')
2144
+ .replace(new RegExp(`\\bkeep\\s+[^\\n.,;]{0,60}\\b(?:unchanged|as\\s+is|the\\s+same|intact)\\b${TAIL}`, 'g'), ' ')
2145
+ // C85-019b: question-tagged mutation verbs are asks, not orders.
2146
+ // English: "<modal> I/we <verb>" (should I fix). Vietnamese: S-V-order
2147
+ // status questions — "<verb> … (chưa|được không|rồi|nhỉ)?" means "has
2148
+ // it been done", never "do it". EXCLUDED: "… giúp/cho/hộ/dùm tôi
2149
+ // không?" — the 'không' is a polite request marker, not a status tag;
2150
+ // also EXCLUDED are wh-led modals ("which files should I update") —
2151
+ // a selection ask is a plan, not a yes/no consult. Strips run LAST
2152
+ // so a real imperative mid-sentence ("sửa file X cho tôi") survives.
2153
+ .replace(/\b(?<!\b(?:which|what|how)\s[^.,;?]{0,30})(?:should|shall)\s+(?:i|we)\s+[^\n?]*[?]?/g, ' ')
2154
+ .replace(/[^\n.,;?]{0,80}\b(?:chua|duoc khong|dung khong|khong nhi|nhi|roi|a)\s*[?]?\s*$/g, ' ')
2155
+ .replace(/^(?!.*\b(?:giup|giuong|cho|ho|dum)\b)[^\n.,;?]{0,80}\b(?:khong|không)\s*[?]?\s*$/g, ' ')
2156
+ .replace(/\b(?:co|có)\s+(?:nen|can|phai)\s+[^\n?]*[?]?/g, ' ');
2157
+ 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);
1689
2158
  }
1690
2159
 
1691
2160
  function deriveExecutionMode({
@@ -1739,6 +2208,15 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1739
2208
  return 'informational';
1740
2209
  }
1741
2210
 
2211
+ // TASK-C85-019: an explicit no-change/advisory assertion beats every signal
2212
+ // score — the prompt mentions mutation vocabulary as subject matter, not as
2213
+ // an order. Checked before the review/delivery/consultation gates so an
2214
+ // advisory turn never mints write or verification debt; a surviving
2215
+ // imperative outside the negated clause still routes mutating.
2216
+ if (hasAdvisoryOnlyAssertion({ promptText, commandText })) {
2217
+ return 'informational';
2218
+ }
2219
+
1742
2220
  if (
1743
2221
  releaseVerificationContinuation
1744
2222
  || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
@@ -1756,25 +2234,25 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
1756
2234
  || /^(?: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);
1757
2235
  const questionShapedPrompt = /\?\s*$/.test(trimmedPromptText)
1758
2236
  || leadingInterrogative
1759
- || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText);
2237
+ || /\b(?:la gi|duoc khong)\s*$/.test(foldedPromptText)
2238
+ // C85-019: Vietnamese status/consult tails without a '?' — "… chưa",
2239
+ // "… chưa nhỉ", "… không nhỉ", "… đúng không". A question about a defect
2240
+ // must not mint the bug-fix floor's write/repro debt.
2241
+ || /\b(?:chua|duoc khong|dung khong|khong nhi|khong|nhi)\s*[.!?]*\s*$/.test(foldedPromptText);
1760
2242
  // Implement verbs (Vietnamese included — the English-only scores above cannot see
1761
2243
  // them) mark an action order even when phrased as a question ("ban sua giup toi…?" is
1762
2244
  // an edit order, not a consultation). A leading interrogative overrides them:
1763
2245
  // "Lam sao ma do duoc khi toi cai…?" asks HOW something is done, it does not order
1764
2246
  // the action performed.
2247
+ // C85-019: question shape + no surviving imperative = consult. The
2248
+ // pre-C85-019 all-zero-scores clause consulted only when NOTHING in the
2249
+ // prompt resembled work — impossible for a question about code. Replacing
2250
+ // it with hasMutationOrder keeps "sửa file cho tôi?" mutating while
2251
+ // "lỗi này chưa nhỉ" / "should I fix the hook?" consult. A targetFile
2252
+ // still vetoes.
1765
2253
  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);
1766
2254
  const consultationOnlySignal = questionShapedPrompt
1767
- && (leadingInterrogative || !consultationImplementWords)
1768
- && scores.editCertainty === 0
1769
- && !scores.implementSignal
1770
- && !scores.reviewSignal
1771
- && !scores.debugSignal
1772
- && !scores.failureSignal
1773
- && !scores.impactSignal
1774
- && !scores.buildSignal
1775
- && !scores.directTransformSignal
1776
- && !scores.smallFixSignal
1777
- && !scores.sharedRisk
2255
+ && !hasMutationOrder({ promptText, commandText })
1778
2256
  && !targetFile;
1779
2257
 
1780
2258
  if (deliveryOnlySignal || consultationOnlySignal) {
@@ -2394,12 +2872,50 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2394
2872
  executionScores: routeSummary.executionScores || null,
2395
2873
  approachSelector: routeSummary.approachSelector || null,
2396
2874
  executionContract: routeSummary.executionContract || null,
2875
+ // TASK-007 (BL-009): playbook fields survive compaction — identical
2876
+ // playbookId/workGroup to what route-task.mjs compactRouteSummary emits.
2877
+ // BL-010: direct/handoff path + fast-path enum survive compaction too —
2878
+ // verification-guard + eval fixtures read them off persisted state.
2879
+ playbookId: routeSummary.playbookId ?? null,
2880
+ workGroup: routeSummary.workGroup ?? null,
2881
+ path: routeSummary.path ?? null,
2882
+ fastPathKind: routeSummary.fastPathKind ?? null,
2883
+ ...(routeSummary.playbookReason ? { playbookReason: routeSummary.playbookReason } : {}),
2884
+ ...(routeSummary.playbookLoad ? { playbookLoad: routeSummary.playbookLoad } : {}),
2397
2885
  completionState: routeSummary.completionState || null,
2398
2886
  continuationState: routeSummary.continuationState || null,
2399
2887
  delegateHint: routeSummary.delegateHint || null,
2400
2888
  nextActionType: routeSummary.nextActionType || null,
2401
2889
  nextActionCommand: routeSummary.nextActionCommand || null,
2402
2890
  helperHint: routeSummary.helperHint || null,
2891
+ // TASK-004 (BL-006): shared-resolver fields, identical to what
2892
+ // route-task.mjs / taskRouting.js routeSummary emits on the helper path.
2893
+ rigor: routeSummary.rigor ?? null,
2894
+ fastPath: routeSummary.fastPath ?? null,
2895
+ riskFloor: routeSummary.riskFloor ?? null,
2896
+ resumable: routeSummary.resumable ?? null,
2897
+ decisionShadow: routeSummary.decisionShadow ?? null,
2898
+ escalationTriggers: Array.isArray(routeSummary.escalationTriggers)
2899
+ ? routeSummary.escalationTriggers
2900
+ : [],
2901
+ // BL-013: bounded history counters survive compaction — identical to the
2902
+ // helper's compactRouteSummary; conditional so absent stays byte-identical.
2903
+ ...(routeSummary.historySignals ? { historySignals: routeSummary.historySignals } : {}),
2904
+ // BL-019: the applied measured tier survives compaction — additive so
2905
+ // no-map routes keep the pre-BL-019 compact shape (identical to
2906
+ // route-task.mjs compactRouteSummary).
2907
+ ...(routeSummary.measuredTier != null ? { measuredTier: routeSummary.measuredTier } : {}),
2908
+ ...(routeSummary.routeVersion !== undefined
2909
+ ? {
2910
+ routeVersion: routeSummary.routeVersion,
2911
+ intent: routeSummary.intent ?? null,
2912
+ execution: routeSummary.execution ?? null,
2913
+ evidence: routeSummary.evidence ?? null,
2914
+ ceremonyBudget: routeSummary.ceremonyBudget ?? null,
2915
+ capabilityPolicy: routeSummary.capabilityPolicy ?? null,
2916
+ escalation: routeSummary.escalation ?? null,
2917
+ }
2918
+ : {}),
2403
2919
  };
2404
2920
  }
2405
2921
 
@@ -2421,17 +2937,52 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2421
2937
  prompt: routingContext.lastExplicitUserPromptText || '',
2422
2938
  adapter: routingContext.adapter || 'claude',
2423
2939
  }),
2940
+ // TASK-004 (BL-006): shared-resolver fields so audit rows carry the same
2941
+ // rigor/fastPath/riskFloor/resumable/shadow data the helper path emits.
2942
+ rigor: routeSummary.rigor ?? null,
2943
+ fastPath: routeSummary.fastPath ?? null,
2944
+ riskFloor: routeSummary.riskFloor ?? null,
2945
+ resumable: routeSummary.resumable ?? null,
2946
+ decisionShadow: routeSummary.decisionShadow ?? null,
2947
+ escalationTriggers: Array.isArray(routeSummary.escalationTriggers)
2948
+ ? routeSummary.escalationTriggers
2949
+ : [],
2424
2950
  targetFile: routingContext.targetFile || null,
2425
2951
  taskType: routingContext.taskType || null,
2952
+ // BL-003: the ledger-visible join key — execution-ledger.mjs stamps
2953
+ // `routeFingerprint: routeState.fingerprint`, the same routefp-v3 digest
2954
+ // this state's `fingerprint` field carries.
2955
+ routeFingerprint: state?.fingerprint || null,
2956
+ // FR-202a parity with route-task.mjs: resolved active-skill ids for the
2957
+ // skill-accuracy roll-ups. Telemetry only — excluded from dedupeKey.
2958
+ skillIds: (state?.activeSkills || [])
2959
+ .map((item) => (item && typeof item === 'object' ? item.id : item))
2960
+ .filter((id) => typeof id === 'string' && id)
2961
+ .slice(0, 8),
2426
2962
  executionMode: routeSummary.executionMode || null,
2963
+ // BL-019: the effective tier the route ran under — the join buckets
2964
+ // per-(mode,tier) reliability off this column. Telemetry only.
2965
+ modelTier: routeSummary?.executionContract?.modelTier || null,
2966
+ measuredTier: routeSummary?.measuredTier || null,
2427
2967
  competingMode: routeSummary?.approachSelector?.competingMode || null,
2428
2968
  competingScoreGap: routeSummary?.approachSelector?.competingScoreGap || null,
2429
2969
  candidateModes: candidateModes.slice(0, 3),
2430
2970
  nextActionType: routeSummary.nextActionType || null,
2431
2971
  nextMilestone: routeSummary?.continuationState?.nextMilestone || null,
2432
- repeatCount: routeSummary?.continuationState?.repeatCount || null,
2972
+ // TASK-007 (BL-009): playbook route fields — identical to the helper's
2973
+ // buildRouteAuditEntry; BL-010/011/012 read playbookId off the route row.
2974
+ playbookId: routeSummary.playbookId ?? null,
2975
+ workGroup: routeSummary.workGroup ?? null,
2976
+ playbookLoad: routeSummary.playbookLoad ?? null,
2977
+ // BL-010: direct/handoff path + fast-path enum on the audit row so the
2978
+ // eval join can assert `direct, fastPath=small-feature` (E-01).
2979
+ path: routeSummary.path ?? null,
2980
+ fastPathKind: routeSummary.fastPathKind ?? null,
2433
2981
  rescueMode: routeSummary?.continuationState?.rescueMode || null,
2434
2982
  wideningBlocked: routeSummary?.continuationState?.wideningBlocked || null,
2983
+ // BL-013: bounded history counters on the audit row — identical to the
2984
+ // helper's buildRouteAuditEntry emission.
2985
+ historySignals: routeSummary.historySignals ?? null,
2435
2986
  };
2436
2987
  }
2437
2988
 
@@ -2538,6 +3089,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2538
3089
  // itself instead of silently losing data.
2539
3090
  const sidecarPath = `${filePath}.unlocked.jsonl`;
2540
3091
  const lockPath = `${filePath}.lock`;
3092
+ // BL-004: ring overflow spills here — same directory, read by
3093
+ // routeOutcomes/skillAccuracy alongside the ring.
3094
+ const segmentsPath = path.join(path.dirname(filePath), 'route-audit.segments.jsonl');
2541
3095
 
2542
3096
  function entryDedupeKey(item) {
2543
3097
  return stableMachineDigest({
@@ -2574,7 +3128,23 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2574
3128
  seen.add(key);
2575
3129
  merged.push(item);
2576
3130
  }
2577
- return merged.slice(0, 40);
3131
+ // BL-004: rows past the 40-entry cap are spilled to
3132
+ // route-audit.segments.jsonl (append-only sidecar) instead of being
3133
+ // dropped — consumers read ring + segments with requestKey dedupe.
3134
+ return { entries: merged.slice(0, 40), evicted: merged.slice(40) };
3135
+ }
3136
+
3137
+ async function spillEvictedEntries(evicted) {
3138
+ if (!evicted?.length) return;
3139
+ try {
3140
+ await fsp.appendFile(
3141
+ segmentsPath,
3142
+ evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
3143
+ );
3144
+ } catch {
3145
+ // Spill write failure degrades to the pre-BL-004 drop — the audit merge
3146
+ // itself must never fail on telemetry sidecar I/O.
3147
+ }
2578
3148
  }
2579
3149
 
2580
3150
  // Synchronous last-chance fold for the self-deadline kill path: process.on('exit')
@@ -2587,6 +3157,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2587
3157
  // handlers below (event loop is gone; async cannot run). The C21-04 guard
2588
3158
  // intentionally does not match this handle so normal-path sync fs stays
2589
3159
  // forbidden while the exit-fold path is legal by construction.
3160
+ function spillEvictedEntriesSync(evicted) {
3161
+ if (!evicted?.length) return;
3162
+ try {
3163
+ fsSync.appendFileSync(
3164
+ segmentsPath,
3165
+ evicted.map((item) => JSON.stringify(item)).join('\n') + '\n',
3166
+ );
3167
+ } catch {
3168
+ // same degrade posture as the async spill above
3169
+ }
3170
+ }
3171
+
2590
3172
  const fsSync = fs;
2591
3173
  function claimSidecarSync() {
2592
3174
  const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
@@ -2645,9 +3227,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2645
3227
  try {
2646
3228
  parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
2647
3229
  } catch {}
3230
+ const { entries: folded, evicted } = mergeEntries(staged, parsed, null);
3231
+ spillEvictedEntriesSync(evicted);
2648
3232
  const tmpPath = `${filePath}.exitfold-${process.pid}`;
2649
3233
  fsSync.writeFileSync(tmpPath, JSON.stringify({
2650
- entries: mergeEntries(staged, parsed, null),
3234
+ entries: folded,
2651
3235
  }));
2652
3236
  fsSync.renameSync(tmpPath, filePath);
2653
3237
  } finally {
@@ -2693,8 +3277,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2693
3277
  }
2694
3278
  }
2695
3279
 
3280
+ const { entries: mergedEntries, evicted } = mergeEntries(sidecarEntries, parsed, entry);
3281
+ await spillEvictedEntries(evicted);
2696
3282
  await writeJsonFileAtomic(filePath, JSON.stringify({
2697
- entries: mergeEntries(sidecarEntries, parsed, entry),
3283
+ entries: mergedEntries,
2698
3284
  }));
2699
3285
  }, {
2700
3286
  onUnlocked: async (reason) => {
@@ -2830,6 +3416,13 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2830
3416
  : {})
2831
3417
  : storedPrevious;
2832
3418
  const runtimeConfig = await loadRuntimeConfig(projectRoot);
3419
+ // BL-013: the hook/bridge payload's transcript pointer (ukit-bridge.js:217,228
3420
+ // forwards transcriptPath; Claude Code payloads carry transcript_path). The
3421
+ // extractor reads a bounded tail of this file — never the whole transcript.
3422
+ const transcriptPathCandidate = payload.transcript_path ?? payload.transcriptPath;
3423
+ const transcriptPath = transcriptPathCandidate === undefined || transcriptPathCandidate === null
3424
+ ? null
3425
+ : String(transcriptPathCandidate).trim() || null;
2833
3426
 
2834
3427
  const promptText = extractPrompt(payload);
2835
3428
  const commandText = payload.tool_input?.command || payload.command || '';
@@ -2866,7 +3459,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2866
3459
  const now = Date.now();
2867
3460
  const debounceMs = 10 * 60 * 1000;
2868
3461
  const previousTs = Number(previous?.ts || 0);
2869
- const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
3462
+ const informationalNoTargetPrompt = (isInformationalPrompt({ promptText, commandText })
3463
+ || hasAdvisoryOnlyAssertion({ promptText, commandText })) && !filePath;
2870
3464
  // A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
2871
3465
  // mutation-free action and typically matches no skill. It must not inherit the
2872
3466
  // previous request's completion contract — carrying find-cause/map-impact debt
@@ -3036,6 +3630,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3036
3630
  routingContext,
3037
3631
  previousContextFingerprint: previousContext?.fingerprint || null,
3038
3632
  });
3633
+ // BL-013: one bounded tail extraction per route — reused by the provisional,
3634
+ // cached-reuse, and final emissions so all three carry identical counters.
3635
+ // ≤50ms async read, never throws, null when the extractor module is absent.
3636
+ const routeHistorySignals = await extractRouteHistorySignals({ requestKey });
3039
3637
  // Persist the completion contract BEFORE any cache import or indexed-context resolution.
3040
3638
  // Those later steps can take longer than the host's PreToolUse hook timeout. Previously a
3041
3639
  // timeout killed this process before its only state write, and completion-gate.sh then had
@@ -3050,6 +3648,15 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3050
3648
  previous?.routeSummary?.continuationState || null,
3051
3649
  );
3052
3650
  applyRescueBiasToRouteSummary(provisionalRouteSummary);
3651
+ // TASK-004 (BL-006): merge shared-resolver fields into the provisional
3652
+ // summary so even a timeout-killed route carries rigor/riskFloor/etc. The
3653
+ // provisional pass runs before context/verification resolution, so it passes
3654
+ // null previews; the final pass below recomputes with the full context.
3655
+ mergeResolverFields(provisionalRouteSummary, await buildSharedRouteFields({
3656
+ routingContext,
3657
+ activeSkills: selected,
3658
+ historySignals: routeHistorySignals,
3659
+ }));
3053
3660
  const provisionalState = {
3054
3661
  ...(sessionId ? { sessionId } : {}),
3055
3662
  requestKey,
@@ -3108,6 +3715,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3108
3715
  if (rescuedRouteSummary) {
3109
3716
  applyRescueBiasToRouteSummary(rescuedRouteSummary);
3110
3717
  }
3718
+ // TASK-004: cached summaries written before the resolver shipped (or after
3719
+ // a resolver load failure) lack the shared fields — fill only what's
3720
+ // missing, never overwrite a cached value.
3721
+ let reusedSharedFields = null;
3722
+ if (rescuedRouteSummary) {
3723
+ reusedSharedFields = await buildSharedRouteFields({
3724
+ routingContext,
3725
+ activeSkills: selected,
3726
+ historySignals: routeHistorySignals,
3727
+ });
3728
+ mergeResolverFields(rescuedRouteSummary, reusedSharedFields, { onlyMissing: true });
3729
+ }
3111
3730
  const reusedState = {
3112
3731
  ...cachedRouteState,
3113
3732
  ...(sessionId ? { sessionId } : {}),
@@ -3125,9 +3744,20 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3125
3744
  await ensureDir(statePath);
3126
3745
  await writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
3127
3746
  await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
3747
+ routingContext: reusedState.routingContext || {},
3748
+ routeSummary: rescuedRouteSummary || {},
3128
3749
  state: reusedState,
3129
3750
  }));
3130
3751
 
3752
+ // BL-017: detached decisions.tsv shadow emission for the reused route —
3753
+ // receipts only; the persisted state above is the authoritative record.
3754
+ fireRouteDecisionShadow({
3755
+ decisionShadowFields: rescuedRouteSummary?.decisionShadow
3756
+ ?? reusedSharedFields?.decisionShadowFields
3757
+ ?? null,
3758
+ targetFile: routingContext.targetFile ?? null,
3759
+ });
3760
+
3131
3761
  process.stdout.write(`[ukit-skill-router] Read skills: ${getActiveSkillPaths(reusedState.activeSkills || []).join(', ')}\n`);
3132
3762
  process.stdout.write(`[ukit-skill-router] Route: ${formatDisplayRouteSummary(reusedState.routeSummary, reusedState.routingContext)}\n`);
3133
3763
  const reusedNextDisplay = formatDisplayNextAction(reusedState.routeSummary);
@@ -3183,6 +3813,31 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3183
3813
  previous?.routeSummary?.continuationState || null,
3184
3814
  );
3185
3815
  applyRescueBiasToRouteSummary(routeSummary);
3816
+ // TASK-004 (BL-006): shared-resolver fields with full context — the same
3817
+ // deriveRouteFields call route-task.mjs/taskRouting.js makes, so the hook
3818
+ // state and helper state can no longer diverge.
3819
+ const finalSharedFields = await buildSharedRouteFields({
3820
+ routingContext,
3821
+ activeSkills: selected,
3822
+ contextRecommendation,
3823
+ verificationRecommendation,
3824
+ historySignals: routeHistorySignals,
3825
+ });
3826
+ mergeResolverFields(routeSummary, finalSharedFields);
3827
+ // BL-019 (SPEC FR-001): fold the persisted measured map into the route —
3828
+ // identical semantics to route-task.mjs finalizeTaskRoute / taskRouting.js
3829
+ // deriveTaskRoute: measuredTier emitted additively; modelTier follows the
3830
+ // map only when tier-map.json says 'promote'. Advisory — every failure
3831
+ // degrades to the static map.
3832
+ try {
3833
+ const tierSelection = await loadTierSelection();
3834
+ if (tierSelection && typeof tierSelection.applyMeasuredTier === 'function') {
3835
+ await tierSelection.applyMeasuredTier({ projectRoot, routeSummary });
3836
+ }
3837
+ } catch {
3838
+ // static map stays authoritative
3839
+ }
3840
+
3186
3841
 
3187
3842
  const fingerprint = buildRouteStateFingerprint({
3188
3843
  activeSkills: selected,
@@ -3218,6 +3873,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3218
3873
  routeSummary,
3219
3874
  state: sharedState,
3220
3875
  }));
3876
+
3877
+ // BL-017: detached decisions.tsv shadow emission — fire-and-forget child
3878
+ // replays the batched adapter shadow against the persisted route state.
3879
+ // Spawned only after the state write the child reads; a spawn failure is
3880
+ // advisory and the route output below still emits unchanged.
3881
+ fireRouteDecisionShadow({
3882
+ decisionShadowFields: routeSummary?.decisionShadow
3883
+ ?? finalSharedFields?.decisionShadowFields
3884
+ ?? null,
3885
+ targetFile: routingContext.targetFile ?? null,
3886
+ });
3221
3887
  if (typeof cacheUtils?.writeRecentCacheEntry === 'function') {
3222
3888
  await cacheUtils.writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
3223
3889
  maxEntries: cacheUtils.DEFAULT_RECENT_CACHE_MAX_ENTRIES,