@ask-llm/plugin 0.15.0 → 0.16.2

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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +966 -0
  4. package/README.md +2 -0
  5. package/agents/brainstorm-coordinator.md +1 -1
  6. package/agents/gemini-reviewer.md +1 -1
  7. package/dist/antigravity-run.js +0 -0
  8. package/dist/brainstorm-run.js +0 -0
  9. package/dist/codex-run.js +0 -0
  10. package/dist/grok-run.js +0 -0
  11. package/dist/ollama-run.js +0 -0
  12. package/dist/run.js +0 -0
  13. package/package.json +14 -14
  14. package/pi/extensions/provider-tools.ts +1 -1
  15. package/scripts/benchmark/README.md +114 -0
  16. package/scripts/benchmark/fixtures/README.md +29 -0
  17. package/scripts/codex-pair-debounce-worker.mjs +0 -0
  18. package/scripts/codex-pair-log.mjs +4 -13
  19. package/scripts/codex-pair-prompt-drain.mjs +1 -1
  20. package/scripts/codex-pair-session.mjs +2 -2
  21. package/scripts/codex-pair-stop-gate.mjs +8 -8
  22. package/scripts/codex-pair-watch.mjs +20 -39
  23. package/skills/gemini-review/SKILL.md +1 -1
  24. package/scripts/lib/broker-lifecycle.mjs +0 -575
  25. package/scripts/lib/broker-rpc.mjs +0 -203
  26. package/scripts/lib/broker-transport.mjs +0 -407
  27. package/scripts/lib/broker.mjs +0 -537
  28. package/scripts/lib/debounce-state.mjs +0 -208
  29. package/scripts/lib/parser.d.mts +0 -12
  30. package/scripts/lib/parser.mjs +0 -229
  31. package/scripts/lib/process.mjs +0 -56
  32. package/scripts/lib/prompt.d.mts +0 -8
  33. package/scripts/lib/prompt.mjs +0 -41
  34. package/scripts/lib/session-registry.mjs +0 -162
  35. package/scripts/lib/state.d.mts +0 -58
  36. package/scripts/lib/state.mjs +0 -733
  37. package/scripts/lib/stop-gate.mjs +0 -134
