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