@ask-llm/plugin 0.13.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.
Files changed (76) hide show
  1. package/.claude-plugin/plugin.json +20 -0
  2. package/.mcp.json +3 -0
  3. package/LICENSE +21 -0
  4. package/README.md +135 -0
  5. package/agents/antigravity-reviewer.md +139 -0
  6. package/agents/brainstorm-coordinator.md +305 -0
  7. package/agents/codex-reviewer.md +194 -0
  8. package/agents/codex-verifier.md +149 -0
  9. package/agents/fable-reviewer.md +44 -0
  10. package/agents/gemini-reviewer.md +130 -0
  11. package/agents/ollama-reviewer.md +131 -0
  12. package/agents/sol-reviewer.md +60 -0
  13. package/codex-pair-defaults.json +4 -0
  14. package/dist/antigravity-run.d.ts +3 -0
  15. package/dist/antigravity-run.d.ts.map +1 -0
  16. package/dist/antigravity-run.js +32 -0
  17. package/dist/antigravity-run.js.map +1 -0
  18. package/dist/codex-run.d.ts +3 -0
  19. package/dist/codex-run.d.ts.map +1 -0
  20. package/dist/codex-run.js +32 -0
  21. package/dist/codex-run.js.map +1 -0
  22. package/dist/index.d.ts +7 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +39 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ollama-run.d.ts +3 -0
  27. package/dist/ollama-run.d.ts.map +1 -0
  28. package/dist/ollama-run.js +32 -0
  29. package/dist/ollama-run.js.map +1 -0
  30. package/dist/run.d.ts +3 -0
  31. package/dist/run.d.ts.map +1 -0
  32. package/dist/run.js +32 -0
  33. package/dist/run.js.map +1 -0
  34. package/hooks/hooks.json +55 -0
  35. package/package.json +104 -0
  36. package/pi/extensions/codex-pair.ts +870 -0
  37. package/pi/extensions/index.ts +13 -0
  38. package/pi/extensions/provider-tools.ts +241 -0
  39. package/pi/tsconfig.json +10 -0
  40. package/prompts/review.txt +75 -0
  41. package/scripts/codex-pair-debounce-worker.mjs +103 -0
  42. package/scripts/codex-pair-log.mjs +271 -0
  43. package/scripts/codex-pair-prompt-drain.mjs +81 -0
  44. package/scripts/codex-pair-session.mjs +194 -0
  45. package/scripts/codex-pair-stop-gate.mjs +271 -0
  46. package/scripts/codex-pair-watch.mjs +1525 -0
  47. package/scripts/lib/broker-lifecycle.mjs +575 -0
  48. package/scripts/lib/broker-rpc.mjs +203 -0
  49. package/scripts/lib/broker-transport.mjs +407 -0
  50. package/scripts/lib/broker.mjs +537 -0
  51. package/scripts/lib/debounce-state.mjs +208 -0
  52. package/scripts/lib/parser.d.mts +12 -0
  53. package/scripts/lib/parser.mjs +229 -0
  54. package/scripts/lib/process.mjs +39 -0
  55. package/scripts/lib/prompt.d.mts +8 -0
  56. package/scripts/lib/prompt.mjs +41 -0
  57. package/scripts/lib/session-registry.mjs +162 -0
  58. package/scripts/lib/state.d.mts +58 -0
  59. package/scripts/lib/state.mjs +733 -0
  60. package/scripts/lib/stop-gate.mjs +134 -0
  61. package/skills/antigravity-review/SKILL.md +49 -0
  62. package/skills/brainstorm/SKILL.md +105 -0
  63. package/skills/brainstorm-all/SKILL.md +43 -0
  64. package/skills/codex-image/SKILL.md +120 -0
  65. package/skills/codex-pair/SKILL.md +315 -0
  66. package/skills/codex-pair-ack/SKILL.md +64 -0
  67. package/skills/codex-pair-pause/SKILL.md +62 -0
  68. package/skills/codex-pair-resume/SKILL.md +52 -0
  69. package/skills/codex-review/SKILL.md +52 -0
  70. package/skills/codex-verify/SKILL.md +110 -0
  71. package/skills/compare/SKILL.md +151 -0
  72. package/skills/fable-review/SKILL.md +42 -0
  73. package/skills/gemini-review/SKILL.md +40 -0
  74. package/skills/multi-review/SKILL.md +182 -0
  75. package/skills/ollama-review/SKILL.md +40 -0
  76. package/skills/sol-review/SKILL.md +41 -0
