@ngockhoale/ukit 2.3.9 → 2.3.11

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.
@@ -121,6 +121,23 @@ const { pathToFileURL } = require('url');
121
121
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
122
122
  }
123
123
 
124
+ function writeJsonFileAtomic(filePath, text) {
125
+ // tmp+rename: readers see old-or-new, never a torn/half file. A truncated
126
+ // skill-router-state.json parses as `{}` downstream and completion-gate.sh then
127
+ // releases a premature Stop with no message — the mid-run silent-stop class.
128
+ ensureDir(filePath);
129
+ const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
130
+ try {
131
+ fs.writeFileSync(tempPath, text);
132
+ fs.renameSync(tempPath, filePath);
133
+ } catch (error) {
134
+ try {
135
+ fs.rmSync(tempPath, { force: true });
136
+ } catch {}
137
+ throw error;
138
+ }
139
+ }
140
+
124
141
  function defaultRuntimeConfig() {
125
142
  return {
126
143
  memory: {
@@ -544,7 +561,7 @@ const { pathToFileURL } = require('url');
544
561
  routingContext = {},
545
562
  previousContextFingerprint = null,
546
563
  }) {
547
- return buildCompactMachineKey('route-v2', {
564
+ return buildCompactMachineKey('route-v3', {
548
565
  commandNamespace,
549
566
  indexGeneratedAtMs,
550
567
  projectFingerprint,
@@ -1577,8 +1594,10 @@ const { pathToFileURL } = require('url');
1577
1594
  intentMode = null,
1578
1595
  executionScores = {},
1579
1596
  executionCandidates = null,
1597
+ signalText = '',
1580
1598
  } = {}) {
1581
1599
  const raw = `${promptText || ''}\n${commandText || ''}`.toLowerCase();
1600
+ const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
1582
1601
  const scores = executionScores;
1583
1602
  const candidates = executionCandidates ?? buildExecutionModeCandidates({
1584
1603
  promptText,
@@ -1591,15 +1610,45 @@ const { pathToFileURL } = require('url');
1591
1610
  const strongImpactLead = scores.sharedRisk
1592
1611
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
1593
1612
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
1613
+ const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
1614
+ && /\b(?:release|tag|publish|push|report)\b/.test(raw);
1615
+ // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
1616
+ // repository mutation the ledger could ever receipt. With no edit/review/debug/
1617
+ // build signal present, routing it to an investigation lane fabricates write debt
1618
+ // and the completion gate then demands an edit that cannot exist. Delivery-only
1619
+ // requests carry no completion contract instead.
1620
+ const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
1621
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
1622
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
1623
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
1624
+ const deliveryOnlySignal = deliveryWordSignal
1625
+ && scores.editCertainty === 0
1626
+ && !scores.implementSignal
1627
+ && !scores.reviewSignal
1628
+ && !scores.debugSignal
1629
+ && !scores.failureSignal
1630
+ && !scores.impactSignal
1631
+ && !scores.buildSignal
1632
+ && !scores.directTransformSignal
1633
+ && !scores.smallFixSignal
1634
+ && !scores.sharedRisk
1635
+ && !targetFile;
1594
1636
 
1595
1637
  if (isInformationalPrompt({ promptText, commandText, scores })) {
1596
1638
  return 'informational';
1597
1639
  }
1598
1640
 
1599
- if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
1641
+ if (
1642
+ releaseVerificationContinuation
1643
+ || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
1644
+ ) {
1600
1645
  return 'review-release';
1601
1646
  }
1602
1647
 
1648
+ if (deliveryOnlySignal) {
1649
+ return 'informational';
1650
+ }
1651
+
1603
1652
  if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
1604
1653
  return 'map-impact';
1605
1654
  }
@@ -1699,7 +1748,7 @@ const { pathToFileURL } = require('url');
1699
1748
  ),
1700
1749
  'map-impact': (
1701
1750
  (scores.sharedRisk ? 7 : 0)
1702
- + (scores.impactSignal ? 5 : 0)
1751
+ + (scores.impactSignal ? 5 : -5)
1703
1752
  + (scores.ambiguity * 2)
1704
1753
  + scores.investigationNeed
1705
1754
  ),
@@ -2254,45 +2303,124 @@ const { pathToFileURL } = require('url');
2254
2303
  };
2255
2304
  }
2256
2305
 
2257
- function appendRouteAuditEntry(filePath, entry) {
2258
- if (!entry || typeof entry !== 'object') {
2259
- return;
2306
+ // Owner-token lock protocol, protocol-compatible with runtime token-utils withFileLock
2307
+ // and the advisory permission hooks (mkdir-based `<file>.lock` + owner liveness).
2308
+ // route-audit.json is a read-modify-write merge: without the lock, 16 concurrent
2309
+ // router invocations were verified to keep only 13/16 entries (lost updates).
2310
+ function isPidAlive(pid) {
2311
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2312
+ try {
2313
+ process.kill(pid, 0);
2314
+ return true;
2315
+ } catch (error) {
2316
+ return error?.code === 'EPERM';
2260
2317
  }
2318
+ }
2261
2319
 
2262
- let parsed = { entries: [] };
2263
- if (fs.existsSync(filePath)) {
2320
+ function readLockOwner(lockPath) {
2321
+ const owner = readJson(path.join(lockPath, 'owner'), null);
2322
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
2323
+ ? owner
2324
+ : null;
2325
+ }
2326
+
2327
+ async function withStateLock(lockPath, fn) {
2328
+ // Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
2329
+ // within maxWaitMs the merge runs unlocked (pre-lock behaviour) rather than hanging
2330
+ // or dropping the entry.
2331
+ const staleMs = 10000;
2332
+ const maxWaitMs = 5000;
2333
+ const startedAt = Date.now();
2334
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
2335
+ let held = false;
2336
+ while (!held) {
2264
2337
  try {
2265
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2266
- } catch {
2267
- parsed = { entries: [] };
2338
+ fs.mkdirSync(lockPath);
2339
+ try {
2340
+ fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
2341
+ pid: process.pid,
2342
+ token: ownerToken,
2343
+ ts: Date.now(),
2344
+ }));
2345
+ } catch (error) {
2346
+ try {
2347
+ fs.rmSync(lockPath, { recursive: true, force: true });
2348
+ } catch {}
2349
+ throw error;
2350
+ }
2351
+ held = true;
2352
+ } catch (error) {
2353
+ if (!error || error.code !== 'EEXIST') return fn();
2354
+ try {
2355
+ const stat = fs.statSync(lockPath);
2356
+ if (Date.now() - stat.mtimeMs > staleMs) {
2357
+ const owner = readLockOwner(lockPath);
2358
+ if (!owner || !isPidAlive(owner.pid)) {
2359
+ try {
2360
+ fs.rmSync(lockPath, { recursive: true, force: true });
2361
+ continue;
2362
+ } catch {}
2363
+ }
2364
+ }
2365
+ } catch {}
2366
+ if (Date.now() - startedAt > maxWaitMs) break;
2367
+ await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
2368
+ }
2369
+ }
2370
+ try {
2371
+ return await fn();
2372
+ } finally {
2373
+ if (held) {
2374
+ const owner = readLockOwner(lockPath);
2375
+ if (owner?.token === ownerToken) {
2376
+ try {
2377
+ fs.rmSync(lockPath, { recursive: true, force: true });
2378
+ } catch {}
2379
+ }
2268
2380
  }
2269
2381
  }
2382
+ }
2270
2383
 
2271
- const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2272
- const dedupeKey = stableMachineDigest({
2273
- requestKey: entry.requestKey,
2274
- executionMode: entry.executionMode,
2275
- nextActionType: entry.nextActionType,
2276
- nextMilestone: entry.nextMilestone,
2277
- repeatCount: entry.repeatCount,
2278
- rescueMode: entry.rescueMode,
2279
- });
2280
- const filtered = entries.filter((item) => {
2281
- const itemKey = stableMachineDigest({
2282
- requestKey: item?.requestKey || null,
2283
- executionMode: item?.executionMode || null,
2284
- nextActionType: item?.nextActionType || null,
2285
- nextMilestone: item?.nextMilestone || null,
2286
- repeatCount: item?.repeatCount || null,
2287
- rescueMode: item?.rescueMode || null,
2384
+ async function appendRouteAuditEntry(filePath, entry) {
2385
+ if (!entry || typeof entry !== 'object') {
2386
+ return;
2387
+ }
2388
+
2389
+ await withStateLock(`${filePath}.lock`, () => {
2390
+ let parsed = { entries: [] };
2391
+ if (fs.existsSync(filePath)) {
2392
+ try {
2393
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2394
+ } catch {
2395
+ parsed = { entries: [] };
2396
+ }
2397
+ }
2398
+
2399
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2400
+ const dedupeKey = stableMachineDigest({
2401
+ requestKey: entry.requestKey,
2402
+ executionMode: entry.executionMode,
2403
+ nextActionType: entry.nextActionType,
2404
+ nextMilestone: entry.nextMilestone,
2405
+ repeatCount: entry.repeatCount,
2406
+ rescueMode: entry.rescueMode,
2407
+ });
2408
+ const filtered = entries.filter((item) => {
2409
+ const itemKey = stableMachineDigest({
2410
+ requestKey: item?.requestKey || null,
2411
+ executionMode: item?.executionMode || null,
2412
+ nextActionType: item?.nextActionType || null,
2413
+ nextMilestone: item?.nextMilestone || null,
2414
+ repeatCount: item?.repeatCount || null,
2415
+ rescueMode: item?.rescueMode || null,
2416
+ });
2417
+ return itemKey !== dedupeKey;
2288
2418
  });
2289
- return itemKey !== dedupeKey;
2290
- });
2291
2419
 
2292
- ensureDir(filePath);
2293
- fs.writeFileSync(filePath, JSON.stringify({
2294
- entries: [entry, ...filtered].slice(0, 40),
2295
- }));
2420
+ writeJsonFileAtomic(filePath, JSON.stringify({
2421
+ entries: [entry, ...filtered].slice(0, 40),
2422
+ }));
2423
+ });
2296
2424
  }
2297
2425
 
2298
2426
  function shouldIncludePreviousContext({ routingContext = {}, useIndexedContext = true } = {}) {
@@ -2445,6 +2573,39 @@ const { pathToFileURL } = require('url');
2445
2573
  const debounceMs = 10 * 60 * 1000;
2446
2574
  const previousTs = Number(previous?.ts || 0);
2447
2575
  const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
2576
+ // A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
2577
+ // mutation-free action and typically matches no skill. It must not inherit the
2578
+ // previous request's completion contract — carrying find-cause/map-impact debt
2579
+ // forward here made the Stop gate demand an edit that a push can never produce.
2580
+ const noRouteScores = deriveExecutionScores({
2581
+ promptText,
2582
+ commandText,
2583
+ targetFile: filePath,
2584
+ intentMode,
2585
+ taskType: '',
2586
+ });
2587
+ const noRouteSignalRaw = [
2588
+ routeSignals.promptRawText || '',
2589
+ routeSignals.promptNormalizedText || '',
2590
+ routeSignals.commandRawText || '',
2591
+ routeSignals.commandNormalizedText || '',
2592
+ ].join('\n');
2593
+ const deliveryWordSignal = /\bgit\s+push\b/.test(noRouteSignalRaw)
2594
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(noRouteSignalRaw)
2595
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(noRouteSignalRaw)
2596
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(noRouteSignalRaw);
2597
+ const deliveryOnlyNoRoute = deliveryWordSignal
2598
+ && noRouteScores.editCertainty === 0
2599
+ && !noRouteScores.implementSignal
2600
+ && !noRouteScores.reviewSignal
2601
+ && !noRouteScores.debugSignal
2602
+ && !noRouteScores.failureSignal
2603
+ && !noRouteScores.impactSignal
2604
+ && !noRouteScores.buildSignal
2605
+ && !noRouteScores.directTransformSignal
2606
+ && !noRouteScores.smallFixSignal
2607
+ && !noRouteScores.sharedRisk
2608
+ && !filePath;
2448
2609
 
2449
2610
  if (active.length === 0 || informationalNoTargetPrompt) {
2450
2611
  const lastExplicitUserPromptText = promptText.trim()
@@ -2457,7 +2618,7 @@ const { pathToFileURL } = require('url');
2457
2618
  targetFile: filePath,
2458
2619
  contextIntent: '',
2459
2620
  taskType: '',
2460
- ...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
2621
+ ...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
2461
2622
  };
2462
2623
  const previousContext = buildPreviousContextSnapshot({
2463
2624
  projectRoot,
@@ -2486,7 +2647,7 @@ const { pathToFileURL } = require('url');
2486
2647
  // route: completion-gate.sh reads routeSummary/requestKey from this state to decide
2487
2648
  // whether a Stop is premature. Downgrading to a route-less state here silently disarms
2488
2649
  // the gate mid-task, so carry the previous route forward until a real route replaces it.
2489
- fs.writeFileSync(statePath, JSON.stringify({
2650
+ writeJsonFileAtomic(statePath, JSON.stringify({
2490
2651
  ...(sessionId ? { sessionId } : {}),
2491
2652
  fingerprint,
2492
2653
  ts: now,
@@ -2494,8 +2655,8 @@ const { pathToFileURL } = require('url');
2494
2655
  activeSkills: [],
2495
2656
  routingContext: compactRoutingContext(routingContext),
2496
2657
  ...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
2497
- ...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
2498
- ...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
2658
+ ...(previous?.routeSummary && !deliveryOnlyNoRoute ? { routeSummary: previous.routeSummary } : {}),
2659
+ ...(previous?.requestKey && !deliveryOnlyNoRoute ? { requestKey: previous.requestKey } : {}),
2499
2660
  }));
2500
2661
  process.exit(0);
2501
2662
  }
@@ -2536,6 +2697,7 @@ const { pathToFileURL } = require('url');
2536
2697
  intentMode,
2537
2698
  executionScores,
2538
2699
  executionCandidates,
2700
+ signalText: `${routeSignals.promptNormalizedText || ''}\n${routeSignals.commandNormalizedText || ''}`,
2539
2701
  });
2540
2702
  const lastExplicitUserPromptText = promptText.trim()
2541
2703
  || previous?.routingContext?.lastExplicitUserPromptText
@@ -2606,7 +2768,7 @@ const { pathToFileURL } = require('url');
2606
2768
  };
2607
2769
  provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
2608
2770
  ensureDir(statePath);
2609
- fs.writeFileSync(statePath, JSON.stringify(provisionalState));
2771
+ writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
2610
2772
 
2611
2773
  let cacheUtils = null;
2612
2774
  if (fs.existsSync(cacheUtilsPath)) {
@@ -2667,8 +2829,8 @@ const { pathToFileURL } = require('url');
2667
2829
  }
2668
2830
 
2669
2831
  ensureDir(statePath);
2670
- fs.writeFileSync(statePath, JSON.stringify(reusedState));
2671
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2832
+ writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
2833
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2672
2834
  state: reusedState,
2673
2835
  }));
2674
2836
 
@@ -2756,8 +2918,8 @@ const { pathToFileURL } = require('url');
2756
2918
  ...(helpers ? { helpers } : {}),
2757
2919
  routeSummary: compactRouteSummary(routeSummary),
2758
2920
  };
2759
- fs.writeFileSync(statePath, JSON.stringify(sharedState));
2760
- appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2921
+ writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
2922
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
2761
2923
  routingContext,
2762
2924
  routeSummary,
2763
2925
  state: sharedState,
@@ -2792,7 +2954,14 @@ const { pathToFileURL } = require('url');
2792
2954
  NODE
2793
2955
 
2794
2956
  if [ -f "$THRESHOLD_SCRIPT" ]; then
2795
- printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true
2957
+ # The 'prompt' phase only bookkeeps prompt-bearing events — a tool payload has no
2958
+ # prompt text and that phase exits before doing anything. Every PreToolUse/
2959
+ # PostToolUse call used to pay a second full node spawn for that no-op, doubling the
2960
+ # per-mutation hook latency. Tool payloads always carry tool_name; skip them.
2961
+ case "$INPUT" in
2962
+ *'"tool_name"'*) ;;
2963
+ *) printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true ;;
2964
+ esac
2796
2965
  fi
2797
2966
 
2798
2967
  exit 0
@@ -9,6 +9,7 @@ ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
9
9
  PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
10
10
 
11
11
  INPUT="$INPUT" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
12
+ const crypto = require('crypto');
12
13
  const fs = require('fs');
13
14
  const path = require('path');
14
15
 
@@ -41,6 +42,23 @@ function sleepSync(ms) {
41
42
  Atomics.wait(new Int32Array(buffer), 0, 0, ms);
42
43
  }
43
44
 
45
+ function isPidAlive(pid) {
46
+ if (!Number.isInteger(pid) || pid <= 0) return false;
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (error) {
51
+ return error?.code === 'EPERM';
52
+ }
53
+ }
54
+
55
+ function readLockOwner(lockPath) {
56
+ const owner = readJson(path.join(lockPath, 'owner'), null);
57
+ return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
58
+ ? owner
59
+ : null;
60
+ }
61
+
44
62
  // mkdir-based lock, protocol-compatible with runtime token-utils withFileLock. Sync on
45
63
  // purpose (see sleepSync). Fail-open after 5s: this hook must never hang a PreToolUse
46
64
  // chain, and a lost progress entry only costs one repeated advisory.
@@ -48,21 +66,37 @@ function withLockSync(lockPath, fn) {
48
66
  const staleMs = 10000;
49
67
  const maxWaitMs = 5000;
50
68
  const startedAt = Date.now();
69
+ const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
51
70
  let held = false;
52
71
  while (!held) {
53
72
  try {
54
73
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
55
74
  fs.mkdirSync(lockPath);
75
+ try {
76
+ fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
77
+ pid: process.pid,
78
+ token: ownerToken,
79
+ ts: Date.now(),
80
+ }));
81
+ } catch (error) {
82
+ try {
83
+ fs.rmSync(lockPath, { recursive: true, force: true });
84
+ } catch {}
85
+ throw error;
86
+ }
56
87
  held = true;
57
88
  } catch (error) {
58
89
  if (!error || error.code !== 'EEXIST') return fn();
59
90
  try {
60
91
  const stat = fs.statSync(lockPath);
61
92
  if (Date.now() - stat.mtimeMs > staleMs) {
62
- try {
63
- fs.rmSync(lockPath, { recursive: true, force: true });
64
- continue;
65
- } catch {}
93
+ const owner = readLockOwner(lockPath);
94
+ if (!owner || !isPidAlive(owner.pid)) {
95
+ try {
96
+ fs.rmSync(lockPath, { recursive: true, force: true });
97
+ continue;
98
+ } catch {}
99
+ }
66
100
  }
67
101
  } catch {}
68
102
  if (Date.now() - startedAt > maxWaitMs) break;
@@ -73,9 +107,12 @@ function withLockSync(lockPath, fn) {
73
107
  return fn();
74
108
  } finally {
75
109
  if (held) {
76
- try {
77
- fs.rmdirSync(lockPath);
78
- } catch {}
110
+ const owner = readLockOwner(lockPath);
111
+ if (owner?.token === ownerToken) {
112
+ try {
113
+ fs.rmSync(lockPath, { recursive: true, force: true });
114
+ } catch {}
115
+ }
79
116
  }
80
117
  }
81
118
  }
