@ask-llm/plugin 0.15.0 → 0.16.3
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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +979 -0
- package/README.md +2 -0
- package/agents/brainstorm-coordinator.md +1 -1
- package/agents/gemini-reviewer.md +1 -1
- package/dist/antigravity-run.js +0 -0
- package/dist/brainstorm-run.js +0 -0
- package/dist/codex-run.js +0 -0
- package/dist/grok-run.js +0 -0
- package/dist/ollama-run.js +0 -0
- package/dist/run.js +0 -0
- package/package.json +14 -14
- package/pi/extensions/provider-tools.ts +1 -1
- package/scripts/benchmark/README.md +114 -0
- package/scripts/benchmark/fixtures/README.md +29 -0
- package/scripts/codex-pair-debounce-worker.mjs +0 -0
- package/scripts/codex-pair-log.mjs +4 -13
- package/scripts/codex-pair-prompt-drain.mjs +1 -1
- package/scripts/codex-pair-session.mjs +2 -2
- package/scripts/codex-pair-stop-gate.mjs +8 -8
- package/scripts/codex-pair-watch.mjs +20 -39
- package/skills/gemini-review/SKILL.md +1 -1
- package/scripts/lib/broker-lifecycle.mjs +0 -575
- package/scripts/lib/broker-rpc.mjs +0 -203
- package/scripts/lib/broker-transport.mjs +0 -407
- package/scripts/lib/broker.mjs +0 -537
- package/scripts/lib/debounce-state.mjs +0 -208
- package/scripts/lib/parser.d.mts +0 -12
- package/scripts/lib/parser.mjs +0 -229
- package/scripts/lib/process.mjs +0 -56
- package/scripts/lib/prompt.d.mts +0 -8
- package/scripts/lib/prompt.mjs +0 -41
- package/scripts/lib/session-registry.mjs +0 -162
- package/scripts/lib/state.d.mts +0 -58
- package/scripts/lib/state.mjs +0 -733
- package/scripts/lib/stop-gate.mjs +0 -134
package/scripts/lib/state.mjs
DELETED
|
@@ -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
|
-
}
|