@ngockhoale/ukit 2.3.9 → 2.3.10

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.
@@ -544,7 +544,7 @@ const { pathToFileURL } = require('url');
544
544
  routingContext = {},
545
545
  previousContextFingerprint = null,
546
546
  }) {
547
- return buildCompactMachineKey('route-v2', {
547
+ return buildCompactMachineKey('route-v3', {
548
548
  commandNamespace,
549
549
  indexGeneratedAtMs,
550
550
  projectFingerprint,
@@ -1577,8 +1577,10 @@ const { pathToFileURL } = require('url');
1577
1577
  intentMode = null,
1578
1578
  executionScores = {},
1579
1579
  executionCandidates = null,
1580
+ signalText = '',
1580
1581
  } = {}) {
1581
1582
  const raw = `${promptText || ''}\n${commandText || ''}`.toLowerCase();
1583
+ const signalRaw = `${raw}\n${String(signalText || '').toLowerCase()}`;
1582
1584
  const scores = executionScores;
1583
1585
  const candidates = executionCandidates ?? buildExecutionModeCandidates({
1584
1586
  promptText,
@@ -1591,15 +1593,45 @@ const { pathToFileURL } = require('url');
1591
1593
  const strongImpactLead = scores.sharedRisk
1592
1594
  && (scores.impactSignal || /\b(check all affected|map all affected|across all affected)\b/.test(raw));
1593
1595
  const boundedLocalBuildCandidate = scores.buildSignal && scores.boundedEditSignal && scores.explicitTarget && !scores.sharedRisk;
1596
+ const releaseVerificationContinuation = /\b(?:pending\s+diff[- ]review|self-install|verify\s+(?:consistency|release)|release\s+(?:verification|readiness))\b/.test(raw)
1597
+ && /\b(?:release|tag|publish|push|report)\b/.test(raw);
1598
+ // A bare delivery command ("push this to git", "đẩy bộ này lên git") performs no
1599
+ // repository mutation the ledger could ever receipt. With no edit/review/debug/
1600
+ // build signal present, routing it to an investigation lane fabricates write debt
1601
+ // and the completion gate then demands an edit that cannot exist. Delivery-only
1602
+ // requests carry no completion contract instead.
1603
+ const deliveryWordSignal = /\bgit\s+push\b/.test(signalRaw)
1604
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(signalRaw)
1605
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(signalRaw)
1606
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(signalRaw);
1607
+ const deliveryOnlySignal = deliveryWordSignal
1608
+ && scores.editCertainty === 0
1609
+ && !scores.implementSignal
1610
+ && !scores.reviewSignal
1611
+ && !scores.debugSignal
1612
+ && !scores.failureSignal
1613
+ && !scores.impactSignal
1614
+ && !scores.buildSignal
1615
+ && !scores.directTransformSignal
1616
+ && !scores.smallFixSignal
1617
+ && !scores.sharedRisk
1618
+ && !targetFile;
1594
1619
 
1595
1620
  if (isInformationalPrompt({ promptText, commandText, scores })) {
1596
1621
  return 'informational';
1597
1622
  }
1598
1623
 
1599
- if ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal) {
1624
+ if (
1625
+ releaseVerificationContinuation
1626
+ || ((intentMode === 'review-specific' || explicitReviewLead) && !scores.implementSignal)
1627
+ ) {
1600
1628
  return 'review-release';
1601
1629
  }
1602
1630
 
1631
+ if (deliveryOnlySignal) {
1632
+ return 'informational';
1633
+ }
1634
+
1603
1635
  if (strongImpactLead || (scores.blastRadius >= 3 && (scores.ambiguity >= 2 || scores.investigationNeed >= 2))) {
1604
1636
  return 'map-impact';
1605
1637
  }
@@ -1699,7 +1731,7 @@ const { pathToFileURL } = require('url');
1699
1731
  ),
1700
1732
  'map-impact': (
1701
1733
  (scores.sharedRisk ? 7 : 0)
1702
- + (scores.impactSignal ? 5 : 0)
1734
+ + (scores.impactSignal ? 5 : -5)
1703
1735
  + (scores.ambiguity * 2)
1704
1736
  + scores.investigationNeed
1705
1737
  ),
@@ -2445,6 +2477,39 @@ const { pathToFileURL } = require('url');
2445
2477
  const debounceMs = 10 * 60 * 1000;
2446
2478
  const previousTs = Number(previous?.ts || 0);
2447
2479
  const informationalNoTargetPrompt = isInformationalPrompt({ promptText, commandText }) && !filePath;
2480
+ // A delivery-only prompt ("push this to git", "đẩy bộ này lên git") starts a new,
2481
+ // mutation-free action and typically matches no skill. It must not inherit the
2482
+ // previous request's completion contract — carrying find-cause/map-impact debt
2483
+ // forward here made the Stop gate demand an edit that a push can never produce.
2484
+ const noRouteScores = deriveExecutionScores({
2485
+ promptText,
2486
+ commandText,
2487
+ targetFile: filePath,
2488
+ intentMode,
2489
+ taskType: '',
2490
+ });
2491
+ const noRouteSignalRaw = [
2492
+ routeSignals.promptRawText || '',
2493
+ routeSignals.promptNormalizedText || '',
2494
+ routeSignals.commandRawText || '',
2495
+ routeSignals.commandNormalizedText || '',
2496
+ ].join('\n');
2497
+ const deliveryWordSignal = /\bgit\s+push\b/.test(noRouteSignalRaw)
2498
+ || /\bpush\b[^\n]{0,60}\b(?:git|github|gitlab|remote|origin|repo)\b/.test(noRouteSignalRaw)
2499
+ || /\b(?:git|github|gitlab|remote|origin|repo)\b[^\n]{0,60}\bpush\b/.test(noRouteSignalRaw)
2500
+ || /\bday\b(?:\s+\S+){0,3}?\s+len\b/.test(noRouteSignalRaw);
2501
+ const deliveryOnlyNoRoute = deliveryWordSignal
2502
+ && noRouteScores.editCertainty === 0
2503
+ && !noRouteScores.implementSignal
2504
+ && !noRouteScores.reviewSignal
2505
+ && !noRouteScores.debugSignal
2506
+ && !noRouteScores.failureSignal
2507
+ && !noRouteScores.impactSignal
2508
+ && !noRouteScores.buildSignal
2509
+ && !noRouteScores.directTransformSignal
2510
+ && !noRouteScores.smallFixSignal
2511
+ && !noRouteScores.sharedRisk
2512
+ && !filePath;
2448
2513
 
2449
2514
  if (active.length === 0 || informationalNoTargetPrompt) {
2450
2515
  const lastExplicitUserPromptText = promptText.trim()
@@ -2457,7 +2522,7 @@ const { pathToFileURL } = require('url');
2457
2522
  targetFile: filePath,
2458
2523
  contextIntent: '',
2459
2524
  taskType: '',
2460
- ...(informationalNoTargetPrompt ? { executionMode: 'informational' } : {}),
2525
+ ...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
2461
2526
  };
2462
2527
  const previousContext = buildPreviousContextSnapshot({
2463
2528
  projectRoot,
@@ -2494,8 +2559,8 @@ const { pathToFileURL } = require('url');
2494
2559
  activeSkills: [],
2495
2560
  routingContext: compactRoutingContext(routingContext),
2496
2561
  ...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
2497
- ...(previous?.routeSummary ? { routeSummary: previous.routeSummary } : {}),
2498
- ...(previous?.requestKey ? { requestKey: previous.requestKey } : {}),
2562
+ ...(previous?.routeSummary && !deliveryOnlyNoRoute ? { routeSummary: previous.routeSummary } : {}),
2563
+ ...(previous?.requestKey && !deliveryOnlyNoRoute ? { requestKey: previous.requestKey } : {}),
2499
2564
  }));
2500
2565
  process.exit(0);
2501
2566
  }
@@ -2536,6 +2601,7 @@ const { pathToFileURL } = require('url');
2536
2601
  intentMode,
2537
2602
  executionScores,
2538
2603
  executionCandidates,
2604
+ signalText: `${routeSignals.promptNormalizedText || ''}\n${routeSignals.commandNormalizedText || ''}`,
2539
2605
  });
2540
2606
  const lastExplicitUserPromptText = promptText.trim()
2541
2607
  || previous?.routingContext?.lastExplicitUserPromptText
@@ -2792,7 +2858,14 @@ const { pathToFileURL } = require('url');
2792
2858
  NODE
2793
2859
 
2794
2860
  if [ -f "$THRESHOLD_SCRIPT" ]; then
2795
- printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true
2861
+ # The 'prompt' phase only bookkeeps prompt-bearing events — a tool payload has no
2862
+ # prompt text and that phase exits before doing anything. Every PreToolUse/
2863
+ # PostToolUse call used to pay a second full node spawn for that no-op, doubling the
2864
+ # per-mutation hook latency. Tool payloads always carry tool_name; skip them.
2865
+ case "$INPUT" in
2866
+ *'"tool_name"'*) ;;
2867
+ *) printf '%s' "$INPUT" | CLAUDE_PROJECT_DIR="$PROJECT_ROOT" node "$THRESHOLD_SCRIPT" prompt 2>/dev/null || true ;;
2868
+ esac
2796
2869
  fi