@@ -288,14 +325,12 @@ function hasCompletedRecentTargetedLane({
288
325
  });
289
326
  }
290
327
 
291
- function deny(message) {
292
- const advisory = message.replace(/^BLOCKED:/, 'ADVISORY:');
293
- if (process.env.UKIT_VERIFICATION_GUARD_ENFORCE === '1') {
294
- fs.writeSync(2, `${message}\n`);
295
- process.exit(2);
296
- }
297
-
298
- fs.writeSync(1, `${advisory}\n`);
328
+ function advise(message) {
329
+ // This hook is intentionally advisory. The executable hard gates are the completion
330
+ // ledger, context hard-cap, protected-file, and destructive-command guards; turning
331
+ // a verification-order recommendation into an exit-2 refusal gave a stale opt-in env
332
+ // variable a way to freeze a run before it could diagnose or land its current change.
333
+ fs.writeSync(1, `${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
299
334
  process.exit(0);
300
335
  }
301
336
 
@@ -433,31 +468,31 @@ if (isTargetedVerification) {
433
468
  }
434
469
 
435
470
  if (policyMode === 'confirm-then-broad' && isBroadTestCommand(command) && !explicitBroadRequested && !recentTargetedLaneCompleted) {
436
- deny(
471
+ advise(
437
472
  `BLOCKED: Ask the user before broad verification. Routed policy is confirm-then-broad because the index could not localize related tests confidently.${helperCommand}`,
438
473
  );
439
474
  }
440
475
 
441
476
  if ((policyMode === 'auto-run-targeted' || policyMode === 'auto-run-targeted-then-fallback') && !isPrimaryCommand && missingPrimary.length > 0) {
442
- deny(
477
+ advise(
443
478
  `BLOCKED: Run targeted verification first. Routed policy is ${policyMode} and the preferred first command(s) are: ${missingPrimaryText}.${helperCommand}`,
444
479
  );
445
480
  }
446
481
 
447
482
  if (policyMode === 'auto-run-targeted' && !explicitBroadRequested && !isPrimaryCommand) {
448
- deny(
483
+ advise(
449
484
  `BLOCKED: Broader verification is not the default for this routed context. Stay with targeted verification unless the user explicitly requests a full suite. Preferred order: ${preferredText}.${helperCommand}`,
450
485
  );
451
486
  }
452
487
 
453
488
  if (policyMode === 'auto-run-targeted-then-fallback' && !explicitBroadRequested && isVerificationCommand(command) && !isPrimaryCommand && !isFallbackCommand) {
454
- deny(
489
+ advise(
455
490
  `BLOCKED: Follow the routed verification order. This command is outside the recommended fallback lane. Preferred order: ${preferredText}.${helperCommand}`,
456
491
  );
457
492
  }
458
493
 
459
494
  if (policyMode === 'docs-light' && !explicitBroadRequested && isBroadTestCommand(command)) {
460
- deny(
495
+ advise(
461
496
  `BLOCKED: Docs-focused change detected. Broad test runs are not the default for docs-light verification. Ask first if a full suite is still required.${helperCommand}`,
462
497
  );
463
498
  }
@@ -450,6 +450,7 @@ async function prepareTaskRoute({
450
450
  intentMode,
451
451
  executionScores,
452
452
  executionCandidates,
453
+ signalText: buildNormalizedRouteSignalText(normalizedPrompt, normalizedCommand),
453
454
  });
454
455
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
455
456
  const visionInfo = await resolveVisionLane({
@@ -1749,8 +1750,10 @@ function deriveExecutionMode({
1749
1750
  intentMode = null,
1750
1751
  executionScores = {},
1751
1752
  executionCandidates = null,
1753
+ signalText = '',
1752
1754
  } = {}) {
1753
1755
  const raw = `${promptText ?? ''}\n${commandText ?? ''}`.toLowerCase();
1756
+ const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
1754
1757
  const scores = executionScores;
1755
1758
  const candidates = executionCandidates ?? buildExecutionModeCandidates({
1756
1759
  promptText,
@@ -1760,18 +1763,48 @@ function deriveExecutionMode({
1760
1763
  executionScores,
1761
1764
  });
1762
1765
  const explicitReviewLead = /\b(review this|audit this|review\b.*\brelease readiness|verify\b.*\brelease readiness)\b/.test(raw);
1766
+ const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
1767
+ && /\b(?:release|tag|publish|push|report)\b/.test(raw);
1763
1768
  const strongImpactLead = scores.sharedRisk
1764
1769
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
1765
1770
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
1771
+ // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
1772
+ // repository mutation the ledger could ever receipt. With no edit/review/debug/
1773
+ // build signal present, routing it to an investigation lane fabricates write debt
1774
+ // and the completion gate then demands an edit that cannot exist. Delivery-only
1775
+ // requests carry no completion contract instead.
1776
+ const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
1777
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
1778
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
1779
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
1780
+ const deliveryOnlySignal = deliveryWordSignal
1781
+ && scores.editCertainty === 0
1782
+ && !scores.implementSignal
1783
+ && !scores.reviewSignal
1784
+ && !scores.debugSignal
1785
+ && !scores.failureSignal
1786
+ && !scores.impactSignal
1787
+ && !scores.buildSignal
1788
+ && !scores.directTransformSignal
1789
+ && !scores.smallFixSignal
1790
+ && !scores.sharedRisk
1791
+ && !targetFile;
1766
1792
 
1767
1793
  if (isInformationalPrompt({ promptText, commandText, scores })) {
1768
1794
  return 'informational';
1769
1795
  }
1770
1796
 
1771
- if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
1797
+ if (
1798
+ releaseVerificationContinuation
1799
+ || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
1800
+ ) {
1772
1801
  return 'review-release';
1773
1802
  }
1774
1803
 
1804
+ if (deliveryOnlySignal) {
1805
+ return 'informational';
1806
+ }
1807
+
1775
1808
  if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
1776
1809
  return 'map-impact';
1777
1810
  }
@@ -1871,7 +1904,7 @@ function buildExecutionModeCandidates({
1871
1904
  ),
1872
1905
  'map-impact': (
1873
1906
  (scores.sharedRisk ? 7 : 0)
1874
- + (scores.impactSignal ? 5 : 0)
1907
+ + (scores.impactSignal ? 5 : -5)
1875
1908
  + (scores.ambiguity * 2)
1876
1909
  + scores.investigationNeed
1877
1910
  ),
@@ -2784,7 +2817,7 @@ function buildRouteRequestKey({
2784
2817
  previousContextSnapshot = null,
2785
2818
  recentOutputSnapshot = null,
2786
2819
  } = {}) {
2787
- return buildCompactMachineKey('route-v2', {
2820
+ return buildCompactMachineKey('route-v3', {
2788
2821
  commandNamespace,
2789
2822
  indexGeneratedAtMs,
2790
2823
  projectFingerprint,