@ask-llm/plugin 0.18.0 → 0.19.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.
@@ -0,0 +1,720 @@
1
+ // Durable hook state: cache, log, pause sentinel, inflight lock (extracted
2
+ // from codex-pair-watch.mjs per ADR-088, originally ADR-079/082/085/086/087).
3
+ //
4
+ // ADR-092: all hook state nests under <markerDir>/.codex-pair/:
5
+ // .codex-pair/context.md — marker + project context
6
+ // .codex-pair/log.jsonl — durable verdicts log
7
+ // .codex-pair/ignore — gitignore-style globs (ADR-081)
8
+ // .codex-pair/cache/ — content-hash response cache (ADR-082)
9
+ // .codex-pair/state/paused — pause sentinel (ADR-085)
10
+ // .codex-pair/state/inflight/ — per-file locks (ADR-087)
11
+ //
12
+ // Atomic-write semantics per ADR-086/091: cache writes use tmp+rename;
13
+ // log entries are clamped under PIPE_BUF for atomic appendFile O_APPEND;
14
+ // log rotation uses a PID-scoped tmp; inflight-lock recovery uses an
15
+ // identity-snapshot recheck.
16
+
17
+ import { createHash, randomUUID } from "node:crypto";
18
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
19
+ import { appendFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ // ADR-092 unified layout — everything lives under PAIR_ROOT_DIR.
24
+ export const PAIR_ROOT_DIR = ".codex-pair";
25
+ export const CONTEXT_FILENAME = "context.md";
26
+ export const IGNORE_FILENAME = "ignore";
27
+ export const LOG_FILENAME = "log.jsonl";
28
+ export const MAX_LOG_BYTES = Number(process.env.CODEX_PAIR_MAX_LOG_BYTES ?? 2_000_000);
29
+ export const MAX_LOG_ENTRIES = 1000;
30
+ export const MAX_LOG_REASON_BYTES = 3500;
31
+
32
+ export const CACHE_DIR = "cache";
33
+ export const CACHE_TTL_MS = 10 * 60 * 1000;
34
+ export const CACHE_MAX_ENTRIES = 50;
35
+
36
+ export const STATE_DIR = "state";
37
+ export const PAUSE_SENTINEL_FILE = "paused";
38
+
39
+ export const INFLIGHT_DIR = "inflight";
40
+ export const INFLIGHT_TTL_MIN_MS = 600_000;
41
+
42
+ // ADR-096: codex-pair UX improvements.
43
+ // `.codex-pair/include` (optional inclusion-list, mirror of `.codex-pair/ignore`):
44
+ // when present + non-empty, ONLY files matching at least one glob are
45
+ // reviewed. Lets users scope codex-pair to high-stakes paths (e.g.
46
+ // src/billing/**) and avoid paying $0.05/edit on routine refactor code.
47
+ // Applied BEFORE the existing ignore-list — include-list narrows; ignore
48
+ // excludes from the narrowed set.
49
+ // `.codex-pair/state/repetitions.json` (repetition-detector state):
50
+ // tracks { file, contentHash } → consecutive-flag count. When a concern
51
+ // reaches REPETITION_BLOCKING_THRESHOLD without being fixed, the hook
52
+ // prefixes the systemMessage with a loud BLOCKING marker so the
53
+ // consumer (Claude or human) can't ignore it again silently.
54
+ export const INCLUDE_FILENAME = "include";
55
+ // ADR-097 (ADR-096 hotfix): repetitions sharded per-file at
56
+ // `.codex-pair/state/repetitions/<sha256(file)[0:16]>.json` to eliminate
57
+ // the cross-file TOCTOU race that both /multi-review reviewers caught
58
+ // on ADR-096 (Gemini conf 95, Codex conf 88). Each shard's read-modify-
59
+ // write cycle is naturally serialized by ADR-087's per-file inflight
60
+ // lock; cross-file edits no longer share a serialization root.
61
+ export const REPETITIONS_FILENAME = "repetitions.json"; // legacy singleton (v1) — unused; left for reference
62
+ export const REPETITIONS_SHARDS_DIR = "repetitions";
63
+ export const REPETITION_BLOCKING_THRESHOLD = 3;
64
+ export const REPETITIONS_SHARD_SCHEMA_VERSION = 2;
65
+ // 30-day TTL on shard files. Sweep runs probabilistically on update
66
+ // (5% per call) so abandoned files don't accumulate state forever.
67
+ export const REPETITIONS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
68
+
69
+ // Path resolvers — single source of truth for every state-file location.
70
+ // The hook never hard-codes these strings; it routes through these helpers.
71
+ export const pairRoot = (markerDir) => join(markerDir, PAIR_ROOT_DIR);
72
+ export const contextPath = (markerDir) => join(pairRoot(markerDir), CONTEXT_FILENAME);
73
+ export const ignorePath = (markerDir) => join(pairRoot(markerDir), IGNORE_FILENAME);
74
+ export const logPath = (markerDir) => join(pairRoot(markerDir), LOG_FILENAME);
75
+ export const cacheRoot = (markerDir) => join(pairRoot(markerDir), CACHE_DIR);
76
+ export const stateRoot = (markerDir) => join(pairRoot(markerDir), STATE_DIR);
77
+ export const pausePath = (markerDir) => join(stateRoot(markerDir), PAUSE_SENTINEL_FILE);
78
+ export const inflightRoot = (markerDir) => join(stateRoot(markerDir), INFLIGHT_DIR);
79
+
80
+ // `acks.json` is the legacy singleton. New writes are one shard per concern
81
+ // hash, avoiding the singleton's cross-process read-modify-write race. Readers
82
+ // merge both layouts so existing acknowledgements survive package updates.
83
+ export const ACKS_FILENAME = "acks.json";
84
+ export const ACKS_DIR = "acks";
85
+ export const acksPath = (markerDir) => join(stateRoot(markerDir), ACKS_FILENAME);
86
+ export const acksRoot = (markerDir) => join(stateRoot(markerDir), ACKS_DIR);
87
+ export function ackShardPath(markerDir, hash) {
88
+ const key = createHash("sha256").update(String(hash)).digest("hex");
89
+ return join(acksRoot(markerDir), `${key}.json`);
90
+ }
91
+
92
+ export function readAcks(markerDir) {
93
+ let acks = {};
94
+ try {
95
+ const legacy = JSON.parse(readFileSync(acksPath(markerDir), "utf8"));
96
+ if (legacy && typeof legacy === "object" && !Array.isArray(legacy)) acks = legacy;
97
+ } catch {
98
+ // missing/corrupt legacy state → continue with shards
99
+ }
100
+
101
+ let shards = [];
102
+ try {
103
+ shards = readdirSync(acksRoot(markerDir)).filter((name) => name.endsWith(".json"));
104
+ } catch {
105
+ // missing shard directory → legacy state (or no acks) is sufficient
106
+ }
107
+ for (const shard of shards) {
108
+ try {
109
+ const { hash, reason, ts } = JSON.parse(readFileSync(join(acksRoot(markerDir), shard), "utf8"));
110
+ if (typeof hash === "string" && typeof reason === "string" && typeof ts === "string") {
111
+ acks[hash] = { reason, ts };
112
+ }
113
+ } catch {
114
+ // A corrupt shard must not make unrelated acknowledgements disappear.
115
+ }
116
+ }
117
+ return acks;
118
+ }
119
+
120
+ // Persist one independent ack shard. A unique temporary name plus rename makes
121
+ // each write kill-safe; unrelated concurrent ack commands never share a file.
122
+ export function addAck(markerDir, hash, { reason }) {
123
+ const value = { hash, reason, ts: new Date().toISOString() };
124
+ mkdirSync(acksRoot(markerDir), { recursive: true });
125
+ const path = ackShardPath(markerDir, hash);
126
+ const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
127
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
128
+ renameSync(tmp, path);
129
+ }
130
+ // ADR-096: include-list + repetitions resolvers
131
+ export const includePath = (markerDir) => join(pairRoot(markerDir), INCLUDE_FILENAME);
132
+ // Legacy v1 singleton path — kept for the one-time cleanup of pre-hotfix
133
+ // shard files. Production code uses repetitionsShardPath() (v2 sharded).
134
+ export const repetitionsPath = (markerDir) => join(stateRoot(markerDir), REPETITIONS_FILENAME);
135
+ // ADR-097: per-file shard layout.
136
+ export const repetitionsShardsRoot = (markerDir) => join(stateRoot(markerDir), REPETITIONS_SHARDS_DIR);
137
+ export function repetitionsShardPath(markerDir, file) {
138
+ const hash = createHash("sha256").update(String(file)).digest("hex").slice(0, 16);
139
+ return join(repetitionsShardsRoot(markerDir), `${hash}.json`);
140
+ }
141
+
142
+ // ── Pause sentinel (ADR-085, paths consolidated per ADR-092) ─────────────
143
+ export function isPaused(markerDir) {
144
+ try {
145
+ statSync(pausePath(markerDir));
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ // ── Auto-pause (#176 / ADR-120, expiry added 2026-07-02) ─────────────────
153
+ // The hook can pause ITSELF: on provider quota exhaustion, or after
154
+ // AUTOPAUSE_FAILURE_THRESHOLD consecutive review failures of any kind.
155
+ // Same sentinel file as the manual /codex-pair-pause skill — an EMPTY file
156
+ // is a manual pause; a JSON body is an auto-pause with provenance.
157
+ // Manual pauses only ever resume manually (/codex-pair-resume or rm).
158
+ // AUTO-pauses self-heal: resolveAutoResume() expires them by TTL (quota:
159
+ // CODEX_PAIR_QUOTA_PAUSE_TTL_MS, failures: CODEX_PAIR_FAILURES_PAUSE_TTL_MS)
160
+ // or, for failures-kind, immediately when the plugin version changed since
161
+ // the pause was written (an update plausibly fixed the failing code — the
162
+ // exact scenario that left the dogfood repo silently dead for 18 days).
163
+
164
+ export const FAILURES_FILENAME = "failures.json";
165
+ export const AUTOPAUSE_FAILURE_THRESHOLD = 3;
166
+ export const failuresPath = (markerDir) => join(stateRoot(markerDir), FAILURES_FILENAME);
167
+
168
+ // Returns null (not paused), { manual: true } (empty or unrecognized body),
169
+ // or the parsed auto-pause JSON ({ v, kind, reason, resetHint?, at }).
170
+ // Unrecognized bodies are treated as manual — the conservative read: an
171
+ // unknown pause never auto-expires and never gets overwritten. A string
172
+ // reason is required too, so downstream provenance rendering never sees
173
+ // a non-string ("[object Object]") reason.
174
+ export function readPauseInfo(markerDir) {
175
+ let raw;
176
+ try {
177
+ raw = readFileSync(pausePath(markerDir), "utf8");
178
+ } catch {
179
+ return null;
180
+ }
181
+ const trimmed = raw.trim();
182
+ if (trimmed.length === 0) return { manual: true };
183
+ try {
184
+ const parsed = JSON.parse(trimmed);
185
+ if (
186
+ parsed &&
187
+ typeof parsed === "object" &&
188
+ (parsed.kind === "quota" || parsed.kind === "failures") &&
189
+ typeof parsed.reason === "string"
190
+ ) {
191
+ return parsed;
192
+ }
193
+ } catch {
194
+ // fall through to manual
195
+ }
196
+ return { manual: true };
197
+ }
198
+
199
+ // Best-effort read of the plugin's own package.json version, cached per
200
+ // process. Used to stamp auto-pause sentinels so a later plugin update can
201
+ // expire a failures-kind pause immediately. Returns null when unreadable
202
+ // (the marketplace git-subdir install ships package.json, but stay tolerant).
203
+ let _cachedPluginVersion;
204
+ export function readPluginVersion() {
205
+ if (_cachedPluginVersion !== undefined) return _cachedPluginVersion;
206
+ try {
207
+ const here = dirname(fileURLToPath(import.meta.url));
208
+ const manifest = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8"));
209
+ _cachedPluginVersion = typeof manifest?.version === "string" ? manifest.version : null;
210
+ } catch {
211
+ _cachedPluginVersion = null;
212
+ }
213
+ return _cachedPluginVersion;
214
+ }
215
+
216
+ export const QUOTA_PAUSE_TTL_MS = Number(process.env.CODEX_PAIR_QUOTA_PAUSE_TTL_MS ?? 6 * 3_600_000);
217
+ export const FAILURES_PAUSE_TTL_MS = Number(process.env.CODEX_PAIR_FAILURES_PAUSE_TTL_MS ?? 24 * 3_600_000);
218
+
219
+ // Pure decision: should an existing pause self-heal now? Manual pauses never
220
+ // do. Auto-pauses expire by TTL from their `at` stamp; failures-kind also
221
+ // expires on plugin-version change. A missing/unparseable `at` counts as
222
+ // expired — liveness-biased, because the manual pause is the reliable
223
+ // off-switch and a corrupt auto-sentinel must not kill pairing forever.
224
+ export function resolveAutoResume(pauseInfo, { now, currentVersion, quotaTtlMs, failuresTtlMs } = {}) {
225
+ if (!pauseInfo || pauseInfo.manual) return { resume: false };
226
+ const { kind } = pauseInfo;
227
+ if (kind !== "quota" && kind !== "failures") return { resume: false };
228
+ if (
229
+ kind === "failures" &&
230
+ typeof pauseInfo.pluginVersion === "string" &&
231
+ typeof currentVersion === "string" &&
232
+ pauseInfo.pluginVersion !== currentVersion
233
+ ) {
234
+ return { resume: true, why: "plugin-updated" };
235
+ }
236
+ const at = Date.parse(pauseInfo.at);
237
+ const ttl = kind === "quota" ? (quotaTtlMs ?? QUOTA_PAUSE_TTL_MS) : (failuresTtlMs ?? FAILURES_PAUSE_TTL_MS);
238
+ if (!Number.isFinite(at) || (now ?? Date.now()) - at >= ttl) {
239
+ return { resume: true, why: "ttl-expired" };
240
+ }
241
+ return { resume: false };
242
+ }
243
+
244
+ // Undo an auto-pause: sentinel AND failure counter go together — resuming
245
+ // with a counter already at threshold would re-pause on the next single
246
+ // failure (the /codex-pair-resume skill had exactly this bug).
247
+ // The sentinel is re-read and verified before unlinking: a manual pause is
248
+ // NEVER removed here ("manual pauses only resume manually"), and when
249
+ // `expected` (the pauseInfo the caller evaluated) is passed, a sentinel that
250
+ // changed in the meantime — e.g. the user raced in a fresh pause — aborts the
251
+ // resume with false (dogfood review findings, 2026-07-02).
252
+ export function clearAutoPause(markerDir, expected) {
253
+ const current = readPauseInfo(markerDir);
254
+ if (!current || current.manual) return false;
255
+ if (expected && typeof expected === "object" && (current.kind !== expected.kind || current.at !== expected.at)) {
256
+ return false;
257
+ }
258
+ try {
259
+ unlinkSync(pausePath(markerDir));
260
+ } catch {
261
+ // already gone
262
+ }
263
+ clearReviewFailures(markerDir);
264
+ return true;
265
+ }
266
+
267
+ // Write the auto-pause sentinel. `flag: "wx"` makes this atomic-exclusive:
268
+ // an existing pause (manual OR auto, including a concurrent hook racing us)
269
+ // is never overwritten — we return false and the caller skips its
270
+ // notification, which is what makes "notify once" hold under concurrency.
271
+ export function writeAutoPause(markerDir, { kind, reason, resetHint }) {
272
+ const pluginVersion = readPluginVersion();
273
+ const body = JSON.stringify({
274
+ v: 1,
275
+ kind,
276
+ reason: clampReason(typeof reason === "string" ? reason : String(reason)),
277
+ ...(resetHint ? { resetHint } : {}),
278
+ ...(pluginVersion ? { pluginVersion } : {}),
279
+ at: new Date().toISOString(),
280
+ });
281
+ try {
282
+ mkdirSync(stateRoot(markerDir), { recursive: true });
283
+ writeFileSync(pausePath(markerDir), body, { flag: "wx" });
284
+ return true;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ // ── Consecutive-failure counter (#176 backstop) ──────────────────────────
291
+ // Global per project (markerDir), spans files and sessions. Incremented on
292
+ // every non-quota review failure; cleared on every successful live review.
293
+ // Tolerant reads (missing/corrupt → 0); atomic tmp+rename writes.
294
+ // Accepted race: this read-modify-write is global per project and NOT
295
+ // serialized by ADR-087's per-file inflight locks — two concurrent
296
+ // failures on different files can lose an increment. Accepted: the
297
+ // threshold just fires one failure later, and the eventual sentinel
298
+ // write is still wx-safe.
299
+
300
+ export function readFailureCount(markerDir) {
301
+ try {
302
+ const parsed = JSON.parse(readFileSync(failuresPath(markerDir), "utf8"));
303
+ if (parsed && typeof parsed === "object" && typeof parsed.consecutive === "number" && parsed.consecutive > 0) {
304
+ return Math.floor(parsed.consecutive);
305
+ }
306
+ } catch {
307
+ // missing/corrupt → 0
308
+ }
309
+ return 0;
310
+ }
311
+
312
+ export function recordReviewFailure(markerDir, reason) {
313
+ const consecutive = readFailureCount(markerDir) + 1;
314
+ const payload = {
315
+ v: 1,
316
+ consecutive,
317
+ lastAt: new Date().toISOString(),
318
+ lastReason: clampReason(typeof reason === "string" ? reason : String(reason)),
319
+ };
320
+ try {
321
+ mkdirSync(stateRoot(markerDir), { recursive: true });
322
+ const p = failuresPath(markerDir);
323
+ const tmp = `${p}.tmp.${process.pid}`;
324
+ writeFileSync(tmp, JSON.stringify(payload));
325
+ renameSync(tmp, p);
326
+ } catch {
327
+ // best-effort — counter loss degrades to "pause later", never breaks the hook
328
+ }
329
+ return consecutive;
330
+ }
331
+
332
+ export function clearReviewFailures(markerDir) {
333
+ try {
334
+ unlinkSync(failuresPath(markerDir));
335
+ } catch {
336
+ // already clear
337
+ }
338
+ }
339
+
340
+ // ── Inflight lock (ADR-087, paths consolidated per ADR-092) ──────────────
341
+ export function inflightLockPath(markerDir, filePath) {
342
+ const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 16);
343
+ return join(inflightRoot(markerDir), hash);
344
+ }
345
+
346
+ export function tryAcquireInflightLock(markerDir, filePath, ttlMs) {
347
+ const lockPath = inflightLockPath(markerDir, filePath);
348
+ try {
349
+ mkdirSync(dirname(lockPath), { recursive: true });
350
+ } catch {
351
+ // mkdir failures fall through — writeFileSync below will report the real error
352
+ }
353
+ try {
354
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
355
+ return { acquired: true, lockPath };
356
+ } catch (err) {
357
+ if (err?.code !== "EEXIST") {
358
+ return { acquired: false, lockPath, reason: "error" };
359
+ }
360
+ }
361
+ // Lock exists. Multi-review (ADR-091) caught a TOCTOU: a blind
362
+ // unlink-after-stat can delete a FRESH lock that another concurrent
363
+ // process wrote between our stat and our unlink. Defense: capture an
364
+ // identity snapshot (mtime + PID content) before deciding the lock is
365
+ // stale, then re-verify the identity right before unlinking. If
366
+ // anyone refreshed it, treat as in-flight.
367
+ let snapshot;
368
+ try {
369
+ const stats = statSync(lockPath);
370
+ if (Date.now() - stats.mtimeMs <= ttlMs) {
371
+ return { acquired: false, lockPath, reason: "in-flight" };
372
+ }
373
+ snapshot = { mtimeMs: stats.mtimeMs, pid: readFileSync(lockPath, "utf8") };
374
+ } catch {
375
+ // Lock vanished between EEXIST and stat — retry the create
376
+ try {
377
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
378
+ return { acquired: true, lockPath, recoveredStale: true };
379
+ } catch {
380
+ return { acquired: false, lockPath, reason: "race" };
381
+ }
382
+ }
383
+ // Re-verify identity right before unlinking; if mtime or PID changed,
384
+ // another actor refreshed the lock and we must back off.
385
+ try {
386
+ const recheck = statSync(lockPath);
387
+ const recheckPid = readFileSync(lockPath, "utf8");
388
+ if (recheck.mtimeMs !== snapshot.mtimeMs || recheckPid !== snapshot.pid) {
389
+ return { acquired: false, lockPath, reason: "in-flight" };
390
+ }
391
+ } catch {
392
+ // Vanished between snapshot and recheck — fall through to retry create
393
+ }
394
+ try {
395
+ unlinkSync(lockPath);
396
+ } catch {
397
+ // someone else already cleaned up — fine, fall through to retry
398
+ }
399
+ try {
400
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
401
+ return { acquired: true, lockPath, recoveredStale: true };
402
+ } catch {
403
+ return { acquired: false, lockPath, reason: "race" };
404
+ }
405
+ }
406
+
407
+ export function releaseInflightLock(lockPath) {
408
+ if (!lockPath) return;
409
+ try {
410
+ unlinkSync(lockPath);
411
+ } catch {
412
+ // already gone — fine
413
+ }
414
+ }
415
+
416
+ // ── Content-hash cache (ADR-082, atomic per ADR-086) ─────────────────────
417
+ export function computeCacheKey({ model, prompt, fileContent, surfaceThreshold }) {
418
+ const h = createHash("sha256");
419
+ h.update(model);
420
+ h.update("\0");
421
+ h.update(prompt);
422
+ h.update("\0");
423
+ h.update(fileContent);
424
+ h.update("\0");
425
+ h.update(surfaceThreshold);
426
+ return h.digest("hex");
427
+ }
428
+
429
+ export function cachePathFor(markerDir, cacheKey) {
430
+ return join(cacheRoot(markerDir), cacheKey.slice(0, 2), `${cacheKey.slice(2)}.json`);
431
+ }
432
+
433
+ export async function getCachedConcerns(markerDir, cacheKey) {
434
+ const cachePath = cachePathFor(markerDir, cacheKey);
435
+ try {
436
+ const stats = await stat(cachePath);
437
+ if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return null;
438
+ const raw = await readFile(cachePath, "utf8");
439
+ const parsed = JSON.parse(raw);
440
+ if (!parsed || !Array.isArray(parsed.high) || !Array.isArray(parsed.med) || !Array.isArray(parsed.low)) {
441
+ return null;
442
+ }
443
+ return parsed;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ export async function setCachedConcerns(markerDir, cacheKey, value) {
450
+ const cachePath = cachePathFor(markerDir, cacheKey);
451
+ try {
452
+ await mkdir(dirname(cachePath), { recursive: true });
453
+ const tmpPath = `${cachePath}.tmp.${process.pid}`;
454
+ await writeFile(tmpPath, JSON.stringify(value));
455
+ await rename(tmpPath, cachePath);
456
+ } catch {
457
+ // intentional no-op — cache write failures must never break Claude's flow
458
+ }
459
+ await evictCacheOldest(markerDir);
460
+ }
461
+
462
+ export async function evictCacheOldest(markerDir) {
463
+ try {
464
+ const root = cacheRoot(markerDir);
465
+ const entries = [];
466
+ const prefixes = await readdir(root);
467
+ for (const prefix of prefixes) {
468
+ let files;
469
+ try {
470
+ files = await readdir(join(root, prefix));
471
+ } catch {
472
+ continue;
473
+ }
474
+ for (const file of files) {
475
+ const full = join(root, prefix, file);
476
+ try {
477
+ const s = await stat(full);
478
+ entries.push({ path: full, mtimeMs: s.mtimeMs });
479
+ } catch {
480
+ // skip unreadable entries
481
+ }
482
+ }
483
+ }
484
+ if (entries.length <= CACHE_MAX_ENTRIES) return;
485
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
486
+ const drop = entries.slice(0, entries.length - CACHE_MAX_ENTRIES);
487
+ for (const e of drop) {
488
+ try {
489
+ await unlink(e.path);
490
+ } catch {
491
+ // skip if already deleted by a concurrent run
492
+ }
493
+ }
494
+ } catch {
495
+ // intentional no-op — eviction is best-effort
496
+ }
497
+ }
498
+
499
+ // ── Log (ADR-079 rotation + ADR-086 clamp + ADR-091 PID-scoped tmp) ──────
500
+ export async function rotateLogIfNeeded(targetLogPath) {
501
+ try {
502
+ const stats = await stat(targetLogPath);
503
+ if (stats.size <= MAX_LOG_BYTES) return;
504
+ const content = await readFile(targetLogPath, "utf8");
505
+ const lines = content.split("\n").filter((l) => l.length > 0);
506
+ if (lines.length <= MAX_LOG_ENTRIES) return;
507
+ const tail = lines.slice(-MAX_LOG_ENTRIES);
508
+ // PID-scoped tmp prevents concurrent rotations from torn-writing the
509
+ // same tmp file (ADR-091).
510
+ const tmpPath = `${targetLogPath}.tmp.${process.pid}`;
511
+ await writeFile(tmpPath, `${tail.join("\n")}\n`);
512
+ await rename(tmpPath, targetLogPath);
513
+ } catch {
514
+ // intentional no-op — rotation is best-effort
515
+ }
516
+ }
517
+
518
+ export function clampReason(reason) {
519
+ if (typeof reason !== "string") return reason;
520
+ // Use UTF-8 BYTE length, not JS char length — ADR-086's PIPE_BUF (4096)
521
+ // atomicity contract is in bytes. Multi-review (ADR-091) flagged that
522
+ // multibyte reasons (Cyrillic identifiers, em-dashes, accented filenames
523
+ // in codex stderr) would slip past a char-count threshold.
524
+ const byteLen = Buffer.byteLength(reason, "utf8");
525
+ if (byteLen <= MAX_LOG_REASON_BYTES) return reason;
526
+ // Slice the UTF-8 buffer, backing off any continuation bytes (high bits
527
+ // 10xxxxxx) so we don't cut mid-codepoint and produce a U+FFFD.
528
+ const buf = Buffer.from(reason, "utf8");
529
+ let end = MAX_LOG_REASON_BYTES;
530
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
531
+ const dropped = byteLen - end;
532
+ return `${buf.subarray(0, end).toString("utf8")}…(${dropped}b truncated)`;
533
+ }
534
+
535
+ export async function appendLog(markerDir, entry) {
536
+ const target = logPath(markerDir);
537
+ // Ensure .codex-pair/ exists. The hook's main flow normally migrates
538
+ // first, so this is a defensive belt — fresh installs hit it once.
539
+ try {
540
+ await mkdir(dirname(target), { recursive: true });
541
+ } catch {
542
+ // ignore — appendFile will surface the real failure
543
+ }
544
+ const safe = entry?.reason !== undefined ? { ...entry, reason: clampReason(entry.reason) } : entry;
545
+ try {
546
+ await appendFile(target, `${JSON.stringify(safe)}\n`);
547
+ } catch {
548
+ // logging failures must never break Claude's flow
549
+ return;
550
+ }
551
+ await rotateLogIfNeeded(target);
552
+ }
553
+
554
+ // ADR-096 (sharded per ADR-097 hotfix): Repetition detector.
555
+ //
556
+ // Stores per-(file, concernHash) consecutive-flag counts so the hook can
557
+ // detect "this same concern has been flagged 3+ times and the consumer
558
+ // keeps ignoring it" and escalate the systemMessage with a 🛑 banner.
559
+ //
560
+ // Storage layout (v2): one shard file per reviewed file, at
561
+ // `.codex-pair/state/repetitions/<sha256(file)[0:16]>.json`
562
+ // Shard schema: `{ v: 2, file, entries: [{hash, count, firstSeenAt, lastSeenAt}] }`
563
+ //
564
+ // Sharding (ADR-097 multi-review hotfix on ADR-096) eliminates the
565
+ // cross-file TOCTOU race: each shard's read-modify-write is naturally
566
+ // serialized by ADR-087's per-file inflight lock. The previous v1
567
+ // singleton design lost increments under concurrent edits on different
568
+ // files.
569
+ //
570
+ // `loadRepetitionsForFile`: read shard for a single file. Returns
571
+ // Map<hash, entry>. Tolerant of missing/malformed/wrong-version.
572
+ // `saveRepetitionsForFile`: atomic tmp+rename of one shard.
573
+ // `updateRepetitions`: increment-or-drop + save + return blocking
574
+ // entries (count >= REPETITION_BLOCKING_THRESHOLD).
575
+ // `getBlockingFromShard`: read-only — checks whether currently-cached
576
+ // concerns have already crossed threshold without incrementing (used
577
+ // by the cache-hit path to surface the banner without re-counting).
578
+ // `sweepStaleRepetitions`: drop shards older than REPETITIONS_TTL_MS.
579
+
580
+ export function hashConcernBody(body) {
581
+ return createHash("sha256").update(String(body)).digest("hex").slice(0, 16);
582
+ }
583
+
584
+ export function loadRepetitionsForFile(markerDir, file) {
585
+ const p = repetitionsShardPath(markerDir, file);
586
+ try {
587
+ const raw = readFileSync(p, "utf-8");
588
+ const parsed = JSON.parse(raw);
589
+ if (!parsed || typeof parsed !== "object") return new Map();
590
+ if (parsed.v !== REPETITIONS_SHARD_SCHEMA_VERSION) return new Map();
591
+ if (!Array.isArray(parsed.entries)) return new Map();
592
+ const map = new Map();
593
+ for (const e of parsed.entries) {
594
+ if (!e || typeof e !== "object") continue;
595
+ if (typeof e.hash !== "string") continue;
596
+ if (typeof e.count !== "number" || e.count <= 0) continue;
597
+ map.set(e.hash, {
598
+ hash: e.hash,
599
+ count: e.count,
600
+ firstSeenAt: e.firstSeenAt ?? new Date().toISOString(),
601
+ lastSeenAt: e.lastSeenAt ?? new Date().toISOString(),
602
+ });
603
+ }
604
+ return map;
605
+ } catch {
606
+ return new Map();
607
+ }
608
+ }
609
+
610
+ export async function saveRepetitionsForFile(markerDir, file, map) {
611
+ const p = repetitionsShardPath(markerDir, file);
612
+ const payload = {
613
+ v: REPETITIONS_SHARD_SCHEMA_VERSION,
614
+ file,
615
+ entries: Array.from(map.values()),
616
+ };
617
+ try {
618
+ await mkdir(dirname(p), { recursive: true });
619
+ const tmp = `${p}.tmp.${process.pid}`;
620
+ await writeFile(tmp, JSON.stringify(payload));
621
+ await rename(tmp, p);
622
+ } catch {
623
+ // best-effort — repetitions are advisory; failure must not break hook
624
+ }
625
+ }
626
+
627
+ // Read-only check — used by the cache-hit path. Returns the subset of
628
+ // `newHashes` whose count already meets/exceeds the BLOCKING threshold.
629
+ // Does NOT mutate state, so rapid undo/redo producing cache hits won't
630
+ // increment counts (closes ADR-096 multi-review finding #3 — cache-hit
631
+ // double-count under content-identical re-saves).
632
+ export function getBlockingFromShard(markerDir, file, newHashes) {
633
+ const map = loadRepetitionsForFile(markerDir, file);
634
+ const blocking = [];
635
+ for (const h of newHashes) {
636
+ const e = map.get(h);
637
+ if (e && e.count >= REPETITION_BLOCKING_THRESHOLD) {
638
+ blocking.push({ file, hash: e.hash, count: e.count });
639
+ }
640
+ }
641
+ return blocking;
642
+ }
643
+
644
+ // Update repetition state for a single file given the set of concern
645
+ // hashes from the just-completed LIVE review. (Cache-hit path uses
646
+ // `getBlockingFromShard` instead — read-only.) Returns blocking entries.
647
+ export async function updateRepetitions(markerDir, file, newHashes) {
648
+ const map = loadRepetitionsForFile(markerDir, file);
649
+ const newSet = new Set(newHashes);
650
+ const now = new Date().toISOString();
651
+ // Drop prior entries absent from new review (assumed fixed);
652
+ // increment ones still flagged.
653
+ for (const [hash, entry] of [...map.entries()]) {
654
+ if (newSet.has(hash)) {
655
+ entry.count += 1;
656
+ entry.lastSeenAt = now;
657
+ newSet.delete(hash);
658
+ } else {
659
+ map.delete(hash);
660
+ }
661
+ }
662
+ // First-time-seen hashes
663
+ for (const hash of newSet) {
664
+ map.set(hash, { hash, count: 1, firstSeenAt: now, lastSeenAt: now });
665
+ }
666
+ await saveRepetitionsForFile(markerDir, file, map);
667
+ // Probabilistic TTL sweep — 5% per update amortizes O(N_files) cost
668
+ // without needing a dedicated SessionStart hook.
669
+ if (Math.random() < 0.05) {
670
+ sweepStaleRepetitions(markerDir).catch(() => {});
671
+ }
672
+ // Return entries at/over threshold
673
+ const blocking = [];
674
+ for (const entry of map.values()) {
675
+ if (entry.count >= REPETITION_BLOCKING_THRESHOLD) {
676
+ blocking.push({ file, hash: entry.hash, count: entry.count });
677
+ }
678
+ }
679
+ return blocking;
680
+ }
681
+
682
+ // Drop shard files with mtime older than REPETITIONS_TTL_MS. Closes
683
+ // ADR-096 multi-review finding #2 (unbounded growth — entries leaked
684
+ // for files never re-reviewed). Runs probabilistically from
685
+ // updateRepetitions; best-effort, never throws.
686
+ export async function sweepStaleRepetitions(markerDir) {
687
+ const root = repetitionsShardsRoot(markerDir);
688
+ try {
689
+ const files = await readdir(root);
690
+ const cutoff = Date.now() - REPETITIONS_TTL_MS;
691
+ for (const f of files) {
692
+ if (!f.endsWith(".json")) continue;
693
+ const full = join(root, f);
694
+ try {
695
+ const s = await stat(full);
696
+ if (s.mtimeMs < cutoff) await unlink(full);
697
+ } catch {
698
+ // skip unreadable / racing-unlink
699
+ }
700
+ }
701
+ } catch {
702
+ // shards dir doesn't exist yet — nothing to sweep
703
+ }
704
+ }
705
+
706
+ // Backward-compat shims for the v1 singleton API. Used by tests that
707
+ // haven't migrated yet. New code should use the per-file shard helpers.
708
+ export function loadRepetitions(markerDir) {
709
+ // v1 singleton is dead — return empty Map. Any v1 file is treated as
710
+ // stale and ignored. The TTL sweep will not touch it (different path)
711
+ // but it's small and harmless; documented as a known dangling artifact.
712
+ // Tests that exercised v1 semantics need to migrate to loadRepetitionsForFile.
713
+ void markerDir;
714
+ return new Map();
715
+ }
716
+ export async function saveRepetitions(markerDir, map) {
717
+ // v1 no-op. Documented as dead in ADR-097.
718
+ void markerDir;
719
+ void map;
720
+ }