@ngockhoale/ukit 3.4.20 → 3.4.22

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.22 - 2026-10-07
6
+
7
+ **Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
8
+ - `.sh` hooks are now launched through Git for Windows bash: `UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` →
9
+ default Git install paths → absolute `PATH` entries. WSL's `bash.exe` and a cwd `bash.exe` are never used.
10
+ - On Windows a `.sh` hook whose bash cannot be found or started is reported as `spawn-unavailable` and warned
11
+ instead of denying every Edit/Write/Bash. POSIX behaviour is unchanged; timeouts, real blocks, missing gate
12
+ files and `block-dangerous.sh` stay fail-closed.
13
+ - `ukit doctor` flags Windows hook launch failures.
14
+ - `*.sh` is pinned to LF (repo `.gitattributes` and a shipped `.claude/.gitattributes`) so a CRLF checkout cannot
15
+ break hook scripts. Troubleshooting added to `docs/INSTALL.md`.
16
+
17
+ ## 3.4.21 - 2026-10-07
18
+
19
+ **Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
20
+ - Retention sweeps (tee cache, hook-latency telemetry, execution ledger, hook payloads, omp hook errors) only
21
+ looked at the first 128 directory entries, so old files past that window were never pruned and the
22
+ over-cap rule deleted newer files instead. The scan window now rotates.
23
+ - Chained `.sh` hooks no longer each spawn their own `node hook-telemetry.mjs --finish`; `hook-chain-runner.mjs`
24
+ writes the per-step rows (an Edit chain went from 11 node spawns to 5, same telemetry rows).
25
+ - `unic-decision` now honours its persisted circuit breaker (closed/open/half-open, fail-open): a rejected key
26
+ or dead endpoint no longer costs a failed call on every prompt.
27
+ - The context hard-cap gate probes the transcript incrementally instead of re-reading the last 4 MB and
28
+ rewriting `compact-pressure.json` on ~95% of calls; invalid persisted probe state falls back to a full scan.
29
+
5
30
  ## 3.4.20 - 2026-10-06
6
31
 
7
32
  **`ukit update` now displays the NEW version.** The running process is the old code, so the
@@ -707,6 +707,17 @@ entries:
707
707
  validation: [manual]
708
708
  archive_policy: never
709
709
  notes: non-.md entry — exists for FR-002 manifest consistency
710
+ - id: tpl-claude-gitattributes
711
+ path: template_project/.claude/.gitattributes
712
+ class: canonical
713
+ audience: [maintainer]
714
+ owner: product
715
+ source_of_truth: template_project/.claude/.gitattributes
716
+ merge_strategy: skip
717
+ load_policy: on-demand
718
+ validation: [manual]
719
+ archive_policy: never
720
+ notes: non-.md entry — keeps hook *.sh LF on Windows autocrlf checkouts; exists for FR-002 manifest consistency
710
721
 
711
722
  # ── repo mirrors (derived from template_project/) ───────────────────────────────
712
723
  - id: mirror-claude-dir
@@ -1511,6 +1511,19 @@ items:
1511
1511
  packs:
1512
1512
  - core
1513
1513
 
1514
+ # Ships `*.sh text eol=lf` inside .claude/ only (never the user's root .gitattributes) so
1515
+ # a Windows autocrlf checkout cannot turn hook scripts into CRLF that bash rejects.
1516
+ - id: claude-gitattributes
1517
+ type: config
1518
+ sourceTemplate: .claude/.gitattributes
1519
+ targetPath: .claude/.gitattributes
1520
+ requires: []
1521
+ mergeStrategy: skip
1522
+ variables: []
1523
+ enabledByDefault: true
1524
+ packs:
1525
+ - core
1526
+
1514
1527
  - id: hook-reset-compact-pressure
1515
1528
  type: hook
1516
1529
  sourceTemplate: .claude/hooks/reset-compact-pressure.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.20",