2797
2870
 
2798
2871
  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,
@@ -499,6 +499,21 @@ export function resetCompactPressureState(rawState = {}, config = {}) {
499
499
  }
500
500
 
501
501
  const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
502
+ // Hook processes are short-lived, so an in-memory cache would not stop every Edit/Write
503
+ // from reopening and parsing a 4MB transcript tail. Persist a very short probe window in
504
+ // the session pressure record instead: a burst of mutations incurs one scan, not one per
505
+ // call. PreCompact is still immediate; this is only the fallback for host auto-compactions
506
+ // that skip PreCompact, where a two-second detection delay is preferable to killing the
507
+ // 4s hook budget on a slow volume.
508
+ const TRANSCRIPT_BOUNDARY_PROBE_TTL_MS = 2_000;
509
+
510
+ function normalizeTranscriptProbe(value) {
511
+ if (!value || typeof value !== 'object' || typeof value.path !== 'string' || !value.path) return null;
512
+ const checkedAt = finiteNumber(value.checkedAt, 0);
513
+ return checkedAt > 0
514
+ ? { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0) }
515
+ : null;
516
+ }
502
517
 
503
518
  // Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
504
519
  // Every real compaction — manual /compact AND harness auto-compact — appends a
@@ -551,18 +566,46 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
551
566
  // history that was already summarised away and the hard-cap gate bricks a healthy
