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