@@ -1,733 +0,0 @@
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 { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
18
- import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
19
- import { createHash, randomUUID } from "node:crypto";
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(
218
- process.env.CODEX_PAIR_FAILURES_PAUSE_TTL_MS ?? 24 * 3_600_000,
219
- );
220
-
221
- // Pure decision: should an existing pause self-heal now? Manual pauses never
222
- // do. Auto-pauses expire by TTL from their `at` stamp; failures-kind also
223
- // expires on plugin-version change. A missing/unparseable `at` counts as
224
- // expired — liveness-biased, because the manual pause is the reliable
225
- // off-switch and a corrupt auto-sentinel must not kill pairing forever.
226
- export function resolveAutoResume(pauseInfo, { now, currentVersion, quotaTtlMs, failuresTtlMs } = {}) {
227
- if (!pauseInfo || pauseInfo.manual) return { resume: false };
228
- const { kind } = pauseInfo;
229
- if (kind !== "quota" && kind !== "failures") return { resume: false };
230
- if (
231
- kind === "failures" &&
232
- typeof pauseInfo.pluginVersion === "string" &&
233
- typeof currentVersion === "string" &&
234
- pauseInfo.pluginVersion !== currentVersion
235
- ) {
236
- return { resume: true, why: "plugin-updated" };
237
- }
238
- const at = Date.parse(pauseInfo.at);
239
- const ttl = kind === "quota" ? (quotaTtlMs ?? QUOTA_PAUSE_TTL_MS) : (failuresTtlMs ?? FAILURES_PAUSE_TTL_MS);
240
- if (!Number.isFinite(at) || (now ?? Date.now()) - at >= ttl) {
241
- return { resume: true, why: "ttl-expired" };
242
- }
243
- return { resume: false };
244
- }
245
-
246
- // Undo an auto-pause: sentinel AND failure counter go together — resuming
247
- // with a counter already at threshold would re-pause on the next single
248
- // failure (the /codex-pair-resume skill had exactly this bug).
249
- // The sentinel is re-read and verified before unlinking: a manual pause is
250
- // NEVER removed here ("manual pauses only resume manually"), and when
251
- // `expected` (the pauseInfo the caller evaluated) is passed, a sentinel that
252
- // changed in the meantime — e.g. the user raced in a fresh pause — aborts the
253
- // resume with false (dogfood review findings, 2026-07-02).
254
- export function clearAutoPause(markerDir, expected) {
255
- const current = readPauseInfo(markerDir);
256
- if (!current || current.manual) return false;
257
- if (
258
- expected &&
259
- typeof expected === "object" &&
260
- (current.kind !== expected.kind || current.at !== expected.at)
261
- ) {
262
- return false;
263
- }
264
- try {
265
- unlinkSync(pausePath(markerDir));
266
- } catch {
267
- // already gone
268
- }
269
- clearReviewFailures(markerDir);
270
- return true;
271
- }
272
-
273
- // Write the auto-pause sentinel. `flag: "wx"` makes this atomic-exclusive:
274
- // an existing pause (manual OR auto, including a concurrent hook racing us)
275
- // is never overwritten — we return false and the caller skips its
276
- // notification, which is what makes "notify once" hold under concurrency.
277
- export function writeAutoPause(markerDir, { kind, reason, resetHint }) {
278
- const pluginVersion = readPluginVersion();
279
- const body = JSON.stringify({
280
- v: 1,
281
- kind,
282
- reason: clampReason(typeof reason === "string" ? reason : String(reason)),
283
- ...(resetHint ? { resetHint } : {}),
284
- ...(pluginVersion ? { pluginVersion } : {}),
285
- at: new Date().toISOString(),
286
- });
287
- try {
288
- mkdirSync(stateRoot(markerDir), { recursive: true });
289
- writeFileSync(pausePath(markerDir), body, { flag: "wx" });
290
- return true;
291
- } catch {
292
- return false;
293
- }
294
- }
295
-
296
- // ── Consecutive-failure counter (#176 backstop) ──────────────────────────
297
- // Global per project (markerDir), spans files and sessions. Incremented on
298
- // every non-quota review failure; cleared on every successful live review.
299
- // Tolerant reads (missing/corrupt → 0); atomic tmp+rename writes.
300
- // Accepted race: this read-modify-write is global per project and NOT
301
- // serialized by ADR-087's per-file inflight locks — two concurrent
302
- // failures on different files can lose an increment. Accepted: the
303
- // threshold just fires one failure later, and the eventual sentinel
304
- // write is still wx-safe.
305
-
306
- export function readFailureCount(markerDir) {
307
- try {
308
- const parsed = JSON.parse(readFileSync(failuresPath(markerDir), "utf8"));
309
- if (parsed && typeof parsed === "object" && typeof parsed.consecutive === "number" && parsed.consecutive > 0) {
310
- return Math.floor(parsed.consecutive);
311
- }
312
- } catch {
313
- // missing/corrupt → 0
314
- }
315
- return 0;
316
- }
317
-
318
- export function recordReviewFailure(markerDir, reason) {
319
- const consecutive = readFailureCount(markerDir) + 1;
320
- const payload = {
321
- v: 1,
322
- consecutive,
323
- lastAt: new Date().toISOString(),
324
- lastReason: clampReason(typeof reason === "string" ? reason : String(reason)),
325
- };
326
- try {
327
- mkdirSync(stateRoot(markerDir), { recursive: true });
328
- const p = failuresPath(markerDir);
329
- const tmp = `${p}.tmp.${process.pid}`;
330
- writeFileSync(tmp, JSON.stringify(payload));
331
- renameSync(tmp, p);
332
- } catch {
333
- // best-effort — counter loss degrades to "pause later", never breaks the hook
334
- }
335
- return consecutive;
336
- }
337
-
338
- export function clearReviewFailures(markerDir) {
339
- try {
340
- unlinkSync(failuresPath(markerDir));
341
- } catch {
342
- // already clear
343
- }
344
- }
345
-
346
- // ── Inflight lock (ADR-087, paths consolidated per ADR-092) ──────────────
347
- export function inflightLockPath(markerDir, filePath) {
348
- const hash = createHash("sha256").update(filePath).digest("hex").slice(0, 16);
349
- return join(inflightRoot(markerDir), hash);
350
- }
351
-
352
- export function tryAcquireInflightLock(markerDir, filePath, ttlMs) {
353
- const lockPath = inflightLockPath(markerDir, filePath);
354
- try {
355
- mkdirSync(dirname(lockPath), { recursive: true });
356
- } catch {
357
- // mkdir failures fall through — writeFileSync below will report the real error
358
- }
359
- try {
360
- writeFileSync(lockPath, String(process.pid), { flag: "wx" });
361
- return { acquired: true, lockPath };
362
- } catch (err) {
363
- if (!err || err.code !== "EEXIST") {
364
- return { acquired: false, lockPath, reason: "error" };
365
- }
366
- }
367
- // Lock exists. Multi-review (ADR-091) caught a TOCTOU: a blind
368
- // unlink-after-stat can delete a FRESH lock that another concurrent
369
- // process wrote between our stat and our unlink. Defense: capture an
370
- // identity snapshot (mtime + PID content) before deciding the lock is
371
- // stale, then re-verify the identity right before unlinking. If
372
- // anyone refreshed it, treat as in-flight.
373
- let snapshot;
374
- try {
375
- const stats = statSync(lockPath);
376
- if (Date.now() - stats.mtimeMs <= ttlMs) {
377
- return { acquired: false, lockPath, reason: "in-flight" };
378
- }
379
- snapshot = { mtimeMs: stats.mtimeMs, pid: readFileSync(lockPath, "utf8") };
380
- } catch {
381
- // Lock vanished between EEXIST and stat — retry the create
382
- try {
383
- writeFileSync(lockPath, String(process.pid), { flag: "wx" });
384
- return { acquired: true, lockPath, recoveredStale: true };
385
- } catch {
386
- return { acquired: false, lockPath, reason: "race" };
387
- }
388
- }
389
- // Re-verify identity right before unlinking; if mtime or PID changed,
390
- // another actor refreshed the lock and we must back off.
391
- try {
392
- const recheck = statSync(lockPath);
393
- const recheckPid = readFileSync(lockPath, "utf8");
394
- if (recheck.mtimeMs !== snapshot.mtimeMs || recheckPid !== snapshot.pid) {
395
- return { acquired: false, lockPath, reason: "in-flight" };
396
- }
397
- } catch {
398
- // Vanished between snapshot and recheck — fall through to retry create
399
- }
400
- try {
401
- unlinkSync(lockPath);
402
- } catch {
403
- // someone else already cleaned up — fine, fall through to retry
404
- }
405
- try {
406
- writeFileSync(lockPath, String(process.pid), { flag: "wx" });
407
- return { acquired: true, lockPath, recoveredStale: true };
408
- } catch {
409
- return { acquired: false, lockPath, reason: "race" };
410
- }
411
- }
412
-
413
- export function releaseInflightLock(lockPath) {
414
- if (!lockPath) return;
415
- try {
416
- unlinkSync(lockPath);
417
- } catch {
418
- // already gone — fine
419
- }
420
- }
421
-
422
- // ── Content-hash cache (ADR-082, atomic per ADR-086) ─────────────────────
423
- export function computeCacheKey({ model, prompt, fileContent, surfaceThreshold }) {
424
- const h = createHash("sha256");
425
- h.update(model);
426
- h.update("\0");
427
- h.update(prompt);
428
- h.update("\0");
429
- h.update(fileContent);
430
- h.update("\0");
431
- h.update(surfaceThreshold);
432
- return h.digest("hex");
433
- }
434
-
435
- export function cachePathFor(markerDir, cacheKey) {
436
- return join(cacheRoot(markerDir), cacheKey.slice(0, 2), `${cacheKey.slice(2)}.json`);
437
- }
438
-
439
- export async function getCachedConcerns(markerDir, cacheKey) {
440
- const cachePath = cachePathFor(markerDir, cacheKey);
441
- try {
442
- const stats = await stat(cachePath);
443
- if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return null;
444
- const raw = await readFile(cachePath, "utf8");
445
- const parsed = JSON.parse(raw);
446
- if (
447
- !parsed ||
448
- !Array.isArray(parsed.high) ||
449
- !Array.isArray(parsed.med) ||
450
- !Array.isArray(parsed.low)
451
- ) {
452
- return null;
453
- }
454
- return parsed;
455
- } catch {
456
- return null;
457
- }
458
- }
459
-
460
- export async function setCachedConcerns(markerDir, cacheKey, value) {
461
- const cachePath = cachePathFor(markerDir, cacheKey);
462
- try {
463
- await mkdir(dirname(cachePath), { recursive: true });
464
- const tmpPath = `${cachePath}.tmp.${process.pid}`;
465
- await writeFile(tmpPath, JSON.stringify(value));
466
- await rename(tmpPath, cachePath);
467
- } catch {
468
- // intentional no-op — cache write failures must never break Claude's flow
469
- }
470
- await evictCacheOldest(markerDir);
471
- }
472
-
473
- export async function evictCacheOldest(markerDir) {
474
- try {
475
- const root = cacheRoot(markerDir);
476
- const entries = [];
477
- const prefixes = await readdir(root);
478
- for (const prefix of prefixes) {
479
- let files;
480
- try {
481
- files = await readdir(join(root, prefix));
482
- } catch {
483
- continue;
484
- }
485
- for (const file of files) {
486
- const full = join(root, prefix, file);
487
- try {
488
- const s = await stat(full);
489
- entries.push({ path: full, mtimeMs: s.mtimeMs });
490
- } catch {
491
- // skip unreadable entries
492
- }
493
- }
494
- }
495
- if (entries.length <= CACHE_MAX_ENTRIES) return;
496
- entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
497
- const drop = entries.slice(0, entries.length - CACHE_MAX_ENTRIES);
498
- for (const e of drop) {
499
- try {
500
- await unlink(e.path);
501
- } catch {
502
- // skip if already deleted by a concurrent run
503
- }
504
- }
505
- } catch {
506
- // intentional no-op — eviction is best-effort
507
- }
508
- }
509
-
510
- // ── Log (ADR-079 rotation + ADR-086 clamp + ADR-091 PID-scoped tmp) ──────
511
- export async function rotateLogIfNeeded(targetLogPath) {
512
- try {
513
- const stats = await stat(targetLogPath);
514
- if (stats.size <= MAX_LOG_BYTES) return;
515
- const content = await readFile(targetLogPath, "utf8");
516
- const lines = content.split("\n").filter((l) => l.length > 0);
517
- if (lines.length <= MAX_LOG_ENTRIES) return;
518
- const tail = lines.slice(-MAX_LOG_ENTRIES);
519
- // PID-scoped tmp prevents concurrent rotations from torn-writing the
520
- // same tmp file (ADR-091).
521
- const tmpPath = `${targetLogPath}.tmp.${process.pid}`;
522
- await writeFile(tmpPath, `${tail.join("\n")}\n`);
523
- await rename(tmpPath, targetLogPath);
524
- } catch {
525
- // intentional no-op — rotation is best-effort
526
- }
527
- }
528
-
529
- export function clampReason(reason) {
530
- if (typeof reason !== "string") return reason;
531
- // Use UTF-8 BYTE length, not JS char length — ADR-086's PIPE_BUF (4096)
532
- // atomicity contract is in bytes. Multi-review (ADR-091) flagged that
533
- // multibyte reasons (Cyrillic identifiers, em-dashes, accented filenames
534
- // in codex stderr) would slip past a char-count threshold.
535
- const byteLen = Buffer.byteLength(reason, "utf8");
536
- if (byteLen <= MAX_LOG_REASON_BYTES) return reason;
537
- // Slice the UTF-8 buffer, backing off any continuation bytes (high bits
538
- // 10xxxxxx) so we don't cut mid-codepoint and produce a U+FFFD.
539
- const buf = Buffer.from(reason, "utf8");
540
- let end = MAX_LOG_REASON_BYTES;
541
- while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
542
- const dropped = byteLen - end;
543
- return `${buf.subarray(0, end).toString("utf8")}…(${dropped}b truncated)`;
544
- }
545
-
546
- export async function appendLog(markerDir, entry) {
547
- const target = logPath(markerDir);
548
- // Ensure .codex-pair/ exists. The hook's main flow normally migrates
549
- // first, so this is a defensive belt — fresh installs hit it once.
550
- try {
551
- await mkdir(dirname(target), { recursive: true });
552
- } catch {
553
- // ignore — appendFile will surface the real failure
554
- }
555
- const safe = entry?.reason !== undefined ? { ...entry, reason: clampReason(entry.reason) } : entry;
556
- try {
557
- await appendFile(target, `${JSON.stringify(safe)}\n`);
558
- } catch {
559
- // logging failures must never break Claude's flow
560
- return;
561
- }
562
- await rotateLogIfNeeded(target);
563
- }
564
-
565
-
566
-
567
- // ADR-096 (sharded per ADR-097 hotfix): Repetition detector.
568
- //
569
- // Stores per-(file, concernHash) consecutive-flag counts so the hook can
570
- // detect "this same concern has been flagged 3+ times and the consumer
571
- // keeps ignoring it" and escalate the systemMessage with a 🛑 banner.
572
- //
573
- // Storage layout (v2): one shard file per reviewed file, at
574
- // `.codex-pair/state/repetitions/<sha256(file)[0:16]>.json`
575
- // Shard schema: `{ v: 2, file, entries: [{hash, count, firstSeenAt, lastSeenAt}] }`
576
- //
577
- // Sharding (ADR-097 multi-review hotfix on ADR-096) eliminates the
578
- // cross-file TOCTOU race: each shard's read-modify-write is naturally
579
- // serialized by ADR-087's per-file inflight lock. The previous v1
580
- // singleton design lost increments under concurrent edits on different
581
- // files.
582
- //
583
- // `loadRepetitionsForFile`: read shard for a single file. Returns
584
- // Map<hash, entry>. Tolerant of missing/malformed/wrong-version.
585
- // `saveRepetitionsForFile`: atomic tmp+rename of one shard.
586
- // `updateRepetitions`: increment-or-drop + save + return blocking
587
- // entries (count >= REPETITION_BLOCKING_THRESHOLD).
588
- // `getBlockingFromShard`: read-only — checks whether currently-cached
589
- // concerns have already crossed threshold without incrementing (used
590
- // by the cache-hit path to surface the banner without re-counting).
591
- // `sweepStaleRepetitions`: drop shards older than REPETITIONS_TTL_MS.
592
-
593
- export function hashConcernBody(body) {
594
- return createHash("sha256").update(String(body)).digest("hex").slice(0, 16);
595
- }
596
-
597
- export function loadRepetitionsForFile(markerDir, file) {
598
- const p = repetitionsShardPath(markerDir, file);
599
- try {
600
- const raw = readFileSync(p, "utf-8");
601
- const parsed = JSON.parse(raw);
602
- if (!parsed || typeof parsed !== "object") return new Map();
603
- if (parsed.v !== REPETITIONS_SHARD_SCHEMA_VERSION) return new Map();
604
- if (!Array.isArray(parsed.entries)) return new Map();
605
- const map = new Map();
606
- for (const e of parsed.entries) {
607
- if (!e || typeof e !== "object") continue;
608
- if (typeof e.hash !== "string") continue;
609
- if (typeof e.count !== "number" || e.count <= 0) continue;
610
- map.set(e.hash, {
611
- hash: e.hash,
612
- count: e.count,
613
- firstSeenAt: e.firstSeenAt ?? new Date().toISOString(),
614
- lastSeenAt: e.lastSeenAt ?? new Date().toISOString(),
615
- });
616
- }
617
- return map;
618
- } catch {
619
- return new Map();
620
- }
621
- }
622
-
623
- export async function saveRepetitionsForFile(markerDir, file, map) {
624
- const p = repetitionsShardPath(markerDir, file);
625
- const payload = {
626
- v: REPETITIONS_SHARD_SCHEMA_VERSION,
627
- file,
628
- entries: Array.from(map.values()),
629
- };
630
- try {
631
- await mkdir(dirname(p), { recursive: true });
632
- const tmp = `${p}.tmp.${process.pid}`;
633
- await writeFile(tmp, JSON.stringify(payload));
634
- await rename(tmp, p);
635
- } catch {
636
- // best-effort — repetitions are advisory; failure must not break hook
637
- }
638
- }
639
-
640
- // Read-only check — used by the cache-hit path. Returns the subset of
641
- // `newHashes` whose count already meets/exceeds the BLOCKING threshold.
642
- // Does NOT mutate state, so rapid undo/redo producing cache hits won't
643
- // increment counts (closes ADR-096 multi-review finding #3 — cache-hit
644
- // double-count under content-identical re-saves).
645
- export function getBlockingFromShard(markerDir, file, newHashes) {
646
- const map = loadRepetitionsForFile(markerDir, file);
647
- const blocking = [];
648
- for (const h of newHashes) {
649
- const e = map.get(h);
650
- if (e && e.count >= REPETITION_BLOCKING_THRESHOLD) {
651
- blocking.push({ file, hash: e.hash, count: e.count });
652
- }
653
- }
654
- return blocking;
655
- }
656
-
657
- // Update repetition state for a single file given the set of concern
658
- // hashes from the just-completed LIVE review. (Cache-hit path uses
659
- // `getBlockingFromShard` instead — read-only.) Returns blocking entries.
660
- export async function updateRepetitions(markerDir, file, newHashes) {
661
- const map = loadRepetitionsForFile(markerDir, file);
662
- const newSet = new Set(newHashes);
663
- const now = new Date().toISOString();
664
- // Drop prior entries absent from new review (assumed fixed);
665
- // increment ones still flagged.
666
- for (const [hash, entry] of [...map.entries()]) {
667
- if (newSet.has(hash)) {
668
- entry.count += 1;
669
- entry.lastSeenAt = now;
670
- newSet.delete(hash);
671
- } else {
672
- map.delete(hash);
673
- }
674
- }
675
- // First-time-seen hashes
676
- for (const hash of newSet) {
677
- map.set(hash, { hash, count: 1, firstSeenAt: now, lastSeenAt: now });
678
- }
679
- await saveRepetitionsForFile(markerDir, file, map);
680
- // Probabilistic TTL sweep — 5% per update amortizes O(N_files) cost
681
- // without needing a dedicated SessionStart hook.
682
- if (Math.random() < 0.05) {
683
- sweepStaleRepetitions(markerDir).catch(() => {});
684
- }
685
- // Return entries at/over threshold
686
- const blocking = [];
687
- for (const entry of map.values()) {
688
- if (entry.count >= REPETITION_BLOCKING_THRESHOLD) {
689
- blocking.push({ file, hash: entry.hash, count: entry.count });
690
- }
691
- }
692
- return blocking;
693
- }
694
-
695
- // Drop shard files with mtime older than REPETITIONS_TTL_MS. Closes
696
- // ADR-096 multi-review finding #2 (unbounded growth — entries leaked
697
- // for files never re-reviewed). Runs probabilistically from
698
- // updateRepetitions; best-effort, never throws.
699
- export async function sweepStaleRepetitions(markerDir) {
700
- const root = repetitionsShardsRoot(markerDir);
701
- try {
702
- const files = await readdir(root);
703
- const cutoff = Date.now() - REPETITIONS_TTL_MS;
704
- for (const f of files) {
705
- if (!f.endsWith(".json")) continue;
706
- const full = join(root, f);
707
- try {
708
- const s = await stat(full);
709
- if (s.mtimeMs < cutoff) await unlink(full);
710
- } catch {
711
- // skip unreadable / racing-unlink
712
- }
713
- }
714
- } catch {
715
- // shards dir doesn't exist yet — nothing to sweep
716
- }
717
- }
718
-
719
- // Backward-compat shims for the v1 singleton API. Used by tests that
720
- // haven't migrated yet. New code should use the per-file shard helpers.
721
- export function loadRepetitions(markerDir) {
722
- // v1 singleton is dead — return empty Map. Any v1 file is treated as
723
- // stale and ignored. The TTL sweep will not touch it (different path)
724
- // but it's small and harmless; documented as a known dangling artifact.
725
- // Tests that exercised v1 semantics need to migrate to loadRepetitionsForFile.
726
- void markerDir;
727
- return new Map();
728
- }
729
- export async function saveRepetitions(markerDir, map) {
730
- // v1 no-op. Documented as dead in ADR-097.
731
- void markerDir;
732
- void map;
733
- }