@ask-llm/plugin 0.18.0 → 0.19.0

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