3
+ "version": "3.4.22",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,12 +24,31 @@ const FAILURE_OUTCOMES = new Set([
24
24
  'output-overflow',
25
25
  'error',
26
26
  'signal',
27
+ 'spawn-unavailable',
27
28
  ]);
28
29
 
29
30
  // The pre-O6 remedy, kept verbatim: a failure with no gate evidence must read
30
31
  // exactly as it did before escalation existed (SPEC §FR-012).
32
+ // SPEC §FR-007: launch failures only arise on win32 (the runner emits
33
+ // `spawn-unavailable` from its bash-launcher path), so there is one remedy.
34
+ const LAUNCH_REMEDY = 'Windows cannot execute .sh hooks directly — install Git for Windows so C:\\Program Files\\Git\\bin\\bash.exe exists, or set UKIT_BASH_BIN to the full path of bash.exe, then restart the session.';
35
+
36
+ // Legacy pre-fix win32 rows recorded a failed .sh launch as a plain `error`
37
+ // that finished almost instantly.
38
+ const LEGACY_LAUNCH_FAILURE_MAX_MS = 10;
39
+
31
40
  const BASE_REMEDY = 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.';
32
41
 
42
+ function isLaunchFailure(entry, platform) {
43
+ if (!entry || typeof entry !== 'object') return false;
44
+ if (typeof entry.scriptName !== 'string' || !entry.scriptName.toLowerCase().endsWith('.sh')) return false;
45
+ if (entry.failureKind === 'spawn-unavailable') return true;
46
+ return platform === 'win32'
47
+ && entry.failureKind === 'error'
48
+ && typeof entry.elapsedMs === 'number'
49
+ && entry.elapsedMs < LEGACY_LAUNCH_FAILURE_MAX_MS;
50
+ }
51
+
33
52
  function telemetryDirFor(projectRoot) {
34
53
  return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
35
54
  }
@@ -78,7 +97,7 @@ async function tailLines(filePath, size) {
78
97
  }
79
98
  }
80
99
 
81
- export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
100
+ export async function inspectHookChainHealth({ projectRoot, now = Date.now(), platform = process.platform } = {}) {
82
101
  const label = 'hook-chain health';
83
102
 
84
103
  // Never-fatal posture extends to malformed input: a missing projectRoot is
@@ -106,6 +125,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
106
125
  // evidence shapes that prove it, collected in the same pass that counts
107
126
  // outcomes so no second read of the rows is needed.
108
127
  const gateFailures = new Set();
128
+ const launchFailures = new Set();
109
129
  let budgetExhaustedChains = 0;
110
130
 
111
131
  for (const file of files) {
@@ -130,6 +150,14 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
130
150
  const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
131
151
  counts[outcome] = (counts[outcome] ?? 0) + 1;
132
152
 
153
+ // .sh launch failures are collected from EVERY row (an exit-code row can
154
+ // still carry one); they are a different signal from a gate that ran and failed.
155
+ if (Array.isArray(row.scripts)) {
156
+ for (const entry of row.scripts) {
157
+ if (isLaunchFailure(entry, platform)) launchFailures.add(entry.scriptName);
158
+ }
159
+ }
160
+
133
161
  // Escalation evidence is only ever read off a FAILING row — a gate that
134
162
  // ran fine is not evidence of anything, and SPEC §FR-012 scopes the
135
163
  // budget signal to a non-ok row ("a non-ok row ... escalates only when
@@ -155,6 +183,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
155
183
  if (!entry || typeof entry !== 'object') continue;
156
184
  if (entry.failClosed !== true) continue;
157
185
  if (typeof entry.failureKind !== 'string' || entry.failureKind === 'ok') continue;
186
+ if (entry.failureKind === 'spawn-unavailable') continue; // reported as a launch failure
158
187
  gateFailures.add(typeof entry.scriptName === 'string' ? entry.scriptName : 'unknown');
159
188
  }
160
189
  }
@@ -177,7 +206,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
177
206
  .filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
178
207
  .sort();
179
208
 