552
567
  // session. Callers must persist when reset=true, or the reset would re-fire (and
553
568
  // zero the counter) on every call.
554
- export async function syncCompactPressureStateWithTranscript(rawState = null, config = {}, transcriptPath = '') {
555
- const boundaryAt = await detectLatestCompactBoundaryAt(transcriptPath);
569
+ export async function syncCompactPressureStateWithTranscript(
570
+ rawState = null,
571
+ config = {},
572
+ transcriptPath = '',
573
+ { now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS } = {},
574
+ ) {
575
+ const normalizedPath = typeof transcriptPath === 'string' ? transcriptPath.trim() : '';
576
+ const previousProbe = normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe);
577
+ // A process-persistent, session-scoped cache avoids rereading/parsing up to 4MB for every
578
+ // mutation during a tool burst. It keys on the path (not just time) so a resumed/new
579
+ // transcript always scans immediately. The short TTL is deliberately only a performance
580
+ // coalescing window; PreCompact remains the immediate source of truth.
581
+ const probeFresh = previousProbe
582
+ && previousProbe.path === normalizedPath
583
+ && now - previousProbe.checkedAt >= 0
584
+ && now - previousProbe.checkedAt < probeTtlMs;
585
+ const boundaryAt = probeFresh
586
+ ? previousProbe.boundaryAt
587
+ : await detectLatestCompactBoundaryAt(normalizedPath);
588
+ const probe = probeFresh
589
+ ? previousProbe
590
+ : { path: normalizedPath, checkedAt: now, boundaryAt };
556
591
  const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
557
592
  if (!boundaryAt || boundaryAt <= accounted) {
558
- return { state: rawState, reset: false };
593
+ return {
594
+ state: rawState && typeof rawState === 'object'
595
+ ? { ...rawState, transcriptBoundaryProbe: probe }
596
+ : rawState,
597
+ reset: false,
598
+ probed: !probeFresh,
599
+ };
559
600
  }
560
601
  return {
561
602
  state: {
562
603
  ...resetCompactPressureState(rawState ?? {}, config),
563
604
  lastCompactBoundaryAt: boundaryAt,
605
+ transcriptBoundaryProbe: probe,
564
606
  },
565
607
  reset: true,
608
+ probed: !probeFresh,
566
609
  };
567
610
  }
568
611
 
@@ -795,6 +838,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
795
838
  taskMode,
796
839
  cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
797
840
  lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
841
+ transcriptBoundaryProbe: normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe),
798
842
  routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
799
843
  ? rawState.routingContext
800
844
  : {},
@@ -1260,7 +1304,9 @@ async function runCli() {
1260
1304
  sessionConfig,
1261
1305
  payload.transcript_path,
1262
1306
  );
1263
- if (synced.reset) {
1307
+ if (synced.reset || synced.probed) {
1308
+ // Persist a clean probe too: this CLI runs in a fresh process on each hook,
1309
+ // so retaining only resets would make every prompt rescan the same tail.
1264
1310
  await writeCompactPressureState(projectRoot, synced.state, sessionConfig);
1265
1311
  }
1266
1312
  } catch { /* advisory healing only — never block the prompt over it */ }