@@ -0,0 +1,1525 @@
1
+ #!/usr/bin/env node
2
+ // codex-pair-watch — production version of the POC hook.
3
+ //
4
+ // PostToolUse hook on Edit|Write|MultiEdit. The hook is always loaded but
5
+ // SELF-GATES on the presence of a `.codex-pair/context.md` marker file
6
+ // somewhere on the path from cwd up to the project root (ADR-092
7
+ // consolidates all hook state under `.codex-pair/`). No marker → exit
8
+ // silently (zero codex calls, zero cost). With marker → file is reviewed
9
+ // per the v2 prompt design (HIGH/MED/LOW grading, surface HIGH+MED, log all).
10
+ //
11
+ // Empirical justification: ADR-077. Four benchmark tasks documented on
12
+ // branch `experiment/codex-pair-poc`.
13
+ //
14
+ // Why no workspace imports: this script ships via marketplace as part of a
15
+ // `git-subdir` extraction with no `npm install` step, so workspace deps
16
+ // (`@ask-llm/codex-mcp/executor`, `@ask-llm/shared`) don't resolve. The codex
17
+ // invocation is inlined; semantics mirror `codexExecutor.ts` deliberately.
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { access, readFile } from "node:fs/promises";
21
+ import { readFileSync } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { fileURLToPath } from "node:url";
25
+ import { IS_WINDOWS, terminateProcessTree } from "./lib/process.mjs";
26
+ // M4: broker integration. Importing initializeBroker + isBrokerEnabled +
27
+ // submitReview from broker.mjs transitively pulls in broker-transport,
28
+ // broker-rpc, broker-lifecycle. ESM-static cost is paid on every hook
29
+ // fire, but isBrokerEnabled returns false fast when ASK_CODEX_BROKER
30
+ // isn't set, so the per-edit fast path is unaffected.
31
+ import { initializeBroker, isBrokerEnabled, readBrokerState, submitReview } from "./lib/broker.mjs";
32
+ import {
33
+ DEFAULT_DEBOUNCE_MS,
34
+ DEFAULT_DEBOUNCE_MAX_MS,
35
+ bumpEditRecord,
36
+ drainPending,
37
+ joinPendingForSurface,
38
+ markReviewed,
39
+ sweepStaleDebounce,
40
+ } from "./lib/debounce-state.mjs";
41
+ import { buildReviewPrompt } from "./lib/prompt.mjs";
42
+ import {
43
+ buildVerdictMessage,
44
+ DEFAULT_SURFACE_THRESHOLD,
45
+ formatDuration,
46
+ parseConcerns,
47
+ parseResetHint,
48
+ VALID_THRESHOLDS,
49
+ VERDICT_PREFIXES,
50
+ } from "./lib/parser.mjs";
51
+ import {
52
+ appendLog,
53
+ AUTOPAUSE_FAILURE_THRESHOLD,
54
+ clearAutoPause,
55
+ clearReviewFailures,
56
+ computeCacheKey,
57
+ CONTEXT_FILENAME,
58
+ contextPath,
59
+ getBlockingFromShard,
60
+ getCachedConcerns,
61
+ hashConcernBody,
62
+ ignorePath,
63
+ includePath,
64
+ INFLIGHT_TTL_MIN_MS,
65
+ logPath,
66
+ PAIR_ROOT_DIR,
67
+ readPauseInfo,
68
+ readPluginVersion,
69
+ recordReviewFailure,
70
+ releaseInflightLock,
71
+ resolveAutoResume,
72
+ setCachedConcerns,
73
+ tryAcquireInflightLock,
74
+ updateRepetitions,
75
+ writeAutoPause,
76
+ } from "./lib/state.mjs";
77
+ import { registerMarker } from "./lib/session-registry.mjs";
78
+
79
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
80
+ const DEFAULTS_PATH = join(SCRIPT_DIR, "..", "codex-pair-defaults.json");
81
+
82
+ // codex-pair-defaults.json carries the canonical default + fallback model
83
+ // names so the hook stays in sync with codex-mcp/src/constants.ts:MODELS
84
+ // without duplicating literals across files. A structural test links the
85
+ // JSON values to constants.ts so drift fails CI. If the file is missing or
86
+ // malformed, fall through to env vars and hardcoded literals.
87
+ let CODEX_PAIR_DEFAULTS = { model: "gpt-5.6-sol", fallbackModel: "gpt-5.6-terra" };
88
+ try {
89
+ CODEX_PAIR_DEFAULTS = JSON.parse(readFileSync(DEFAULTS_PATH, "utf8"));
90
+ } catch {
91
+ // intentional fallback to inline defaults
92
+ }
93
+
94
+ // ADR-092: marker is the consolidated `.codex-pair/context.md` path. The
95
+ // hook walks up looking for this nested file (presence enables review,
96
+ // content is the project context sent to codex).
97
+ const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
98
+ const WATCHED_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
99
+ const DEFAULT_MODEL = process.env.ASK_CODEX_MODEL ?? CODEX_PAIR_DEFAULTS.model;
100
+ const FALLBACK_MODEL = process.env.ASK_CODEX_FALLBACK_MODEL ?? CODEX_PAIR_DEFAULTS.fallbackModel;
101
+ const CODEX_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
102
+ const configuredReasoningEffort = process.env.ASK_CODEX_REASONING_EFFORT;
103
+ const DEFAULT_REASONING_EFFORT = CODEX_REASONING_EFFORTS.has(configuredReasoningEffort)
104
+ ? configuredReasoningEffort
105
+ : "medium";
106
+ const DEFAULT_TIMEOUT_MS = Number(process.env.ASK_CODEX_TIMEOUT_MS ?? 800_000);
107
+ const MAX_FILE_BYTES = Number(process.env.CODEX_PAIR_MAX_FILE_BYTES ?? 20_000);
108
+ const DEBOUNCE_MS = Number(process.env.ASK_CODEX_DEBOUNCE_MS ?? DEFAULT_DEBOUNCE_MS);
109
+ const DEBOUNCE_MAX_MS = Number(process.env.ASK_CODEX_DEBOUNCE_MAX_MS ?? DEFAULT_DEBOUNCE_MAX_MS);
110
+ const QUOTA_SIGNALS = [
111
+ "rate_limit_exceeded",
112
+ "quota_exceeded",
113
+ "429",
114
+ "insufficient_quota",
115
+ // ChatGPT-plan phrasings (#176) — API-style signals above never match these.
116
+ "usage limit",
117
+ "rate limit",
118
+ ];
119
+
120
+ // A configured fallback model can be structurally unavailable on some Codex
121
+ // account types — e.g. gpt-5.5-mini is rejected with a 400 on ChatGPT-plan
122
+ // accounts (where quota is account-wide, so a cheaper fallback never applied).
123
+ // The built-in GPT-5.6 Terra fallback avoids that legacy pin, but a user can
124
+ // still configure an unavailable model via ASK_CODEX_FALLBACK_MODEL — this guard
125
+ // keeps that case graceful. Matched only on
126
+ // the FALLBACK leg after a primary quota error: it means the fallback ladder is
127
+ // broken, i.e. the same "no usable model" exhaustion.
128
+ const MODEL_UNAVAILABLE_SIGNALS = ["is not supported when using codex with a chatgpt"];
129
+
130
+ // Transient failure signatures (item #10). Errors matching any of these get
131
+ // ONE retry with jittered delay before propagating. Quota errors take the
132
+ // existing model-fallback path (not retry — quota exhaustion isn't transient).
133
+ // Hook-side timeouts and JSONL parse failures are excluded by verdict tag
134
+ // (see isTransientError) — those are deterministic failures that retry can't fix.
135
+ const TRANSIENT_SIGNALS = [
136
+ /ECONNRESET/,
137
+ /ECONNREFUSED/,
138
+ /ETIMEDOUT/,
139
+ /EAI_AGAIN/,
140
+ /UND_ERR/,
141
+ /\b502\b/,
142
+ /\b503\b/,
143
+ /\b504\b/,
144
+ ];
145
+
146
+ // Cache, log, pause, and inflight-lock state live in ./lib/state.mjs.
147
+ // The hook imports computeCacheKey, getCachedConcerns, setCachedConcerns,
148
+ // appendLog, readPauseInfo, tryAcquireInflightLock, releaseInflightLock, and
149
+ // INFLIGHT_TTL_MIN_MS at the top of this file.
150
+
151
+ // Marker-walk anchor for the unhandled-exception catch handler. main() sets
152
+ // this to `dirname(filePath)` once the payload is validated; the catch
153
+ // handler at the bottom of the file reads it to write diagnostics into the
154
+ // correct repo's log (the edited file's repo, not cwd's). If main() throws
155
+ // before payload parsing, this stays null and the catch falls back to cwd.
156
+ // See multi-review feedback on PR #76 — both Gemini and Codex flagged the
157
+ // previous cwd-only catch path as a residual cross-repo gap.
158
+ let markerAnchor = null;
159
+
160
+ // Closed verdict set + presentation prefixes live in ./lib/parser.mjs
161
+ // (VERDICT_PREFIXES). The hook imports them at the top.
162
+
163
+ const SKIP_PATTERNS = [
164
+ // Path patterns — leading/trailing slash guards against substring matches
165
+ "/node_modules/",
166
+ "/dist/",
167
+ "/.git/",
168
+ // Lockfiles by exact filename
169
+ "yarn.lock",
170
+ "package-lock.json",
171
+ "pnpm-lock.yaml",
172
+ "Cargo.lock",
173
+ "Gemfile.lock",
174
+ "composer.lock",
175
+ "poetry.lock",
176
+ "go.sum",
177
+ // Images
178
+ ".png",
179
+ ".jpg",
180
+ ".jpeg",
181
+ ".gif",
182
+ ".svg",
183
+ ".ico",
184
+ // Fonts
185
+ ".woff",
186
+ ".woff2",
187
+ ".ttf",
188
+ ".otf",
189
+ ".eot",
190
+ // Documents + archives
191
+ ".pdf",
192
+ ".zip",
193
+ ".tar",
194
+ ".gz",
195
+ // Snapshots, sourcemaps, minified assets
196
+ ".snap",
197
+ ".map",
198
+ ".min.js",
199
+ ".min.css",
200
+ // Generic .lock catch-all (matches anything ending in .lock)
201
+ ".lock",
202
+ ];
203
+
204
+ async function readStdin() {
205
+ return new Promise((resolveRead) => {
206
+ let data = "";
207
+ process.stdin.on("data", (chunk) => {
208
+ data += chunk.toString();
209
+ });
210
+ process.stdin.on("end", () => resolveRead(data));
211
+ process.stdin.on("error", () => resolveRead(""));
212
+ });
213
+ }
214
+
215
+ // One-shot prefix folded into the NEXT emission. A hook run may emit at most
216
+ // ONE JSON object on stdout (two objects make the whole output unparseable to
217
+ // Claude Code), so the mid-session auto-resume notice rides on whichever
218
+ // single emission the run produces instead of being its own line.
219
+ let noticePrefix = null;
220
+
221
+ // Surface a one-line (or multi-line) notice on BOTH hook channels by emitting
222
+ // hook JSON to stdout. `systemMessage` renders in the user's transcript only —
223
+ // Claude Code does NOT inject it into the model's context — so the same text
224
+ // also goes out as PostToolUse `hookSpecificOutput.additionalContext`, the
225
+ // channel the model actually receives. Without the second channel every
226
+ // verdict was invisible to the pairing partner (the whole point of the hook).
227
+ // We await the write-callback so the bytes are flushed to the parent before
228
+ // process.exit terminates us.
229
+ function emitSystemMessage(text) {
230
+ let full = text;
231
+ if (noticePrefix) {
232
+ full = text ? `${noticePrefix}\n\n${text}` : noticePrefix;
233
+ noticePrefix = null;
234
+ }
235
+ return new Promise((resolveWrite) => {
236
+ const payload = JSON.stringify({
237
+ continue: true,
238
+ systemMessage: full,
239
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: full },
240
+ });
241
+ process.stdout.write(`${payload}\n`, () => resolveWrite());
242
+ });
243
+ }
244
+
245
+ // Flush a queued notice (auto-resume etc.) as the run's single JSON object.
246
+ // No-op when nothing is queued, so every silent-exit path can call it
247
+ // unconditionally — the emission is never empty (the notice IS the content).
248
+ function flushNoticeOnly() {
249
+ if (!noticePrefix) return Promise.resolve();
250
+ return emitSystemMessage("");
251
+ }
252
+
253
+ // formatDuration + buildVerdictMessage live in ./lib/parser.mjs.
254
+
255
+ // Zero-dependency YAML frontmatter parser. Recognizes an opening `---` on
256
+ // line 1, parses flat key:value lines, stops at the closing `---`. No nested
257
+ // structures, no arrays, no multi-line values. Returns { frontmatter, body,
258
+ // malformed }. `malformed` flips true when an opener exists with no closer —
259
+ // the caller can log a warning and fall through to defaults.
260
+ function parseFrontmatter(content) {
261
+ if (typeof content !== "string" || content.length === 0) {
262
+ return { frontmatter: {}, body: "", malformed: false };
263
+ }
264
+ const firstNewline = content.indexOf("\n");
265
+ if (firstNewline === -1) return { frontmatter: {}, body: content, malformed: false };
266
+ const opener = content.slice(0, firstNewline).replace(/\r$/, "");
267
+ if (opener !== "---") return { frontmatter: {}, body: content, malformed: false };
268
+
269
+ const rest = content.slice(firstNewline + 1);
270
+ const closerMatch = rest.match(/^---\s*$/m);
271
+ if (!closerMatch || typeof closerMatch.index !== "number") {
272
+ return { frontmatter: {}, body: content, malformed: true };
273
+ }
274
+
275
+ const fmText = rest.slice(0, closerMatch.index);
276
+ let body = rest.slice(closerMatch.index + closerMatch[0].length);
277
+ if (body.startsWith("\r")) body = body.slice(1);
278
+ if (body.startsWith("\n")) body = body.slice(1);
279
+
280
+ const frontmatter = {};
281
+ for (const rawLine of fmText.split("\n")) {
282
+ const line = rawLine.replace(/\r$/, "");
283
+ const trimmed = line.trim();
284
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
285
+ const colon = line.indexOf(":");
286
+ if (colon === -1) continue;
287
+ const key = line.slice(0, colon).trim();
288
+ if (key.length === 0) continue;
289
+ let valueRaw = line.slice(colon + 1);
290
+ // Strip inline comment, but only when `#` follows whitespace.
291
+ const inlineComment = valueRaw.match(/\s+#.*$/);
292
+ if (inlineComment && typeof inlineComment.index === "number") {
293
+ valueRaw = valueRaw.slice(0, inlineComment.index);
294
+ }
295
+ let value = valueRaw.trim();
296
+ if (
297
+ (value.startsWith('"') && value.endsWith('"')) ||
298
+ (value.startsWith("'") && value.endsWith("'"))
299
+ ) {
300
+ value = value.slice(1, -1);
301
+ }
302
+ if (value === "true") frontmatter[key] = true;
303
+ else if (value === "false") frontmatter[key] = false;
304
+ else if (/^-?\d+$/.test(value)) frontmatter[key] = Number(value);
305
+ else if (/^-?\d+\.\d+$/.test(value)) frontmatter[key] = Number(value);
306
+ else frontmatter[key] = value;
307
+ }
308
+ return { frontmatter, body, malformed: false };
309
+ }
310
+
311
+ // Spawn `git diff -U<n> HEAD -- <filePath>` with a hard timeout. Returns the
312
+ // diff output as a string, or null on any failure (not a repo, untracked file,
313
+ // git binary missing, timeout, non-zero exit). Never throws. Process-tree
314
+ // termination is provided by ./lib/process.mjs (ADR-084 / ADR-088).
315
+ function runGitDiff({ filePath, contextLines, cwd, timeoutMs }) {
316
+ return new Promise((resolveDiff) => {
317
+ let stdout = "";
318
+ let settled = false;
319
+ const child = spawn("git", ["diff", `-U${contextLines}`, "HEAD", "--", filePath], {
320
+ stdio: ["ignore", "pipe", "pipe"],
321
+ cwd,
322
+ // detached:true on POSIX makes the child a process-group leader so
323
+ // terminateProcessTree can reach grandchildren via negative-PID kill.
324
+ detached: !IS_WINDOWS,
325
+ });
326
+ child.stdout.on("data", (chunk) => {
327
+ stdout += chunk.toString();
328
+ });
329
+ child.stderr.on("data", () => {
330
+ // discard stderr — we only care about successful diff output
331
+ });
332
+ const timer = setTimeout(() => {
333
+ if (settled) return;
334
+ settled = true;
335
+ terminateProcessTree(child, "SIGTERM");
336
+ resolveDiff(null);
337
+ }, timeoutMs);
338
+ child.on("error", () => {
339
+ if (settled) return;
340
+ settled = true;
341
+ clearTimeout(timer);
342
+ resolveDiff(null);
343
+ });
344
+ child.on("close", (code) => {
345
+ if (settled) return;
346
+ settled = true;
347
+ clearTimeout(timer);
348
+ if (code === 0 && stdout.length > 0) {
349
+ resolveDiff(stdout);
350
+ } else {
351
+ resolveDiff(null);
352
+ }
353
+ });
354
+ });
355
+ }
356
+
357
+ // Build a partial-view payload for files that exceed the size cap. Three modes:
358
+ // - "diff": file is tracked in git and `git diff -U20` returned something
359
+ // useful. Sends `header-80-lines + diff-against-HEAD`.
360
+ // - "head-tail": file is untracked, git unavailable, or diff was too large.
361
+ // Sends `head-150 + omission marker + tail-80`.
362
+ // - "truncated": file has few lines but is still over the byte cap (e.g.,
363
+ // one massive minified line). Sends a hard-truncated slice.
364
+ // Caller is responsible for the `partialView: true` flag on `buildPrompt`.
365
+ async function buildAdaptiveContext({ filePath, fileContent, markerDir, maxFileBytes }) {
366
+ const diff = await runGitDiff({
367
+ filePath,
368
+ contextLines: 20,
369
+ cwd: markerDir,
370
+ timeoutMs: 5000,
371
+ });
372
+ const headerLines = fileContent.split("\n").slice(0, 80);
373
+ const headerText = headerLines.join("\n");
374
+ if (diff && Buffer.byteLength(diff, "utf8") < maxFileBytes) {
375
+ return {
376
+ strategy: "diff",
377
+ content: `<file_header_first_80_lines>\n${headerText}\n</file_header_first_80_lines>\n\n<diff_against_head>\n${diff}\n</diff_against_head>`,
378
+ };
379
+ }
380
+ const lines = fileContent.split("\n");
381
+ if (lines.length <= 230) {
382
+ const truncated = fileContent.slice(0, maxFileBytes);
383
+ return {
384
+ strategy: "truncated",
385
+ content: `<file_partial_view_truncated>\n${truncated}\n[... rest of file truncated due to size cap ...]\n</file_partial_view_truncated>`,
386
+ };
387
+ }
388
+ const head = lines.slice(0, 150).join("\n");
389
+ const tail = lines.slice(-80).join("\n");
390
+ const omitted = lines.length - 230;
391
+ return {
392
+ strategy: "head-tail",
393
+ content: `<file_head_first_150_lines>\n${head}\n</file_head_first_150_lines>\n\n[... ${omitted} lines omitted ...]\n\n<file_tail_last_80_lines>\n${tail}\n</file_tail_last_80_lines>`,
394
+ };
395
+ }
396
+
397
+ // readPauseInfo, inflightLockPath, tryAcquireInflightLock, releaseInflightLock
398
+ // all live in ./lib/state.mjs.
399
+
400
+ // Read `.codex-pair/ignore` from the marker directory if present. Returns an
401
+ // array of rule objects in declaration order. Missing file / read error →
402
+ // empty array. Comments (`#` lines) and blank lines are filtered out. Each
403
+ // rule carries `{ negate, pattern, raw }`. Per-project, single file — no
404
+ // nested ignore-file traversal in subdirs (the marker is the project anchor).
405
+ // Generic gitignore-style rule parser used for BOTH .codex-pair/ignore
406
+ // (ADR-081 exclusion-list) AND .codex-pair/include (ADR-096 inclusion-list).
407
+ function readGlobRulesFile(absolutePath) {
408
+ let content;
409
+ try {
410
+ content = readFileSync(absolutePath, "utf8");
411
+ } catch {
412
+ return [];
413
+ }
414
+ const rules = [];
415
+ for (const rawLine of content.split("\n")) {
416
+ const line = rawLine.replace(/\r$/, "").trim();
417
+ if (line.length === 0 || line.startsWith("#")) continue;
418
+ const negate = line.startsWith("!");
419
+ const pattern = negate ? line.slice(1) : line;
420
+ if (pattern.length === 0) continue;
421
+ rules.push({ negate, pattern, raw: line });
422
+ }
423
+ return rules;
424
+ }
425
+
426
+ function readIgnoreFile(markerDir) {
427
+ return readGlobRulesFile(ignorePath(markerDir));
428
+ }
429
+
430
+ // ADR-096: inclusion-list mirror of ignore-list. When `.codex-pair/include`
431
+ // exists AND has at least one non-comment rule, ONLY files matching at
432
+ // least one rule are reviewed. Empty/missing = no scoping (review everything).
433
+ function readIncludeFile(markerDir) {
434
+ return readGlobRulesFile(includePath(markerDir));
435
+ }
436
+
437
+ // Convert a gitignore-style glob into a JS RegExp. Handles `*` (any chars
438
+ // except `/`), `**` (any chars including `/`), `?` (single char except `/`),
439
+ // `[abc]` character class, leading `/` anchors to marker dir, trailing `/`
440
+ // matches directory contents. Does NOT support the full gitignore spec —
441
+ // the common cases work; weird precedence edge cases are out of scope.
442
+ function globToRegex(pattern) {
443
+ const anchored = pattern.startsWith("/");
444
+ const trailingSlash = pattern.endsWith("/");
445
+ let p = anchored ? pattern.slice(1) : pattern;
446
+ if (trailingSlash) p = p.slice(0, -1);
447
+ let body = "";
448
+ for (let i = 0; i < p.length; i++) {
449
+ const c = p[i];
450
+ if (c === "*") {
451
+ if (p[i + 1] === "*") {
452
+ body += ".*";
453
+ i++;
454
+ if (p[i + 1] === "/") i++;
455
+ } else {
456
+ body += "[^/]*";
457
+ }
458
+ } else if (c === "?") {
459
+ body += "[^/]";
460
+ } else if (c === "[") {
461
+ const end = p.indexOf("]", i);
462
+ if (end === -1) {
463
+ body += "\\[";
464
+ } else {
465
+ body += p.slice(i, end + 1);
466
+ i = end;
467
+ }
468
+ } else if ("().+|^$\\".includes(c)) {
469
+ body += `\\${c}`;
470
+ } else {
471
+ body += c;
472
+ }
473
+ }
474
+ const prefix = anchored ? "^" : "(^|.*/)";
475
+ const suffix = "(/.*)?$";
476
+ return new RegExp(prefix + body + suffix);
477
+ }
478
+
479
+ // Walk rules in declaration order; the last matching rule wins. If that last
480
+ // matching rule is a negation (!pattern), the file is NOT ignored. Returns
481
+ // the matching rule object or null if no rule matches (or final match is a
482
+ // negation). `filePath` is normalized to a marker-relative path.
483
+ function matchesIgnoreRule(filePath, markerDir, rules) {
484
+ if (rules.length === 0) return null;
485
+ let rel = filePath;
486
+ const prefix = `${markerDir}/`;
487
+ if (filePath.startsWith(prefix)) {
488
+ rel = filePath.slice(prefix.length);
489
+ }
490
+ let lastMatch = null;
491
+ for (const rule of rules) {
492
+ if (globToRegex(rule.pattern).test(rel)) {
493
+ lastMatch = rule;
494
+ }
495
+ }
496
+ if (lastMatch?.negate) return null;
497
+ return lastMatch;
498
+ }
499
+
500
+ // Resolve runtime config per-marker. Precedence: frontmatter > env > default.
501
+ // Invalid types in frontmatter are silently ignored (fall through to env/default).
502
+ function resolveConfig(frontmatter) {
503
+ const fm = frontmatter ?? {};
504
+ const surfaceCandidate = typeof fm.surfaceThreshold === "string" ? fm.surfaceThreshold : null;
505
+ return {
506
+ model: typeof fm.model === "string" && fm.model.length > 0 ? fm.model : DEFAULT_MODEL,
507
+ fallbackModel:
508
+ typeof fm.fallbackModel === "string" && fm.fallbackModel.length > 0
509
+ ? fm.fallbackModel
510
+ : FALLBACK_MODEL,
511
+ timeoutMs:
512
+ typeof fm.timeoutMs === "number" && fm.timeoutMs > 0 ? fm.timeoutMs : DEFAULT_TIMEOUT_MS,
513
+ maxFileBytes:
514
+ typeof fm.maxFileBytes === "number" && fm.maxFileBytes > 0
515
+ ? fm.maxFileBytes
516
+ : MAX_FILE_BYTES,
517
+ surfaceThreshold:
518
+ surfaceCandidate && VALID_THRESHOLDS.has(surfaceCandidate)
519
+ ? surfaceCandidate
520
+ : DEFAULT_SURFACE_THRESHOLD,
521
+ debounceMs:
522
+ typeof fm.debounceMs === "number" && fm.debounceMs >= 0 ? fm.debounceMs : DEBOUNCE_MS,
523
+ debounceMaxMs:
524
+ typeof fm.debounceMaxMs === "number" && fm.debounceMaxMs > 0 ? fm.debounceMaxMs : DEBOUNCE_MAX_MS,
525
+ };
526
+ }
527
+
528
+ // Walks up from `startDir` looking for `<dir>/.codex-pair/context.md`.
529
+ // Returns the PROJECT ROOT (the directory that holds `.codex-pair/`) or
530
+ // null when nothing is found within 20 levels or once we hit $HOME.
531
+ async function findMarkerUp(startDir) {
532
+ const home = homedir();
533
+ let current = resolve(startDir);
534
+ for (let depth = 0; depth < 20; depth++) {
535
+ const candidate = join(current, MARKER_FILE);
536
+ try {
537
+ await access(candidate);
538
+ return current;
539
+ } catch {
540
+ // not found at this level
541
+ }
542
+ const parent = dirname(current);
543
+ if (parent === current) return null;
544
+ if (current === home) return null;
545
+ current = parent;
546
+ }
547
+ return null;
548
+ }
549
+
550
+ // ADR-089: the prompt template is now externalized at prompts/review.txt and
551
+ // rendered by ./lib/prompt.mjs. The hook keeps `buildPrompt` as a thin
552
+ // pass-through so callers don't change — and so structural tests that pin
553
+ // the call site stay readable.
554
+ function buildPrompt(args) {
555
+ return buildReviewPrompt(args);
556
+ }
557
+
558
+ // ADR-083 JSON-first parser (tryExtractJson, parseConcernsJson,
559
+ // parseConcernsLegacy, parseConcerns, formatFindingBody) lives in
560
+ // ./lib/parser.mjs and is imported at the top of this file.
561
+
562
+ // Cache + log helpers (computeCacheKey/cachePathFor/getCachedConcerns/
563
+ // setCachedConcerns/evictCacheOldest/rotateLogIfNeeded/clampReason/appendLog)
564
+ // all live in ./lib/state.mjs.
565
+
566
+ // Build codex CLI args. Mirrors packages/codex-mcp/src/utils/codexExecutor.ts
567
+ // `buildArgs` for the no-session, stdin-prompt case (hook always passes prompt
568
+ // via stdin to avoid ARG_MAX limits on file-content-heavy prompts).
569
+ function buildCodexArgs(model) {
570
+ const args = ["exec", "--skip-git-repo-check", "--ephemeral"];
571
+ if (process.env.ASK_CODEX_LOAD_USER_CONFIG !== "1") {
572
+ args.push("--ignore-user-config", "--ignore-rules");
573
+ }
574
+ // codex-pair only reviews the edited file. The pairing partner applies fixes;
575
+ // Codex must never mutate the workspace while evaluating them (ADR-136).
576
+ args.push(
577
+ "--sandbox",
578
+ "read-only",
579
+ "-c",
580
+ `model_reasoning_effort="${DEFAULT_REASONING_EFFORT}"`,
581
+ "--json",
582
+ "-m",
583
+ model,
584
+ );
585
+ return args;
586
+ }
587
+
588
+ // Parse codex `--json` JSONL stdout. Pulled from `codexExecutor.ts`
589
+ // `parseCodexJsonlOutput`: the agent's final answer is the last
590
+ // `item.completed` event whose `item.type === "agent_message"`.
591
+ function parseCodexJsonl(stdout) {
592
+ const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
593
+ let lastAgentMessage;
594
+ let lastError;
595
+ for (const line of lines) {
596
+ let parsed;
597
+ try {
598
+ parsed = JSON.parse(line);
599
+ } catch {
600
+ continue;
601
+ }
602
+ if (parsed?.type === "item.completed") {
603
+ const item = parsed.item;
604
+ if (item?.type === "agent_message" && typeof item.text === "string" && item.text.length > 0) {
605
+ lastAgentMessage = item.text;
606
+ }
607
+ }
608
+ if (parsed?.type === "error") {
609
+ lastError = JSON.stringify(parsed);
610
+ }
611
+ }
612
+ if (lastError && !lastAgentMessage) {
613
+ throw new Error(`Codex error event: ${lastError}`);
614
+ }
615
+ return lastAgentMessage ?? stdout;
616
+ }
617
+
618
+ function isQuotaError(err) {
619
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
620
+ return QUOTA_SIGNALS.some((sig) => msg.includes(sig));
621
+ }
622
+
623
+ function isModelUnavailableError(err) {
624
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
625
+ return MODEL_UNAVAILABLE_SIGNALS.some((sig) => msg.includes(sig));
626
+ }
627
+
628
+ // Transient = retryable. Excludes hook-side timeout and parse_failed by
629
+ // verdict tag (those are deterministic and retry can't help). Quota errors
630
+ // take the model-fallback path instead — they're not transient either.
631
+ function isTransientError(err) {
632
+ if (err && typeof err === "object") {
633
+ if (err.verdict === "timeout" || err.verdict === "parse_failed") return false;
634
+ }
635
+ if (isQuotaError(err)) return false;
636
+ const msg = err instanceof Error ? err.message : String(err);
637
+ return TRANSIENT_SIGNALS.some((sig) => sig.test(msg));
638
+ }
639
+
640
+ function sleepMs(ms) {
641
+ return new Promise((r) => {
642
+ setTimeout(r, ms);
643
+ });
644
+ }
645
+
646
+ // Attach a verdict tag to an Error so the main() catch can classify the
647
+ // failure into the closed VERDICT_PREFIXES set without re-parsing the message.
648
+ function taggedError(message, verdict) {
649
+ const err = new Error(message);
650
+ err.verdict = verdict;
651
+ return err;
652
+ }
653
+
654
+ function verdictFromError(err) {
655
+ if (err && typeof err === "object" && typeof err.verdict === "string" && err.verdict in VERDICT_PREFIXES) {
656
+ return err.verdict;
657
+ }
658
+ return "error";
659
+ }
660
+
661
+ // Pull `{"type":"error"}` event messages out of codex --json stdout. On a
662
+ // non-zero exit the real failure reason is usually HERE, while stderr holds
663
+ // only the "Reading prompt from stdin..." banner (#176).
664
+ function extractJsonlErrorEvents(stdout) {
665
+ const messages = [];
666
+ for (const line of stdout.split("\n")) {
667
+ if (line.trim().length === 0) continue;
668
+ let parsed;
669
+ try {
670
+ parsed = JSON.parse(line);
671
+ } catch {
672
+ continue;
673
+ }
674
+ if (parsed?.type === "error") {
675
+ messages.push(typeof parsed.message === "string" ? parsed.message : JSON.stringify(parsed));
676
+ }
677
+ }
678
+ return messages;
679
+ }
680
+
681
+ // Last non-empty stderr lines (≤3), capped at 500 chars. The informative
682
+ // part of codex stderr is the TAIL. Capped at 500 chars here; clampReason
683
+ // applies the UTF-8 byte clamp downstream before the reason is logged.
684
+ function stderrTail(stderr) {
685
+ const lines = stderr
686
+ .split("\n")
687
+ .map((l) => l.trim())
688
+ .filter((l) => l.length > 0);
689
+ if (lines.length === 0) return "";
690
+ const tail = lines.slice(-3).join(" | ");
691
+ return tail.length > 500 ? tail.slice(-500) : tail;
692
+ }
693
+
694
+ // Single codex invocation. The stdio + stdin-end pattern (and the SIGTERM →
695
+ // SIGKILL escalation, now tree-aware per ADR-084) mirrors
696
+ // `packages/shared/src/commandExecutor.ts`. Critically: stdin must be "pipe"
697
+ // (not "ignore") and must be ended explicitly, otherwise codex hangs on its
698
+ // stdin probe (issue #19 / first-hand observation: stdout stalls at
699
+ // "Reading additional input from stdin..." indefinitely).
700
+ function spawnCodex({ prompt, model, timeoutMs }) {
701
+ return new Promise((resolveCall, rejectCall) => {
702
+ const args = buildCodexArgs(model);
703
+ const child = spawn("codex", args, {
704
+ stdio: ["pipe", "pipe", "pipe"],
705
+ // ADR-084: process-group leader on POSIX so terminateProcessTree can
706
+ // kill codex + the Rust subprocess + any git-via-codex grandchildren.
707
+ detached: !IS_WINDOWS,
708
+ });
709
+
710
+ let stdout = "";
711
+ let stderr = "";
712
+ let settled = false;
713
+
714
+ child.stdin.on("error", () => {});
715
+ child.stdin.write(prompt);
716
+ child.stdin.end();
717
+
718
+ child.stdout.on("data", (chunk) => {
719
+ stdout += chunk.toString();
720
+ });
721
+ child.stderr.on("data", (chunk) => {
722
+ stderr += chunk.toString();
723
+ });
724
+
725
+ const timer = setTimeout(() => {
726
+ if (settled) return;
727
+ settled = true;
728
+ terminateProcessTree(child, "SIGTERM");
729
+ setTimeout(() => {
730
+ terminateProcessTree(child, "SIGKILL");
731
+ }, 5000);
732
+ rejectCall(
733
+ taggedError(`codex exec timed out after ${Math.round(timeoutMs / 1000)}s`, "timeout"),
734
+ );
735
+ }, timeoutMs);
736
+
737
+ child.on("error", (err) => {
738
+ if (settled) return;
739
+ settled = true;
740
+ clearTimeout(timer);
741
+ rejectCall(taggedError(`failed to spawn codex: ${err.message}`, "spawn_failed"));
742
+ });
743
+
744
+ child.on("close", (code) => {
745
+ if (settled) return;
746
+ settled = true;
747
+ clearTimeout(timer);
748
+ if (code === 0) {
749
+ try {
750
+ resolveCall(parseCodexJsonl(stdout));
751
+ } catch (err) {
752
+ const msg = err instanceof Error ? err.message : String(err);
753
+ rejectCall(taggedError(msg, "parse_failed"));
754
+ }
755
+ } else {
756
+ // Prefer the JSONL error event (the real reason) over the stderr
757
+ // tail (often just the stdin banner) — #176.
758
+ const errorEvents = extractJsonlErrorEvents(stdout);
759
+ const reason =
760
+ errorEvents.length > 0
761
+ ? errorEvents[errorEvents.length - 1]
762
+ : stderrTail(stderr) || `codex exit ${code}`;
763
+ rejectCall(taggedError(reason, "error"));
764
+ }
765
+ });
766
+ });
767
+ }
768
+
769
+ // Spawn codex with one retry on transient failures. The retry is jittered
770
+ // (1000 + Math.random()*1500 ms) to avoid synchronized retries across
771
+ // multiple concurrent hook invocations. Quota errors fall through unretried
772
+ // (handled by the outer fallback layer); hook-side timeouts and parse_failed
773
+ // errors are explicitly excluded by verdictFromError tag.
774
+ async function spawnCodexWithRetry({ prompt, model, timeoutMs, markerDir }) {
775
+ try {
776
+ return await spawnCodex({ prompt, model, timeoutMs });
777
+ } catch (err) {
778
+ if (!isTransientError(err)) throw err;
779
+ const reason = err instanceof Error ? err.message : String(err);
780
+ const delayMs = 1000 + Math.random() * 1500;
781
+ await appendLog(markerDir, {
782
+ timestamp: new Date().toISOString(),
783
+ verdict: "retried",
784
+ reason,
785
+ model,
786
+ delayMs: Math.round(delayMs),
787
+ });
788
+ await sleepMs(delayMs);
789
+ return await spawnCodex({ prompt, model, timeoutMs });
790
+ }
791
+ }
792
+
793
+ // M4: cached plugin clientInfo for broker handshake. Built once per
794
+ // process (per ADR-095, plugin version detection used to silently always
795
+ // return "unknown" before the ESM fix).
796
+ let _cachedBrokerClientInfo = null;
797
+ function brokerClientInfo() {
798
+ if (_cachedBrokerClientInfo) return _cachedBrokerClientInfo;
799
+ let v = "unknown";
800
+ try {
801
+ const here = dirname(fileURLToPath(import.meta.url));
802
+ const manifest = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
803
+ v = manifest?.version || "unknown";
804
+ } catch {
805
+ // best-effort
806
+ }
807
+ _cachedBrokerClientInfo = { name: "codex-pair", title: `codex-pair plugin v${v}`, version: v };
808
+ return _cachedBrokerClientInfo;
809
+ }
810
+
811
+ // M4: broker-path wrapper. Opens an RPC connection to the running
812
+ // `codex app-server`, calls submitReview, closes the connection.
813
+ // Connect/initialize failures get tagged with `err.brokerFailure = true`
814
+ // so runCodexWithFallback falls back to spawnCodex silently (ADR-077).
815
+ // Wall-clock budget is the same as spawnCodex's `timeoutMs`.
816
+ async function runWithBroker({ prompt, timeoutMs, model, markerDir }) {
817
+ const state = readBrokerState(markerDir);
818
+ if (!state) {
819
+ const err = new Error("runWithBroker: no broker descriptor");
820
+ err.brokerFailure = true;
821
+ err.brokerPhase = "connect";
822
+ throw err;
823
+ }
824
+ let connection = null;
825
+ let rpc = null;
826
+ try {
827
+ // Tight handshake budget — broker should be already running; if it
828
+ // takes more than 2s to handshake, treat as broken and fall back
829
+ // rather than blocking the hook (M4 brainstorm Risk #3).
830
+ const init = await initializeBroker(state.transportUrl, brokerClientInfo(), {
831
+ handshakeTimeoutMs: 2000,
832
+ initializeTimeoutMs: 2000,
833
+ });
834
+ connection = init.connection;
835
+ rpc = init.rpc;
836
+ } catch (err) {
837
+ if (err && typeof err === "object") {
838
+ err.brokerFailure = true;
839
+ err.brokerPhase = err.brokerPhase || "connect";
840
+ }
841
+ throw err;
842
+ }
843
+ try {
844
+ return await submitReview({
845
+ connection,
846
+ rpc,
847
+ cwd: markerDir,
848
+ // baseInstructions is folded into `prompt` by buildReviewPrompt
849
+ // already; passing empty string keeps the codex API happy.
850
+ baseInstructions: "",
851
+ prompt,
852
+ model,
853
+ timeoutMs,
854
+ });
855
+ } finally {
856
+ if (connection) {
857
+ try {
858
+ connection.close(1000, "review done");
859
+ } catch {
860
+ // best-effort
861
+ }
862
+ }
863
+ }
864
+ }
865
+
866
+ async function runCodexWithFallback({ prompt, timeoutMs, model, fallbackModel, markerDir }) {
867
+ // M4: try the broker first if enabled. On err.brokerFailure (transport,
868
+ // handshake, parse-layer failure) fall through to per-edit spawnCodex
869
+ // silently per ADR-077. On other errors (verdict:"error" from a real
870
+ // codex result, verdict:"timeout") propagate as-is — retrying via
871
+ // spawnCodex would double the spend on cases where the model
872
+ // legitimately couldn't produce a verdict.
873
+ if (isBrokerEnabled(markerDir)) {
874
+ try {
875
+ return {
876
+ response: await runWithBroker({ prompt, model, timeoutMs, markerDir }),
877
+ fellBack: false,
878
+ viaBroker: true,
879
+ };
880
+ } catch (err) {
881
+ if (!err?.brokerFailure) {
882
+ // The broker path has no fallback ladder — a real (non-transport)
883
+ // quota error here IS exhaustion, the same as the no-ladder spawn
884
+ // case (model === fallbackModel). Tag it so main()'s catch surfaces
885
+ // the clean quota auto-pause notice instead of routing through the
886
+ // 3-failure backstop (#176 PR-review follow-up). Strictly additive:
887
+ // only sets a flag on an error already propagating. Broker mode is
888
+ // env-gated, so this path is not exercised by the fake-codex fixture.
889
+ if (isQuotaError(err) && err && typeof err === "object") {
890
+ err.quotaExhausted = true;
891
+ }
892
+ throw err;
893
+ }
894
+ // brokerFailure → silent fall-through to spawnCodex path below.
895
+ // Append a log entry so dogfooders can audit broker-mode regressions.
896
+ try {
897
+ await appendLog(markerDir, {
898
+ timestamp: new Date().toISOString(),
899
+ verdict: "broker_fallback",
900
+ reason: `${err.brokerPhase || "unknown"}: ${err.message ?? String(err)}`,
901
+ });
902
+ } catch {
903
+ // best-effort; logging failure must never break the hook
904
+ }
905
+ }
906
+ }
907
+ try {
908
+ return {
909
+ response: await spawnCodexWithRetry({ prompt, model, timeoutMs, markerDir }),
910
+ fellBack: false,
911
+ };
912
+ } catch (err) {
913
+ if (isQuotaError(err) && model !== fallbackModel) {
914
+ try {
915
+ const response = await spawnCodexWithRetry({
916
+ prompt,
917
+ model: fallbackModel,
918
+ timeoutMs,
919
+ markerDir,
920
+ });
921
+ return { response, fellBack: true };
922
+ } catch (fallbackErr) {
923
+ // BOTH models are now unusable, which is exhaustion either way:
924
+ // (a) the fallback also hit quota → provider exhausted, or
925
+ // (b) the fallback is structurally unavailable on this account
926
+ // (e.g. gpt-5.5-mini on a ChatGPT plan returns a 400, not a
927
+ // quota) → the ladder is broken, the same "no usable model"
928
+ // case as model === fallbackModel below.
929
+ // Tag quotaExhausted so main()'s catch does the clean #176 quota
930
+ // auto-pause instead of the 3-failure backstop. For (b) re-throw the
931
+ // PRIMARY quota error so its reason + reset hint reach the pause
932
+ // notice — the fallback 400 carries neither.
933
+ if (isQuotaError(fallbackErr) && fallbackErr && typeof fallbackErr === "object") {
934
+ fallbackErr.quotaExhausted = true;
935
+ throw fallbackErr;
936
+ }
937
+ if (isModelUnavailableError(fallbackErr) && err && typeof err === "object") {
938
+ err.quotaExhausted = true;
939
+ throw err;
940
+ }
941
+ throw fallbackErr;
942
+ }
943
+ }
944
+ // model === fallbackModel: there is no ladder left — quota here IS exhaustion.
945
+ if (isQuotaError(err) && err && typeof err === "object") {
946
+ err.quotaExhausted = true;
947
+ }
948
+ throw err;
949
+ }
950
+ }
951
+
952
+ // Spawn the detached edit-debounce worker (design 2026-06-03). Mirrors the
953
+ // detached+unref pattern from spawnBroker. Returns true on success; the caller
954
+ // falls back to a synchronous review when this returns false.
955
+ function spawnDebounceWorker({ markerDir, filePath, toolName, generation, settleMs, maxMs, sessionId }) {
956
+ try {
957
+ const worker = spawn(process.execPath, [join(SCRIPT_DIR, "codex-pair-debounce-worker.mjs")], {
958
+ detached: true,
959
+ stdio: "ignore",
960
+ env: {
961
+ ...process.env,
962
+ CP_MARKER_DIR: markerDir,
963
+ CP_FILE: filePath,
964
+ CP_TOOL: toolName,
965
+ CP_GENERATION: String(generation),
966
+ CP_SETTLE_MS: String(settleMs),
967
+ CP_MAX_MS: String(maxMs),
968
+ CP_SESSION_ID: sessionId ?? "",
969
+ },
970
+ });
971
+ worker.on("error", () => {});
972
+ worker.unref();
973
+ return true;
974
+ } catch {
975
+ return false;
976
+ }
977
+ }
978
+
979
+ async function main() {
980
+ if (process.env.CODEX_PAIR_DISABLED === "1") process.exit(0);
981
+
982
+ const raw = await readStdin();
983
+ let payload;
984
+ try {
985
+ payload = JSON.parse(raw);
986
+ } catch {
987
+ process.exit(0);
988
+ }
989
+
990
+ const toolName = payload?.tool_name;
991
+ if (!WATCHED_TOOLS.has(toolName)) process.exit(0);
992
+
993
+ const filePath = payload?.tool_input?.file_path;
994
+ if (!filePath || typeof filePath !== "string") process.exit(0);
995
+
996
+ // Marker resolution starts from the edited file's directory, not cwd.
997
+ // In multi-repo workflows where Claude Code's cwd is one repo but the edit
998
+ // happens in another (e.g. cross-repo navigation, monorepo with linked
999
+ // siblings), cwd-anchored resolution writes logs to the wrong repo. The
1000
+ // file_path is always absolute per Claude Code's tool_input contract, so
1001
+ // its dirname is a reliable anchor that matches the edit's actual project.
1002
+ // See issue #65. Also hoisted to module scope so the catch handler can
1003
+ // log unhandled exceptions to the correct repo without re-parsing stdin.
1004
+ markerAnchor = dirname(filePath);
1005
+ const markerDir = await findMarkerUp(markerAnchor);
1006
+ if (!markerDir) process.exit(0);
1007
+
1008
+ // ADR-131 (#209): record this repo as active in this session so the
1009
+ // cwd-anchored Stop/UserPromptSubmit drains + blockOn:HIGH gate can see it at
1010
+ // turn-end even when Claude Code's cwd is a DIFFERENT repo. Placed above the
1011
+ // skip/ignore gates so a repo with earlier HIGH findings still registers even
1012
+ // when this particular edit is skipped. Best-effort; must not affect review.
1013
+ registerMarker(payload?.session_id, markerDir);
1014
+
1015
+ const pauseInfo = readPauseInfo(markerDir);
1016
+ if (pauseInfo) {
1017
+ // Self-healing (2026-07-02 seamless-pairing design): an expired auto-pause
1018
+ // resumes right here and the review proceeds. A failed retry re-pauses via
1019
+ // the existing paths (the sentinel is gone, so the wx write succeeds) —
1020
+ // notify-once still holds per pause episode.
1021
+ const resumeDecision = resolveAutoResume(pauseInfo, {
1022
+ now: Date.now(),
1023
+ currentVersion: readPluginVersion(),
1024
+ });
1025
+ // clearAutoPause aborts (false) when the sentinel changed since we read it.
1026
+ // A false return can also mean another hook resumed concurrently (sentinel
1027
+ // gone) — re-read before deciding, so a won-elsewhere resume proceeds to
1028
+ // review and a raced-in NEW pause is reported from current state, not the
1029
+ // stale pauseInfo (dogfood review finding).
1030
+ const resumed = resumeDecision.resume && clearAutoPause(markerDir, pauseInfo);
1031
+ const currentPause = resumed ? null : resumeDecision.resume ? readPauseInfo(markerDir) : pauseInfo;
1032
+ if (resumed) {
1033
+ await appendLog(markerDir, {
1034
+ timestamp: new Date().toISOString(),
1035
+ tool: toolName,
1036
+ file: filePath,
1037
+ verdict: "auto_resumed",
1038
+ reason: `${resumeDecision.why} (paused ${pauseInfo.at ?? "unknown"}, kind: ${pauseInfo.kind})`,
1039
+ });
1040
+ noticePrefix = `codex-pair auto-resumed (${resumeDecision.why}): was ${pauseInfo.kind}-paused since ${pauseInfo.at ?? "unknown"}. Reviews are live again.`;
1041
+ // fall through — this edit gets reviewed
1042
+ } else if (currentPause) {
1043
+ const pauseReason = currentPause.manual
1044
+ ? "paused via /codex-pair-pause (rm .codex-pair/state/paused to resume)"
1045
+ : `auto-paused (${currentPause.kind}${currentPause.resetHint ? `, resets ~${currentPause.resetHint}` : ""}) — resume with /codex-pair-resume`;
1046
+ await appendLog(markerDir, {
1047
+ timestamp: new Date().toISOString(),
1048
+ tool: toolName,
1049
+ file: filePath,
1050
+ verdict: "skipped",
1051
+ reason: pauseReason,
1052
+ });
1053
+ process.exit(0);
1054
+ }
1055
+ // currentPause null without `resumed`: a concurrent hook already resumed —
1056
+ // proceed with the review, no notice (the winner emitted one).
1057
+ }
1058
+
1059
+ const lower = filePath.toLowerCase();
1060
+ if (SKIP_PATTERNS.some((p) => lower.includes(p))) {
1061
+ // A skipped file must not swallow a just-set auto-resume notice — this
1062
+ // path emits nothing else, so the notice is safely the run's single output.
1063
+ await flushNoticeOnly();
1064
+ process.exit(0);
1065
+ }
1066
+
1067
+ // ADR-096: .codex-pair/include — inclusion-list scoping. When present + non-
1068
+ // empty, ONLY files matching at least one rule are reviewed. Lets users
1069
+ // scope codex-pair to high-stakes paths (src/billing/**, src/auth/**) and
1070
+ // avoid paying $0.05/edit on routine refactor code. Applied BEFORE the
1071
+ // ignore-list — include narrows; ignore then excludes from the narrowed set.
1072
+ //
1073
+ // ADR-097 multi-review hotfix on ADR-096 (Codex finding #4): if include
1074
+ // has ONLY negation rules (e.g., `!*.test.ts`), the user's intent is
1075
+ // "review everything EXCEPT these patterns" — symmetric with ignore-list
1076
+ // semantics. Without this fix, negation-only include silently skipped
1077
+ // every file (no positive rule = no match for anything = always skip).
1078
+ // The fix: strip the `negate` flag from those rules and append them to
1079
+ // the ignore-list, then proceed with no inclusion gate.
1080
+ const includeRules = readIncludeFile(markerDir);
1081
+ const positiveInclude = includeRules.filter((r) => !r.negate);
1082
+ const includeNegationsAsIgnore = [];
1083
+ if (positiveInclude.length > 0) {
1084
+ // Standard inclusion gate with full rule set (positive + negation)
1085
+ const includeMatch = matchesIgnoreRule(filePath, markerDir, includeRules);
1086
+ if (!includeMatch) {
1087
+ await appendLog(markerDir, {
1088
+ timestamp: new Date().toISOString(),
1089
+ tool: toolName,
1090
+ file: filePath,
1091
+ verdict: "skipped",
1092
+ reason: "file not in .codex-pair/include scope",
1093
+ });
1094
+ await flushNoticeOnly();
1095
+ process.exit(0);
1096
+ }
1097
+ } else if (includeRules.length > 0) {
1098
+ // Negation-only include — transform each `!pattern` into a positive
1099
+ // ignore rule. Log once per fire so users see the semantic mapping.
1100
+ for (const r of includeRules) {
1101
+ includeNegationsAsIgnore.push({ negate: false, pattern: r.pattern, raw: r.raw });
1102
+ }
1103
+ await appendLog(markerDir, {
1104
+ timestamp: new Date().toISOString(),
1105
+ tool: toolName,
1106
+ file: filePath,
1107
+ level: "info",
1108
+ reason:
1109
+ ".codex-pair/include has only negation rules — interpreting as 'review everything except these patterns' (treating as ignore-list entries). Add a positive rule like '**' to use include as a strict allow-list.",
1110
+ });
1111
+ }
1112
+
1113
+ // .codex-pair/ignore — granular per-project opt-out via gitignore-style
1114
+ // globs. Match → silent log skip with the matching pattern, NO
1115
+ // systemMessage (preserves silent-gating UX for opted-out files).
1116
+ const ignoreRulesRaw = readIgnoreFile(markerDir);
1117
+ const ignoreRules = [...ignoreRulesRaw, ...includeNegationsAsIgnore];
1118
+ const ignoreMatch = matchesIgnoreRule(filePath, markerDir, ignoreRules);
1119
+ if (ignoreMatch) {
1120
+ await appendLog(markerDir, {
1121
+ timestamp: new Date().toISOString(),
1122
+ tool: toolName,
1123
+ file: filePath,
1124
+ verdict: "skipped",
1125
+ reason: `matched .codex-pair/ignore: ${ignoreMatch.raw}`,
1126
+ });
1127
+ await flushNoticeOnly();
1128
+ process.exit(0);
1129
+ }
1130
+
1131
+ // Read + parse the marker file FIRST so config (model/timeout/cap/threshold)
1132
+ // can take effect on the file-size check below. Malformed frontmatter is a
1133
+ // silent fallback to defaults plus a "warning"-level log entry.
1134
+ let projectContext = "";
1135
+ let frontmatter = {};
1136
+ let frontmatterMalformed = false;
1137
+ try {
1138
+ const markerContent = await readFile(contextPath(markerDir), "utf8");
1139
+ const parsed = parseFrontmatter(markerContent);
1140
+ projectContext = parsed.body;
1141
+ frontmatter = parsed.frontmatter;
1142
+ frontmatterMalformed = parsed.malformed;
1143
+ } catch {
1144
+ // marker unreadable — proceed with empty context and defaults
1145
+ }
1146
+ if (frontmatterMalformed) {
1147
+ await appendLog(markerDir, {
1148
+ timestamp: new Date().toISOString(),
1149
+ tool: toolName,
1150
+ file: filePath,
1151
+ level: "warning",
1152
+ reason:
1153
+ "malformed frontmatter in .codex-pair/context.md — opener `---` with no matching closer; falling back to defaults",
1154
+ });
1155
+ }
1156
+ const config = resolveConfig(frontmatter);
1157
+
1158
+ // Edit-debounce (design 2026-06-03, #96). When enabled, this edit is recorded
1159
+ // and a detached worker reviews the SETTLED file after the window — the hook
1160
+ // does NOT review inline. force-sync (set by the worker's re-invocation)
1161
+ // collapses the window to 0 so the synchronous path below runs verbatim.
1162
+ const effectiveDebounceMs = process.env.CODEX_PAIR_FORCE_SYNC === "1" ? 0 : config.debounceMs;
1163
+ if (effectiveDebounceMs > 0) {
1164
+ const record = bumpEditRecord(markerDir, filePath, {
1165
+ sessionId: payload?.session_id,
1166
+ now: Date.now(),
1167
+ });
1168
+ if (Math.random() < 0.05) sweepStaleDebounce(markerDir, config.debounceMaxMs);
1169
+ const spawned = spawnDebounceWorker({
1170
+ markerDir,
1171
+ filePath,
1172
+ toolName,
1173
+ generation: record.generation,
1174
+ settleMs: effectiveDebounceMs,
1175
+ maxMs: config.debounceMaxMs,
1176
+ sessionId: payload?.session_id,
1177
+ });
1178
+ if (spawned) {
1179
+ // Surface any verdict a prior worker queued (the worker has no stdout to
1180
+ // Claude). Drained ONLY on the successful-dispatch path so it cannot pair
1181
+ // with the synchronous review's emit below into a double systemMessage;
1182
+ // on spawn failure the pending verdict stays queued for the next hook.
1183
+ const pendingMessages = drainPending(markerDir);
1184
+ if (pendingMessages.length > 0) {
1185
+ await emitSystemMessage(joinPendingForSurface(pendingMessages));
1186
+ } else {
1187
+ // Flush any pending auto-resume notice instead of swallowing it
1188
+ // (PR #208 review); no-op when nothing is queued.
1189
+ await flushNoticeOnly();
1190
+ }
1191
+ process.exit(0);
1192
+ }
1193
+ // Worker spawn failed → fall through to a synchronous review (safety net),
1194
+ // leaving any pending verdict queued to drain on a later hook. Consume the
1195
+ // just-bumped debounce record: the sync review below covers this burst, and
1196
+ // an unconsumed record would read as "still settling" to the Stop-gate's
1197
+ // in-flight check for the whole stale window (PR #208 review).
1198
+ markReviewed(markerDir, filePath, record.generation);
1199
+ }
1200
+
1201
+ let fileContent;
1202
+ try {
1203
+ fileContent = await readFile(filePath, "utf8");
1204
+ } catch (err) {
1205
+ await appendLog(markerDir, {
1206
+ timestamp: new Date().toISOString(),
1207
+ tool: toolName,
1208
+ file: filePath,
1209
+ verdict: "skipped",
1210
+ reason: `unreadable: ${err.message}`,
1211
+ });
1212
+ await emitSystemMessage(
1213
+ `codex-pair ${VERDICT_PREFIXES.skipped}: ${filePath} — unreadable (${err.message})`,
1214
+ );
1215
+ process.exit(0);
1216
+ }
1217
+
1218
+ const fileBytes = Buffer.byteLength(fileContent, "utf8");
1219
+ // Adaptive context: under-cap → full file (unchanged). Over-cap → build a
1220
+ // partial view (diff or head+tail) and pass a partial-view warning to codex
1221
+ // instead of silently skipping. Replaces ADR-077's original over-cap skip.
1222
+ let promptContent = fileContent;
1223
+ let partialView = false;
1224
+ let contextStrategy = "full";
1225
+ if (fileBytes > config.maxFileBytes) {
1226
+ const adaptive = await buildAdaptiveContext({
1227
+ filePath,
1228
+ fileContent,
1229
+ markerDir,
1230
+ maxFileBytes: config.maxFileBytes,
1231
+ });
1232
+ promptContent = adaptive.content;
1233
+ partialView = true;
1234
+ contextStrategy = adaptive.strategy;
1235
+ await appendLog(markerDir, {
1236
+ timestamp: new Date().toISOString(),
1237
+ tool: toolName,
1238
+ file: filePath,
1239
+ level: "info",
1240
+ reason: `over-cap (${fileBytes} bytes > ${config.maxFileBytes}); using adaptive context strategy "${contextStrategy}"`,
1241
+ });
1242
+ }
1243
+
1244
+ const prompt = buildPrompt({
1245
+ filePath,
1246
+ fileContent: promptContent,
1247
+ toolName,
1248
+ projectContext,
1249
+ partialView,
1250
+ });
1251
+
1252
+ const startedAt = Date.now();
1253
+
1254
+ // Content-hash cache check (item #8). Same inputs → same review → skip the
1255
+ // codex spawn entirely on hit. Cache miss falls through to normal flow.
1256
+ const cacheKey = computeCacheKey({
1257
+ model: config.model,
1258
+ prompt,
1259
+ fileContent: promptContent,
1260
+ surfaceThreshold: config.surfaceThreshold,
1261
+ });
1262
+ const cached = await getCachedConcerns(markerDir, cacheKey);
1263
+ if (cached) {
1264
+ const cachedDurationMs = Date.now() - startedAt;
1265
+ await appendLog(markerDir, {
1266
+ timestamp: new Date().toISOString(),
1267
+ tool: toolName,
1268
+ file: filePath,
1269
+ verdict: "cached",
1270
+ counts: {
1271
+ high: cached.high.length,
1272
+ med: cached.med.length,
1273
+ low: cached.low.length,
1274
+ },
1275
+ durationMs: cachedDurationMs,
1276
+ originalDurationMs: cached.durationMs,
1277
+ concerns: {
1278
+ high: cached.high.map((c) => c.slice(0, 800)),
1279
+ med: cached.med.map((c) => c.slice(0, 800)),
1280
+ low: cached.low.map((c) => c.slice(0, 800)),
1281
+ },
1282
+ });
1283
+ // ADR-097 (ADR-096 multi-review hotfix): cache-hit path is READ-ONLY
1284
+ // against repetitions state. Mutating on cache hits caused the
1285
+ // "rapid undo/redo false-fire" Gemini flagged — same content hash =
1286
+ // cache hit, so 3 trivial saves of unchanged content would cross
1287
+ // the BLOCKING threshold without the user ever seeing the verdict
1288
+ // a second time. The cache TTL (10 min) already bounds how often
1289
+ // live reviews fire; live reviews remain the only path that
1290
+ // increments. The cache-hit path now reads the current shard and
1291
+ // surfaces the banner ONLY if a prior live review crossed threshold.
1292
+ const cachedHashes = [
1293
+ ...cached.high.map(hashConcernBody),
1294
+ ...cached.med.map(hashConcernBody),
1295
+ ...cached.low.map(hashConcernBody),
1296
+ ];
1297
+ let cachedRepeatedIgnoredCount = 0;
1298
+ try {
1299
+ const blocking = getBlockingFromShard(markerDir, filePath, cachedHashes);
1300
+ cachedRepeatedIgnoredCount = blocking.length;
1301
+ } catch {
1302
+ // best-effort
1303
+ }
1304
+ await emitSystemMessage(
1305
+ buildVerdictMessage({
1306
+ filePath,
1307
+ concerns: cached,
1308
+ fellBack: false,
1309
+ durationMs: cachedDurationMs,
1310
+ surfaceThreshold: config.surfaceThreshold,
1311
+ cached: true,
1312
+ repeatedIgnoredCount: cachedRepeatedIgnoredCount,
1313
+ logPath: logPath(markerDir),
1314
+ }),
1315
+ );
1316
+ process.exit(0);
1317
+ }
1318
+
1319
+ // ADR-087: inflight lock per file path. Cache miss reached → we're about
1320
+ // to spawn codex. If another hook is mid-spawn for the same file, coalesce:
1321
+ // log skipped and exit. TTL = max(codex timeout, 10 min) + 60s buffer so
1322
+ // stale-recovery never steals still-valid locks.
1323
+ const inflightTtlMs = Math.max(config.timeoutMs, INFLIGHT_TTL_MIN_MS) + 60_000;
1324
+ const lockResult = tryAcquireInflightLock(markerDir, filePath, inflightTtlMs);
1325
+ if (!lockResult.acquired) {
1326
+ await appendLog(markerDir, {
1327
+ timestamp: new Date().toISOString(),
1328
+ tool: toolName,
1329
+ file: filePath,
1330
+ verdict: "skipped",
1331
+ reason: `coalesced — another review is in-flight for this file (${lockResult.reason})`,
1332
+ });
1333
+ await flushNoticeOnly();
1334
+ process.exit(0);
1335
+ }
1336
+ const acquiredLockPath = lockResult.lockPath;
1337
+ process.on("exit", () => releaseInflightLock(acquiredLockPath));
1338
+
1339
+ let response;
1340
+ let fellBack = false;
1341
+ try {
1342
+ // M4 multi-review hotfix: dispatch unified through runCodexWithFallback
1343
+ // for BOTH broker and spawn modes. Previous duplicate inline branch
1344
+ // bypassed runWithBroker's brokerFailure-fallback semantics + missed
1345
+ // initialize handshake + missed quota fallback. Both /multi-review
1346
+ // reviewers (Codex 98% + Claude 98%) caught this independently —
1347
+ // a duplicate dispatch path I auto-completed without realizing.
1348
+ const result = await runCodexWithFallback({
1349
+ prompt,
1350
+ timeoutMs: config.timeoutMs,
1351
+ model: config.model,
1352
+ fallbackModel: config.fallbackModel,
1353
+ markerDir,
1354
+ });
1355
+ response = result.response;
1356
+ fellBack = result.fellBack;
1357
+ } catch (err) {
1358
+ const reason = err instanceof Error ? err.message : String(err);
1359
+ const verdict = verdictFromError(err);
1360
+ const prefix = VERDICT_PREFIXES[verdict] ?? VERDICT_PREFIXES.error;
1361
+ const durationMs = Date.now() - startedAt;
1362
+
1363
+ // #176 / ADR-120: provider quota exhausted (both models) → pause
1364
+ // ourselves ONCE instead of erroring on every subsequent edit. The
1365
+ // sentinel write is wx-exclusive; false means another hook (or the
1366
+ // user) already paused — log, but stay silent.
1367
+ if (err && typeof err === "object" && err.quotaExhausted) {
1368
+ const resetHint = parseResetHint(reason);
1369
+ const paused = writeAutoPause(markerDir, { kind: "quota", reason, resetHint });
1370
+ await appendLog(markerDir, {
1371
+ timestamp: new Date().toISOString(),
1372
+ tool: toolName,
1373
+ file: filePath,
1374
+ verdict,
1375
+ reason,
1376
+ durationMs,
1377
+ ...(paused ? { autoPaused: "quota" } : {}),
1378
+ });
1379
+ if (paused) {
1380
+ const resetClause = resetHint ? ` (resets ~${resetHint})` : "";
1381
+ await emitSystemMessage(
1382
+ `codex-pair auto-paused: provider quota exhausted${resetClause}. Resume with /codex-pair-resume.`,
1383
+ );
1384
+ } else {
1385
+ // Flush any pending auto-resume notice instead of swallowing it
1386
+ // (PR #208 review); no-op when nothing is queued.
1387
+ await flushNoticeOnly();
1388
+ }
1389
+ process.exit(0);
1390
+ }
1391
+
1392
+ // #176 backstop: any other failure increments the consecutive counter.
1393
+ // At AUTOPAUSE_FAILURE_THRESHOLD, pause — a broken provider must not
1394
+ // error-spam an entire session. The counter is global per project and
1395
+ // persists across sessions until a successful review or a manual clear,
1396
+ // so a stale streak can trip the pause on the first failure of a new
1397
+ // session — intentional, matching the issue's "never error-spam a session".
1398
+ const failureCount = recordReviewFailure(markerDir, reason);
1399
+ if (failureCount >= AUTOPAUSE_FAILURE_THRESHOLD) {
1400
+ const paused = writeAutoPause(markerDir, { kind: "failures", reason });
1401
+ await appendLog(markerDir, {
1402
+ timestamp: new Date().toISOString(),
1403
+ tool: toolName,
1404
+ file: filePath,
1405
+ verdict,
1406
+ reason,
1407
+ durationMs,
1408
+ ...(paused ? { autoPaused: "failures" } : {}),
1409
+ });
1410
+ if (paused) {
1411
+ await emitSystemMessage(
1412
+ `codex-pair auto-paused after ${AUTOPAUSE_FAILURE_THRESHOLD} consecutive review failures (last: ${reason}). Resume with /codex-pair-resume.`,
1413
+ );
1414
+ } else {
1415
+ // Flush any pending auto-resume notice instead of swallowing it
1416
+ // (PR #208 review); no-op when nothing is queued.
1417
+ await flushNoticeOnly();
1418
+ }
1419
+ process.exit(0);
1420
+ }
1421
+
1422
+ await appendLog(markerDir, {
1423
+ timestamp: new Date().toISOString(),
1424
+ tool: toolName,
1425
+ file: filePath,
1426
+ verdict,
1427
+ reason,
1428
+ durationMs,
1429
+ });
1430
+ await emitSystemMessage(
1431
+ `codex-pair ${prefix}: ${filePath} — review failed: ${reason} (${formatDuration(durationMs)}) (failure ${failureCount}/${AUTOPAUSE_FAILURE_THRESHOLD} before auto-pause)`,
1432
+ );
1433
+ process.exit(0);
1434
+ }
1435
+
1436
+ // Live review succeeded — any failure streak is over (#176 backstop).
1437
+ clearReviewFailures(markerDir);
1438
+
1439
+ const concerns = parseConcerns(response);
1440
+ const total = concerns.high.length + concerns.med.length + concerns.low.length;
1441
+ const durationMs = Date.now() - startedAt;
1442
+
1443
+ // Cache the parsed concerns for future identical-input calls. Failures here
1444
+ // are silent — a write failure shouldn't break the user-visible review.
1445
+ await setCachedConcerns(markerDir, cacheKey, {
1446
+ high: concerns.high,
1447
+ med: concerns.med,
1448
+ low: concerns.low,
1449
+ durationMs,
1450
+ });
1451
+
1452
+ await appendLog(markerDir, {
1453
+ timestamp: new Date().toISOString(),
1454
+ tool: toolName,
1455
+ file: filePath,
1456
+ verdict: total === 0 ? "none" : "concerns",
1457
+ fellBack,
1458
+ counts: {
1459
+ high: concerns.high.length,
1460
+ med: concerns.med.length,
1461
+ low: concerns.low.length,
1462
+ },
1463
+ durationMs,
1464
+ concerns: {
1465
+ high: concerns.high.map((c) => c.slice(0, 800)),
1466
+ med: concerns.med.map((c) => c.slice(0, 800)),
1467
+ low: concerns.low.map((c) => c.slice(0, 800)),
1468
+ },
1469
+ });
1470
+
1471
+ // ADR-096: repetition tracker. Hash each concern body across all severities;
1472
+ // updateRepetitions increments counts for concerns flagged again on the
1473
+ // same file and drops concerns the user has clearly fixed. When the
1474
+ // count crosses REPETITION_BLOCKING_THRESHOLD, the systemMessage gets a
1475
+ // loud BLOCKING banner so the consumer can't silently keep ignoring it.
1476
+ const repetitionHashes = [
1477
+ ...concerns.high.map(hashConcernBody),
1478
+ ...concerns.med.map(hashConcernBody),
1479
+ ...concerns.low.map(hashConcernBody),
1480
+ ];
1481
+ let repeatedIgnoredCount = 0;
1482
+ try {
1483
+ const blocking = await updateRepetitions(markerDir, filePath, repetitionHashes);
1484
+ repeatedIgnoredCount = blocking.length;
1485
+ } catch {
1486
+ // best-effort — repetitions are advisory; failure must not break the hook
1487
+ }
1488
+
1489
+ await emitSystemMessage(
1490
+ buildVerdictMessage({
1491
+ filePath,
1492
+ concerns,
1493
+ fellBack,
1494
+ durationMs,
1495
+ surfaceThreshold: config.surfaceThreshold,
1496
+ repeatedIgnoredCount,
1497
+ logPath: logPath(markerDir),
1498
+ }),
1499
+ );
1500
+
1501
+ process.exit(0);
1502
+ }
1503
+
1504
+ main().catch(async (err) => {
1505
+ try {
1506
+ // Prefer the hoisted markerAnchor (set from dirname(filePath) once the
1507
+ // payload was validated) so unhandled-exception logs land in the edited
1508
+ // file's repo, not cwd's. Falls back to cwd only when main() threw
1509
+ // before payload parsing — the unavoidable case where filePath is
1510
+ // unknown. Multi-review on PR #76 flagged the prior cwd-only path as a
1511
+ // residual cross-repo gap; this hoist closes it.
1512
+ const anchor = markerAnchor ?? process.cwd();
1513
+ const markerDir = await findMarkerUp(anchor);
1514
+ if (markerDir) {
1515
+ await appendLog(markerDir, {
1516
+ timestamp: new Date().toISOString(),
1517
+ verdict: "error",
1518
+ reason: `unhandled: ${err?.message ?? String(err)}`,
1519
+ });
1520
+ }
1521
+ } catch {
1522
+ // ignore — nothing more we can do
1523
+ }
1524
+ process.exit(0);
1525
+ });