180
- if (failureKinds.length === 0) {
209
+ if (failureKinds.length === 0 && launchFailures.size === 0) {
181
210
  return {
182
211
  label,
183
212
  passed: true,
@@ -190,7 +219,9 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
190
219
  }
191
220
 
192
221
  const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
193
- const baseDetail = `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`;
222
+ const baseDetail = failureKinds.length > 0
223
+ ? `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`
224
+ : `${counts.rowsScanned} chain row(s) scanned`;
194
225
 
195
226
  // O6 (SPEC §FR-012): escalate only when a failure actually landed ON a gate —
196
227
  // a gate that merely ran is not evidence. The escalation is purely additive:
@@ -207,6 +238,11 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
207
238
  `A fail-closed gate actually failed (${gateList.join(', ')}) — that is a real budget or path problem, not a slow advisory hook; widen the failing gate's budget or fix that step.`,
208
239
  );
209
240
  }
241
+ const launchList = [...launchFailures].sort();
242
+ if (launchList.length > 0) {
243
+ escalations.push(`.sh hook(s) could not be launched: ${launchList.join(', ')}`);
244
+ advices.push(LAUNCH_REMEDY);
245
+ }
210
246
  if (budgetExhaustedChains > 0) {
211
247
  escalations.push(`${budgetExhaustedChains} chain row(s) exhausted the total budget`);
212
248
  advices.push(
@@ -386,6 +386,7 @@ export async function sweepTeeCache(dir, {
386
386
  maxFiles = TEE_MAX_FILES,
387
387
  maxEntries = TEE_SWEEP_MAX_ENTRIES,
388
388
  maxRemovals = TEE_SWEEP_MAX_REMOVALS,
389
+ random = Math.random,
389
390
  } = {}) {
390
391
  let dirents;
391
392
  try {
@@ -404,8 +405,17 @@ export async function sweepTeeCache(dir, {
404
405
  const cutoff = now() - maxAgeMs;
405
406
  const entries = [];
406
407
  let scanned = 0;
407
- for (const name of eligibleNames) {
408
- if (scanned >= maxEntries) break;
408
+ // Circular window at a random start so expired files beyond the first
409
+ // maxEntries readdir names are still reached (BUG-1: the head slice never
410
+ // age-pruned the tail). Non-finite random() falls back to start 0.
411
+ const L = eligibleNames.length;
412
+ const r = random();
413
+ const start = L > maxEntries
414
+ ? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
415
+ : 0;
416
+ const windowSize = Math.min(maxEntries, L);
417
+ for (let i = 0; i < windowSize; i += 1) {
418
+ const name = eligibleNames[(start + i) % L];
409
419
  scanned += 1;
410
420
  try {
411
421
  const stats = await fs.stat(path.join(dir, name));
@@ -439,7 +449,7 @@ export async function maybeSweepTeeCache(dir, {
439
449
  ? Math.min(1, Math.max(0, probability))
440
450
  : teeSweepProbabilityFromEnv();
441
451
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
442
- return sweepTeeCache(dir, options);
452
+ return sweepTeeCache(dir, { ...options, random });
443
453
  }
444
454
 
445
455
  async function persistRawOutput(projectRoot, {
@@ -0,0 +1,2 @@
1
+ # Hook scripts must stay LF: a CRLF checkout makes bash fail on "\r".
2
+ *.sh text eol=lf
@@ -1771,7 +1771,18 @@ export async function runRouteDecisionShadow({
1771
1771
 
1772
1772
  let result = null;
1773
1773
  const startedAt = now();
1774
+ // FR-004: skip the CLI spawn while the persisted decision circuit is open
1775
+ // (half-open does not skip — the CLI claims the probe). Any failure = closed.
1776
+ let circuitOpen = false;
1774
1777
  try {
1778
+ const { isDecisionCircuitOpen } = await import('./unic-decision.mjs');
1779
+ circuitOpen = isDecisionCircuitOpen(rootDir, { config, now }) === true;
1780
+ } catch {
1781
+ circuitOpen = false;
1782
+ }
1783
+ if (circuitOpen) {
1784
+ result = { status: 'unavailable', fallbackCode: 'circuit-open' };
1785
+ } else try {
1775
1786
  const spawned = spawnImpl(
1776
1787
  process.execPath,
1777
1788
  [cliPath, '--root', rootDir],
@@ -716,6 +716,99 @@ function outcome(status, extra = {}) {
716
716
  return { status, finishReason: null, answers: [], ...extra };
717
717
  }
718
718
 
719
+ // --- Persisted circuit breaker (SPEC FR-004 / BUG-4) -----------------------
720
+ // The hook path spawns a fresh process per prompt, so an in-memory breaker is
721
+ // useless. State lives in <projectRoot>/.ukit/storage/cache/decision-circuit.json.
722
+ // Advisory + best-effort: every read/parse/write error degrades to "closed".
723
+ const CIRCUIT_DEFAULTS = { failureThreshold: 3, cooldownMs: 30000 };
724
+
725
+ function circuitFile(projectRoot) {
726
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'decision-circuit.json');
727
+ }
728
+
729
+ function circuitConfig(config) {
730
+ const raw = decisionPlaneConfig(config).circuitBreaker;
731
+ const pick = (value, fallback) =>
732
+ Number.isFinite(value) && value >= 1 ? value : fallback;
733
+ return {
734
+ failureThreshold: pick(raw?.failureThreshold, CIRCUIT_DEFAULTS.failureThreshold),
735
+ cooldownMs: pick(raw?.cooldownMs, CIRCUIT_DEFAULTS.cooldownMs),
736
+ };
737
+ }
738
+
739
+ function writeCircuitState(projectRoot, next, now) {
740
+ const file = circuitFile(projectRoot);
741
+ const tmp = `${file}.${process.pid}.tmp`;
742
+ try {
743
+ fs.mkdirSync(path.dirname(file), { recursive: true });
744
+ fs.writeFileSync(
745
+ tmp,
746
+ JSON.stringify({
747
+ schemaVersion: 1,
748
+ consecutiveFailures: next.consecutiveFailures,
749
+ openedAt: next.openedAt,
750
+ lastFallbackCode: next.lastFallbackCode,
751
+ updatedAt: now,
752
+ }),
753
+ 'utf8',
754
+ );
755
+ fs.renameSync(tmp, file);
756
+ } catch {
757
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort */ }
758
+ }
759
+ }
760
+
761
+ export function readDecisionCircuit(projectRoot, { config = null, now = () => Date.now() } = {}) {
762
+ const closed = { state: 'closed', consecutiveFailures: 0, openedAt: null, lastFallbackCode: null };
763
+ if (typeof projectRoot !== 'string' || projectRoot === '') return closed;
764
+ try {
765
+ const raw = JSON.parse(fs.readFileSync(circuitFile(projectRoot), 'utf8'));
766
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return closed;
767
+ const consecutiveFailures =
768
+ Number.isInteger(raw.consecutiveFailures) && raw.consecutiveFailures >= 0
769
+ ? raw.consecutiveFailures
770
+ : 0;
771
+ const lastFallbackCode =
772
+ typeof raw.lastFallbackCode === 'string' ? raw.lastFallbackCode : null;
773
+ const base = { consecutiveFailures, openedAt: null, lastFallbackCode };
774
+ if (!Number.isFinite(raw.openedAt)) return { state: 'closed', ...base };
775
+ // Negative delta = clock moved backwards / future stamp: never pin open.
776
+ const delta = now() - raw.openedAt;
777
+ if (!(delta >= 0)) return { state: 'closed', ...base };
778
+ const state = delta < circuitConfig(config).cooldownMs ? 'open' : 'half-open';
779
+ return { state, ...base, openedAt: raw.openedAt };
780
+ } catch {
781
+ return closed;
782
+ }
783
+ }
784
+
785
+ export function isDecisionCircuitOpen(projectRoot, opts = {}) {
786
+ return readDecisionCircuit(projectRoot, opts).state === 'open';
787
+ }
788
+
789
+ function recordCircuitOutcome(projectRoot, prior, kind, { config, now, fallbackCode = null }) {
790
+ if (typeof projectRoot !== 'string' || projectRoot === '') return;
791
+ try {
792
+ const at = now();
793
+ if (kind === 'success') {
794
+ if (prior.consecutiveFailures === 0 && prior.openedAt === null) return;
795
+ writeCircuitState(projectRoot, {
796
+ consecutiveFailures: 0, openedAt: null, lastFallbackCode: null,
797
+ }, at);
798
+ return;
799
+ }
800
+ const consecutiveFailures = prior.consecutiveFailures + 1;
801
+ const reopen =
802
+ prior.state === 'half-open' ||
803
+ consecutiveFailures >= circuitConfig(config).failureThreshold;
804
+ writeCircuitState(projectRoot, {
805
+ consecutiveFailures,
806
+ openedAt: reopen ? at : null,
807
+ lastFallbackCode: fallbackCode,
808
+ }, at);
809
+ } catch { /* advisory */ }
810
+ }
811
+
719
812
  /**
720
813
  * Send one C13 batch over the configured gateway. `batch` =
721
814
  * {batchId?, boundary?, statePacket, questions, deadlineMs?}. Returns a typed
@@ -798,6 +891,24 @@ export async function requestBatch(batch, {
798
891
  detail: error?.message ?? String(error),
799
892
  });
800
893
  }
894
+ // Persisted breaker (FR-004): consulted only after all local validation, so
895
+ // those outcomes keep their order and never touch the state file.
896
+ const breakerOn = typeof projectRoot === 'string' && projectRoot !== '';
897
+ const circuit = breakerOn
898
+ ? readDecisionCircuit(projectRoot, { config, now })
899
+ : null;
900
+ if (circuit?.state === 'open') {
901
+ return outcome('unavailable', {
902
+ batchId: batch?.batchId ?? null,
903
+ checkpoint,
904
+ fallbackCode: 'circuit-open',
905
+ circuitCause: circuit.lastFallbackCode,
906
+ });
907
+ }
908
+ if (circuit?.state === 'half-open') {
909
+ // Claim the probe: concurrent processes now see 'open' until the cooldown.
910
+ writeCircuitState(projectRoot, { ...circuit, openedAt: now() }, now());
911
+ }
801
912
  let fingerprint = requestFingerprint(body);
802
913
  const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
803
914
  const deadlineMs = Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : timeoutMs;
@@ -889,6 +1000,7 @@ export async function requestBatch(batch, {
889
1000
  }
890
1001
 
891
1002
  const result = parseBatchResponse(parsed, questions);
1003
+ if (breakerOn) recordCircuitOutcome(projectRoot, circuit, 'success', { config, now });
892
1004
  return {
893
1005
  batchId: batch?.batchId ?? null,
894
1006
  checkpoint,
@@ -900,11 +1012,18 @@ export async function requestBatch(batch, {
900
1012
  };
901
1013
  }
902
1014
 
903
- return outcome(lastError ?? 'unavailable', {
1015
+ const finalStatus = lastError ?? 'unavailable';
1016
+ const finalCode =
1017
+ lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error');
1018
+ if (breakerOn && (finalStatus === 'timeout' || finalStatus === 'unavailable')) {
1019
+ recordCircuitOutcome(projectRoot, circuit, 'failure', {
1020
+ config, now, fallbackCode: finalCode,
1021
+ });
1022
+ }
1023
+ return outcome(finalStatus, {
904
1024
  batchId: batch?.batchId ?? null,
905
1025
  checkpoint,
906
- fallbackCode:
907
- lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error'),
1026
+ fallbackCode: finalCode,
908
1027
  requestFingerprint: fingerprint,
909
1028
  });
910
1029
  }
@@ -557,12 +557,83 @@ const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
557
557
  // 4s hook budget on a slow volume.
558
558
  const TRANSCRIPT_BOUNDARY_PROBE_TTL_MS = 2_000;
559
559
 
560
+ // Persist a probe update only after this many unpersisted bytes were scanned: transcripts grow
561
+ // on every tool call, but rewriting compact-pressure.json (70-110KB) each time is the cost
562
+ // the incremental probe exists to avoid.
563
+ const PROBE_PERSIST_DELTA_BYTES = 256 * 1024;
564
+
560
565
  function normalizeTranscriptProbe(value) {
561
566
  if (!value || typeof value !== 'object' || typeof value.path !== 'string' || !value.path) return null;
562
567
  const checkedAt = finiteNumber(value.checkedAt, 0);
563
- return checkedAt > 0
564
- ? { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0) }
565
- : null;
568
+ if (!(checkedAt > 0)) return null;
569
+ // Only a non-negative safe integer is a usable byte offset; anything else (fractional, NaN,
570
+ // negative, string) is treated as 'no scannedSize' so the next probe does one full scan.
571
+ const scannedSize = Number.isSafeInteger(value.scannedSize) && value.scannedSize >= 0 ? value.scannedSize : null;
572
+ return { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0), scannedSize };
573
+ }
574
+
575
+ function boundaryAtOfLine(line) {
576
+ if (!line.includes('compact_boundary')) return 0;
577
+ try {
578
+ const entry = JSON.parse(line);
579
+ if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
580
+ const at = Date.parse(entry.timestamp ?? '');
581
+ if (Number.isFinite(at)) return at;
582
+ }
583
+ } catch { /* not a usable boundary line */ }
584
+ return 0;
585
+ }
586
+
587
+ // Scan `[max(fromOffset, size - tailBytes), size)` of the transcript for the newest
588
+ // compact_boundary (epoch ms, 0 if none). A leading partial line is dropped only when the
589
+ // read starts mid-line. `scannedSize` is the offset just past the last `\n` read, so a
590
+ // trailing line still being written is rescanned once it is complete and a delta scan always
591
+ // starts at a line boundary. A fromOffset beyond EOF (truncation/rotation) scans the tail.
592
+ // Throws on stat/open/read failure; callers decide the fallback.
593
+ export async function scanCompactBoundary(transcriptPath, { tailBytes = BOUNDARY_TAIL_BYTES, fromOffset = 0 } = {}) {
594
+ const stat = await fs.stat(transcriptPath);
595
+ const size = stat.size;
596
+ if (!stat.isFile() || size === 0) {
597
+ return { boundaryAt: 0, scannedSize: 0, size };
598
+ }
599
+ const requested = Number.isSafeInteger(fromOffset) && fromOffset >= 0 && fromOffset <= size ? fromOffset : 0;
600
+ const start = Math.max(requested, size - tailBytes);
601
+ // One extra leading byte tells whether `start` sits on a line boundary.
602
+ const readStart = start > 0 ? start - 1 : 0;
603
+ const length = size - readStart;
604
+ const buffer = Buffer.allocUnsafe(length);
605
+ const handle = await fs.open(transcriptPath, 'r');
606
+ let bytesRead;
607
+ try {
608
+ ({ bytesRead } = await handle.read(buffer, 0, length, readStart));
609
+ } finally {
610
+ await handle.close();
611
+ }
612
+ const data = buffer.subarray(0, bytesRead);
613
+ // A delta scan must start on a line boundary (scannedSize sits just past a `\n`). If the
614
+ // byte before it is not `\n`, the file was replaced at the same path: rescan from the top.
615
+ if (requested > 0 && start === requested && data.length > 0 && data[0] !== 0x0a) {
616
+ return scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 });
617
+ }
618
+ let begin = 0;
619
+ let leadingPartialOnly = false;
620
+ if (start > 0 && data.length > 0 && data[0] !== 0x0a) {
621
+ const nl = data.indexOf(0x0a, 1);
622
+ if (nl === -1) { begin = data.length; leadingPartialOnly = true; } else { begin = nl + 1; }
623
+ } else if (start > 0) {
624
+ begin = 1;
625
+ }
626
+ const region = data.subarray(begin);
627
+ const lastNl = region.lastIndexOf(0x0a);
628
+ const scannedSize = lastNl >= 0
629
+ ? readStart + begin + lastNl + 1
630
+ : (leadingPartialOnly ? start : readStart + begin);
631
+ const lines = region.toString('utf8').split('\n');
632
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
633
+ const at = boundaryAtOfLine(lines[i]);
634
+ if (at) return { boundaryAt: at, scannedSize, size };
635
+ }
636
+ return { boundaryAt: 0, scannedSize, size };
566
637
  }
567
638
 
568
639
  // Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
@@ -576,35 +647,7 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
576
647
  return 0;
577
648
  }
578
649
  try {
579
- const stat = await fs.stat(transcriptPath);
580
- if (!stat.isFile() || stat.size === 0) {
581
- return 0;
582
- }
583
- const start = Math.max(0, stat.size - tailBytes);
584
- const length = stat.size - start;
585
- const buffer = Buffer.allocUnsafe(length);
586
- const handle = await fs.open(transcriptPath, 'r');
587
- let text;
588
- try {
589
- const { bytesRead } = await handle.read(buffer, 0, length, start);
590
- text = buffer.toString('utf8', 0, bytesRead);
591
- } finally {
592
- await handle.close();
593
- }
594
- const lines = text.split('\n');
595
- if (start > 0) lines.shift(); // a tail read can split the first line
596
- for (let i = lines.length - 1; i >= 0; i -= 1) {
597
- const line = lines[i];
598
- if (!line.includes('compact_boundary')) continue;
599
- try {
600
- const entry = JSON.parse(line);
601
- if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
602
- const at = Date.parse(entry.timestamp ?? '');
603
- if (Number.isFinite(at)) return at;
604
- }
605
- } catch { /* not a usable boundary line */ }
606
- }
607
- return 0;
650
+ return (await scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 })).boundaryAt;
608
651
  } catch {
609
652
  return 0;
610
653
  }
@@ -616,28 +659,89 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
616
659
  // history that was already summarised away and the hard-cap gate bricks a healthy
617
660
  // session. Callers must persist when reset=true, or the reset would re-fire (and
618
661
  // zero the counter) on every call.
662
+ //
663
+ // The probe is incremental: it remembers how many bytes were already scanned
664
+ // (`scannedSize`), so an unchanged transcript costs one stat and a grown one only reads the
665
+ // delta. `probed: true` tells the caller to persist; it is raised only on a meaningful change
666
+ // (new path, new boundary, full rescan, or >= persistDeltaBytes unpersisted bytes scanned).
619
667
  export async function syncCompactPressureStateWithTranscript(
620
668
  rawState = null,
621
669
  config = {},
622
670
  transcriptPath = '',
623
- { now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS } = {},
671
+ { now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS, persistDeltaBytes = PROBE_PERSIST_DELTA_BYTES } = {},
624
672
  ) {
625
673
  const normalizedPath = typeof transcriptPath === 'string' ? transcriptPath.trim() : '';
626
674
  const previousProbe = normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe);
627
- // A process-persistent, session-scoped cache avoids rereading/parsing up to 4MB for every
675
+ const samePath = Boolean(previousProbe) && previousProbe.path === normalizedPath;
676
+ // A process-persistent, session-scoped cache avoids rereading/parsing the transcript for every
628
677
  // mutation during a tool burst. It keys on the path (not just time) so a resumed/new
629
678
  // transcript always scans immediately. The short TTL is deliberately only a performance
630
679
  // coalescing window; PreCompact remains the immediate source of truth.
631
- const probeFresh = previousProbe
632
- && previousProbe.path === normalizedPath
680
+ const probeFresh = samePath
633
681
  && now - previousProbe.checkedAt >= 0
634
682
  && now - previousProbe.checkedAt < probeTtlMs;
635
- const boundaryAt = probeFresh
636
- ? previousProbe.boundaryAt
637
- : await detectLatestCompactBoundaryAt(normalizedPath);
638
- const probe = probeFresh
639
- ? previousProbe
640
- : { path: normalizedPath, checkedAt: now, boundaryAt };
683
+
684
+ let boundaryAt;
685
+ let probe;
686
+ let probed;
687
+ if (probeFresh) {
688
+ boundaryAt = previousProbe.boundaryAt;
689
+ probe = previousProbe;
690
+ probed = false;
691
+ } else {
692
+ // `persistedScannedSize` is the scannedSize loaded from disk via rawState: the gate only
693
+ // persists when probed is true, so this bounds the delta a later process must re-read.
694
+ const persistedScannedSize = samePath ? previousProbe.scannedSize : null;
695
+ const fullScan = async () => {
696
+ const scan = await scanCompactBoundary(normalizedPath, { fromOffset: 0 });
697
+ return {
698
+ boundaryAt: scan.boundaryAt,
699
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: scan.boundaryAt, scannedSize: scan.scannedSize },
700
+ probed: true,
701
+ };
702
+ };
703
+ const incrementalScan = async () => {
704
+ const { size } = await fs.stat(normalizedPath);
705
+ if (size === persistedScannedSize) {
706
+ // Nothing new since the persisted probe: reuse, no read.
707
+ const reused = previousProbe.boundaryAt;
708
+ return {
709
+ boundaryAt: reused,
710
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: reused, scannedSize: persistedScannedSize },
711
+ probed: false,
712
+ };
713
+ }
714
+ if (size < persistedScannedSize) return fullScan(); // truncation/rotation
715
+ const scan = await scanCompactBoundary(normalizedPath, { fromOffset: persistedScannedSize });
716
+ const merged = scan.boundaryAt || previousProbe.boundaryAt;
717
+ return {
718
+ boundaryAt: merged,
719
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: merged, scannedSize: scan.scannedSize },
720
+ probed: merged !== previousProbe.boundaryAt
721
+ || scan.scannedSize - persistedScannedSize >= persistDeltaBytes,
722
+ };
723
+ };
724
+ let result = null;
725
+ if (persistedScannedSize !== null) {
726
+ // Any incremental failure retries one full scan, so the gate decides exactly as a full
727
+ // re-read would; only if that fails too do we fall back to the previous boundary.
728
+ try { result = await incrementalScan(); } catch { result = null; }
729
+ }
730
+ if (!result) {
731
+ try {
732
+ result = await fullScan();
733
+ } catch {
734
+ // stat/open/read failure: keep the previous boundary for the same path, else 0.
735
+ const kept = samePath ? previousProbe.boundaryAt : 0;
736
+ result = {
737
+ boundaryAt: kept,
738
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: kept, scannedSize: persistedScannedSize },
739
+ probed: !samePath,
740
+ };
741
+ }
742
+ }
743
+ ({ boundaryAt, probe, probed } = result);
744
+ }
641
745
  const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
642
746
  if (!boundaryAt || boundaryAt <= accounted) {
643
747
  return {
@@ -645,7 +749,7 @@ export async function syncCompactPressureStateWithTranscript(
645
749
  ? { ...rawState, transcriptBoundaryProbe: probe }
646
750
  : rawState,
647
751
  reset: false,
648
- probed: !probeFresh,
752
+ probed,
649
753
  };
650
754
  }
651
755
  return {
@@ -655,7 +759,7 @@ export async function syncCompactPressureStateWithTranscript(
655
759
  transcriptBoundaryProbe: probe,
656
760
  },
657
761
  reset: true,
658
- probed: !probeFresh,
762
+ probed,
659
763
  };
660
764
  }
661
765