@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/package.json +5 -4
- package/scripts/codex-pair-debounce-worker.mjs +60 -88
- package/scripts/codex-pair-prompt-drain.mjs +50 -64
- package/scripts/codex-pair-session.mjs +129 -168
- package/scripts/codex-pair-stop-gate.mjs +183 -233
- package/scripts/codex-pair-watch.mjs +1016 -1370
- package/scripts/lib/broker-lifecycle.mjs +677 -0
- package/scripts/lib/broker-rpc.mjs +173 -0
- package/scripts/lib/broker-transport.mjs +327 -0
- package/scripts/lib/broker.mjs +327 -0
- package/scripts/lib/debounce-state.mjs +206 -0
- package/scripts/lib/frontmatter.mjs +57 -0
- package/scripts/lib/parser.mjs +229 -0
- package/scripts/lib/process.mjs +56 -0
- package/scripts/lib/prompt.mjs +32 -0
- package/scripts/lib/session-registry.mjs +161 -0
- package/scripts/lib/state.mjs +720 -0
- package/scripts/lib/stop-gate.mjs +134 -0
- package/skills/codex-pair/SKILL.md +1 -1
|
@@ -1,1507 +1,1153 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// codex-pair-watch
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
250
|
-
|
|
135
|
+
if (!noticePrefix)
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
return emitSystemMessage("");
|
|
251
138
|
}
|
|
252
|
-
|
|
253
139
|
// formatDuration + buildVerdictMessage live in ./lib/parser.mjs.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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 (
|
|
596
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
413
|
+
const err = new Error(message);
|
|
414
|
+
err.verdict = verdict;
|
|
415
|
+
return err;
|
|
639
416
|
}
|
|
640
|
-
|
|
641
417
|
function verdictFromError(err) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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 (
|
|
930
|
-
|
|
689
|
+
if (err && typeof err === "object") {
|
|
690
|
+
err.quotaExhausted = true;
|
|
931
691
|
}
|
|
932
692
|
throw err;
|
|
933
|
-
}
|
|
934
693
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1282
|
-
cachedRepeatedIgnoredCount = blocking.length;
|
|
1283
|
-
} catch {
|
|
1284
|
-
// best-effort
|
|
891
|
+
fileContent = await readFile(filePath, "utf8");
|
|
1285
892
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1504
|
-
// ignore — nothing more we can do
|
|
1505
|
-
}
|
|
1506
|
-
process.exit(0);
|
|
1152
|
+
process.exit(0);
|
|
1507
1153
|
});
|