@ask-llm/plugin 0.17.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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +36 -0
  4. package/README.md +8 -8
  5. package/agents/brainstorm-coordinator.md +17 -16
  6. package/agents/codex-reviewer.md +1 -1
  7. package/agents/sol-reviewer.md +5 -5
  8. package/codex-pair-defaults.json +1 -1
  9. package/dist/brainstorm-panel.d.ts +1 -1
  10. package/dist/brainstorm-panel.d.ts.map +1 -1
  11. package/dist/brainstorm-panel.js +8 -8
  12. package/dist/brainstorm-panel.js.map +1 -1
  13. package/dist/brainstorm-run.js +1 -1
  14. package/dist/brainstorm-run.js.map +1 -1
  15. package/package.json +11 -10
  16. package/pi/extensions/codex-pair.ts +2 -1
  17. package/pi/extensions/provider-tools.ts +1 -1
  18. package/scripts/codex-pair-debounce-worker.mjs +60 -88
  19. package/scripts/codex-pair-prompt-drain.mjs +50 -64
  20. package/scripts/codex-pair-session.mjs +129 -168
  21. package/scripts/codex-pair-stop-gate.mjs +183 -233
  22. package/scripts/codex-pair-watch.mjs +1018 -1371
  23. package/scripts/lib/broker-lifecycle.mjs +677 -0
  24. package/scripts/lib/broker-rpc.mjs +173 -0
  25. package/scripts/lib/broker-transport.mjs +327 -0
  26. package/scripts/lib/broker.mjs +327 -0
  27. package/scripts/lib/debounce-state.mjs +206 -0
  28. package/scripts/lib/frontmatter.mjs +57 -0
  29. package/scripts/lib/parser.mjs +229 -0
  30. package/scripts/lib/process.mjs +56 -0
  31. package/scripts/lib/prompt.mjs +32 -0
  32. package/scripts/lib/session-registry.mjs +161 -0
  33. package/scripts/lib/state.mjs +720 -0
  34. package/scripts/lib/stop-gate.mjs +134 -0
  35. package/scripts/sol-review-transport.mjs +1 -1
  36. package/skills/brainstorm/SKILL.md +9 -9
  37. package/skills/codex-image/SKILL.md +2 -2
  38. package/skills/codex-pair/SKILL.md +5 -4
  39. package/skills/codex-review/SKILL.md +1 -1
  40. package/skills/grok-pair/SKILL.md +3 -3
  41. package/skills/sol-review/SKILL.md +5 -5
@@ -0,0 +1,327 @@
1
+ // Source of truth: broker.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
2
+ // Unavailable or incompatible app-server brokers fall back to per-edit Codex.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createRpcClient } from "./broker-rpc.mjs";
6
+ import { connectWebSocket } from "./broker-transport.mjs";
7
+ import { parseFrontmatter } from "./frontmatter.mjs";
8
+ // State file under <markerDir>/.codex-pair/state/ (ADR-092).
9
+ export const BROKER_STATE_FILE = "broker.json";
10
+ export const BROKER_HEALTH_TIMEOUT_MS = 2000;
11
+ export const BROKER_SOCKET_PREFIX = "codex-pair-broker";
12
+ // Pin the app-server protocol version so incompatible upgrades fail the handshake.
13
+ export const BROKER_PROTOCOL_VERSION = "v2";
14
+ export const JSONRPC_METHODS = Object.freeze({
15
+ INITIALIZE: "initialize",
16
+ THREAD_START: "thread/start",
17
+ TURN_START: "turn/start",
18
+ TURN_INTERRUPT: "turn/interrupt",
19
+ MODEL_LIST: "model/list", // used for health probe (cheap, no side effects)
20
+ });
21
+ // turn/completed carries the final agent message and structured verdict.
22
+ export const JSONRPC_NOTIFICATIONS = Object.freeze({
23
+ TURN_COMPLETED: "turn/completed",
24
+ TURN_STARTED: "turn/started",
25
+ ITEM_AGENT_MESSAGE_DELTA: "item/agentMessage/delta", // streaming text
26
+ THREAD_TOKEN_USAGE_UPDATED: "thread/tokenUsage/updated", // cost tracking
27
+ });
28
+ // Strict structured output requires every property; nullable fields remain optional in meaning.
29
+ export function buildVerdictSchema() {
30
+ return {
31
+ type: "object",
32
+ required: ["verdict", "findings"],
33
+ additionalProperties: false,
34
+ properties: {
35
+ verdict: {
36
+ type: "string",
37
+ enum: ["clean", "concerns"],
38
+ description: "Closed-set verdict (parser.mjs:parseConcernsJson contract).",
39
+ },
40
+ findings: {
41
+ type: "array",
42
+ description: "Use an empty array when verdict is clean.",
43
+ items: {
44
+ type: "object",
45
+ required: ["severity", "body", "title", "file", "line_start", "recommendation"],
46
+ additionalProperties: false,
47
+ properties: {
48
+ severity: {
49
+ type: "string",
50
+ enum: ["high", "medium", "low"],
51
+ description: "ADR-077 severity ladder. 'medium' (canonical) — 'med' is a legacy alias.",
52
+ },
53
+ body: {
54
+ type: "string",
55
+ description: "The concern itself — what's wrong + why it matters + how to fix.",
56
+ },
57
+ title: {
58
+ type: ["string", "null"],
59
+ description: "Optional short title rendered ahead of the file:line line.",
60
+ },
61
+ file: {
62
+ type: ["string", "null"],
63
+ description: "File path (optional). Prepended to the rendered concern.",
64
+ },
65
+ line_start: {
66
+ type: ["integer", "null"],
67
+ description: "Line number (optional). Multi-review M3 hotfix: parser.mjs reads `line_start`, not `line`. Rendered as ':<n>' suffix on file.",
68
+ },
69
+ recommendation: {
70
+ type: ["string", "null"],
71
+ description: "Optional fix suggestion. Appended on a new line after body.",
72
+ },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ };
78
+ }
79
+ // Read the session broker descriptor; callers validate it before use.
80
+ export function readBrokerState(markerDir) {
81
+ return lifecycleReadBrokerDescriptor(markerDir);
82
+ }
83
+ export function resolveBrokerPreference(markerDir, env = process.env) {
84
+ if (env.ASK_CODEX_BROKER !== "1")
85
+ return false;
86
+ try {
87
+ const marker = readFileSync(join(markerDir, ".codex-pair", "context.md"), "utf8");
88
+ return parseFrontmatter(marker).frontmatter.broker !== false;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ export function isBrokerDescriptorEligible(state) {
95
+ if (!isIsolatedBrokerHome(state.isolatedHome))
96
+ return false;
97
+ if (!hasCurrentBrokerAuth(state.isolatedHome))
98
+ return false;
99
+ if (state.protocolVersion !== BROKER_PROTOCOL_VERSION)
100
+ return false;
101
+ if (!lifecycleIsPidAlive(state.pid))
102
+ return false;
103
+ return true;
104
+ }
105
+ export function isBrokerEnabled(markerDir) {
106
+ if (!resolveBrokerPreference(markerDir))
107
+ return false;
108
+ const state = lifecycleReadBrokerDescriptor(markerDir);
109
+ return state !== null && isBrokerDescriptorEligible(state);
110
+ }
111
+ export function brokerStatePath(markerDir, stateDir) {
112
+ return join(markerDir, stateDir, BROKER_STATE_FILE);
113
+ }
114
+ // Re-exported so hooks import one broker surface; the lifecycle module imports this module's constants.
115
+ export { clearStaleBrokerState } from "./broker-lifecycle.mjs";
116
+ import { hasCurrentBrokerAuth, isIsolatedBrokerHome, isPidAlive as lifecycleIsPidAlive, readBrokerDescriptorSync as lifecycleReadBrokerDescriptor, } from "./broker-lifecycle.mjs";
117
+ // Caller owns the returned connection; any failure rejects so the hook falls back to direct review.
118
+ export async function initializeBroker(transportUrl, clientInfo, options = {}) {
119
+ const { handshakeTimeoutMs = 5000, initializeTimeoutMs = 5000 } = options;
120
+ const connection = await (options.connectWebSocket ?? connectWebSocket)(transportUrl, { handshakeTimeoutMs });
121
+ const rpc = (options.createRpcClient ?? createRpcClient)(connection, { defaultTimeoutMs: initializeTimeoutMs });
122
+ try {
123
+ const initializeResult = await rpc.request(JSONRPC_METHODS.INITIALIZE, { clientInfo }, { timeoutMs: initializeTimeoutMs });
124
+ rpc.notify("initialized", {});
125
+ return { connection, rpc, initializeResult };
126
+ }
127
+ catch (err) {
128
+ try {
129
+ connection.close(1011, "initialize failed");
130
+ }
131
+ catch {
132
+ // already torn down
133
+ }
134
+ throw err;
135
+ }
136
+ }
137
+ // Initializes a fresh connection and completes `model/list`; any failure means unhealthy, never a throw.
138
+ export async function probeBrokerHealth(state) {
139
+ if (!state || typeof state.transportUrl !== "string")
140
+ return false;
141
+ let connection;
142
+ let rpc;
143
+ try {
144
+ connection = await connectWebSocket(state.transportUrl, {
145
+ handshakeTimeoutMs: BROKER_HEALTH_TIMEOUT_MS,
146
+ });
147
+ rpc = createRpcClient(connection, { defaultTimeoutMs: BROKER_HEALTH_TIMEOUT_MS });
148
+ // Initialize each probe connection before model/list.
149
+ await rpc.request(JSONRPC_METHODS.INITIALIZE, { clientInfo: { name: "codex-pair-health-probe", title: "codex-pair health probe", version: "0.0.0" } }, { timeoutMs: BROKER_HEALTH_TIMEOUT_MS });
150
+ rpc.notify("initialized", {});
151
+ await rpc.request(JSONRPC_METHODS.MODEL_LIST, undefined, {
152
+ timeoutMs: BROKER_HEALTH_TIMEOUT_MS,
153
+ });
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ finally {
160
+ if (connection && !connection.destroyed) {
161
+ try {
162
+ connection.close(1000, "probe done");
163
+ }
164
+ catch {
165
+ // best-effort
166
+ }
167
+ }
168
+ }
169
+ }
170
+ // Transport and protocol failures fall back; numeric model errors keep their meaning.
171
+ async function brokerRequest(rpc, method, params, timeoutMs, brokerPhase) {
172
+ try {
173
+ return await rpc.request(method, params, { timeoutMs });
174
+ }
175
+ catch (caught) {
176
+ const err = caught;
177
+ const protocolRejection = [-32600, -32601, -32602].includes(err?.code);
178
+ if (err && typeof err === "object" && (typeof err.code !== "number" || protocolRejection) && !err.brokerFailure) {
179
+ err.verdict = "error";
180
+ err.brokerFailure = true;
181
+ err.brokerPhase = protocolRejection ? "protocol" : brokerPhase;
182
+ }
183
+ throw err;
184
+ }
185
+ }
186
+ export async function submitReview(args) {
187
+ const { rpc, connection, cwd, baseInstructions, prompt, model, timeoutMs = 60_000, abortSignal } = args;
188
+ const effort = args.effort ?? "medium";
189
+ if (!rpc)
190
+ throw new Error("submitReview: rpc client required");
191
+ if (!connection)
192
+ throw new Error("submitReview: connection required");
193
+ if (typeof prompt !== "string" || prompt.length === 0) {
194
+ throw new Error("submitReview: prompt required");
195
+ }
196
+ const deadline = Date.now() + timeoutMs;
197
+ const remaining = () => Math.max(0, deadline - Date.now());
198
+ // Hook reviews use ephemeral read-only threads with no interactive approvals.
199
+ const threadResp = await brokerRequest(rpc, JSONRPC_METHODS.THREAD_START, {
200
+ ephemeral: true,
201
+ cwd,
202
+ baseInstructions,
203
+ model,
204
+ approvalPolicy: "never",
205
+ sandbox: "read-only",
206
+ }, remaining(), "thread_start");
207
+ const threadId = threadResp?.thread?.id;
208
+ if (typeof threadId !== "string") {
209
+ // Thread-start failure means the broker protocol is unusable.
210
+ const err = new Error("submitReview: thread/start returned no thread.id");
211
+ err.verdict = "error";
212
+ err.brokerFailure = true;
213
+ err.brokerPhase = "thread_start";
214
+ throw err;
215
+ }
216
+ // Register completion before turn/start to avoid missing a fast terminal event.
217
+ const completionPromise = rpc.waitFor(JSONRPC_NOTIFICATIONS.TURN_COMPLETED, (n) => n.params?.threadId === threadId, remaining());
218
+ completionPromise.catch(() => { });
219
+ let completion;
220
+ try {
221
+ // Constrain output to the parser-compatible verdict shape.
222
+ const turnResp = await brokerRequest(rpc, JSONRPC_METHODS.TURN_START, {
223
+ threadId,
224
+ input: [{ type: "text", text: prompt }],
225
+ outputSchema: buildVerdictSchema(),
226
+ effort,
227
+ // Belt-and-suspenders: also pin turn-level sandbox + deny network.
228
+ sandboxPolicy: { type: "readOnly", networkAccess: false },
229
+ }, remaining(), "turn_start");
230
+ const turnId = turnResp?.turn?.id;
231
+ if (typeof turnId !== "string") {
232
+ // Turn-start failure means the broker protocol is unusable.
233
+ const err = new Error("submitReview: turn/start returned no turn.id");
234
+ err.verdict = "error";
235
+ err.brokerFailure = true;
236
+ err.brokerPhase = "turn_start";
237
+ throw err;
238
+ }
239
+ // 4. Wire cancellation. abortSignal abort → turn/interrupt + reject.
240
+ let abortHandler = null;
241
+ let interruptSent = false;
242
+ // Track completion so a late abort cannot interrupt a finished turn.
243
+ let completed = false;
244
+ const abortPromise = abortSignal
245
+ ? new Promise((_, reject) => {
246
+ abortHandler = () => {
247
+ // A completed turn must ignore late aborts.
248
+ if (completed)
249
+ return;
250
+ interruptSent = true;
251
+ // Reject abort promptly; interrupt is best-effort.
252
+ rpc.request(JSONRPC_METHODS.TURN_INTERRUPT, { threadId, turnId }, { timeoutMs: 2000 }).catch(() => { });
253
+ // Use the verdict field consumed by verdictFromError.
254
+ const err = new Error("submitReview: aborted");
255
+ err.verdict = "error";
256
+ err.aborted = true; // structured marker for callers who care
257
+ reject(err);
258
+ };
259
+ if (abortSignal.aborted)
260
+ abortHandler();
261
+ else
262
+ abortSignal.addEventListener("abort", abortHandler);
263
+ })
264
+ : null;
265
+ // 5. Race the completion against the abort.
266
+ try {
267
+ completion = abortPromise ? await Promise.race([completionPromise, abortPromise]) : await completionPromise;
268
+ completed = true;
269
+ }
270
+ catch (caught) {
271
+ const err = caught;
272
+ // Timeout interrupts the server turn; use the RPC timeout marker.
273
+ if (!interruptSent && err && err.timeout === true) {
274
+ rpc.request(JSONRPC_METHODS.TURN_INTERRUPT, { threadId, turnId }, { timeoutMs: 2000 }).catch(() => { });
275
+ const wrapped = new Error("submitReview: turn timed out");
276
+ wrapped.verdict = "timeout";
277
+ wrapped.timeout = true;
278
+ wrapped.brokerFailure = true;
279
+ wrapped.brokerPhase = "turn_completion";
280
+ throw wrapped;
281
+ }
282
+ if (err && typeof err === "object" && !err.aborted) {
283
+ err.brokerFailure = true;
284
+ err.brokerPhase = "turn_completion";
285
+ }
286
+ throw err;
287
+ }
288
+ finally {
289
+ if (abortHandler && abortSignal) {
290
+ abortSignal.removeEventListener("abort", abortHandler);
291
+ }
292
+ }
293
+ }
294
+ finally {
295
+ completionPromise.cancel?.();
296
+ }
297
+ // Use the last agentMessage because a turn may emit multiple messages.
298
+ const turn = completion?.params?.turn;
299
+ if (!turn || !Array.isArray(turn.items)) {
300
+ // M4: protocol-layer failure → brokerFailure → hook falls back.
301
+ const err = new Error("submitReview: turn/completed missing turn.items");
302
+ err.verdict = "parse_failed";
303
+ err.brokerFailure = true;
304
+ err.brokerPhase = "protocol";
305
+ throw err;
306
+ }
307
+ if (turn.status === "failed" || turn.status === "interrupted") {
308
+ const err = new Error(`submitReview: turn ${turn.status}${turn.error?.message ? ` — ${turn.error.message}` : ""}`);
309
+ err.verdict = "error";
310
+ if (turn.status === "failed" &&
311
+ /invalid_json_schema|unsupported method|method not found|invalid params/i.test(turn.error?.message ?? "")) {
312
+ err.brokerFailure = true;
313
+ err.brokerPhase = "protocol";
314
+ }
315
+ throw err;
316
+ }
317
+ const finalMessage = turn.items.findLast?.((i) => i?.type === "agentMessage");
318
+ if (!finalMessage || typeof finalMessage.text !== "string") {
319
+ // Missing agentMessage is a protocol failure that permits direct fallback.
320
+ const err = new Error("submitReview: turn/completed has no agentMessage item");
321
+ err.verdict = "parse_failed";
322
+ err.brokerFailure = true;
323
+ err.brokerPhase = "protocol";
324
+ throw err;
325
+ }
326
+ return finalMessage.text;
327
+ }
@@ -0,0 +1,206 @@
1
+ // Per-file edit-debounce state (design 2026-06-03, closes #96 Bug 2 / Idea 1).
2
+ //
3
+ // Two per-file stores under <markerDir>/.codex-pair/state/:
4
+ // debounce/<sha256(file)[0:16]>.json — edit record { file, generation, burstStartedAt, reviewedGen, sessionId }
5
+ // pending/<sha256(file)[0:16]>.json — settled verdict { file, message } awaiting surface
6
+ //
7
+ // Atomic writes use tmp+rename (ADR-086/091). Reads tolerate missing/malformed
8
+ // (return null / []). Every write is best-effort — debounce state failures must
9
+ // never break the hook (ADR-077).
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { stateRoot } from "./state.mjs";
15
+
16
+ export const DEBOUNCE_DIR = "debounce";
17
+ export const PENDING_DIR = "pending";
18
+ export const REVIEWING_DIR = "reviewing";
19
+ export const DEFAULT_DEBOUNCE_MS = 15_000;
20
+ export const DEFAULT_DEBOUNCE_MAX_MS = 60_000;
21
+ // Sweep records/pending older than maxMs + this buffer (junk from crashes).
22
+ export const DEBOUNCE_STALE_BUFFER_MS = 300_000;
23
+
24
+ export const debounceRoot = (markerDir) => join(stateRoot(markerDir), DEBOUNCE_DIR);
25
+ export const pendingRoot = (markerDir) => join(stateRoot(markerDir), PENDING_DIR);
26
+ export const reviewingRoot = (markerDir) => join(stateRoot(markerDir), REVIEWING_DIR);
27
+
28
+ function fileHash(file) {
29
+ return createHash("sha256").update(String(file)).digest("hex").slice(0, 16);
30
+ }
31
+ export const debounceRecordPath = (markerDir, file) => join(debounceRoot(markerDir), `${fileHash(file)}.json`);
32
+ export const pendingPath = (markerDir, file) => join(pendingRoot(markerDir), `${fileHash(file)}.json`);
33
+
34
+ function writeAtomicSync(p, value) {
35
+ try {
36
+ mkdirSync(dirname(p), { recursive: true });
37
+ const tmp = `${p}.tmp.${process.pid}`;
38
+ writeFileSync(tmp, JSON.stringify(value));
39
+ renameSync(tmp, p);
40
+ } catch {
41
+ // best-effort (ADR-077)
42
+ }
43
+ }
44
+
45
+ // Record one edit. Increments generation; preserves burstStartedAt while a
46
+ // burst is unconsumed (reviewedGen < generation), resets it for a fresh burst.
47
+ export function bumpEditRecord(markerDir, file, { sessionId, now }) {
48
+ let prev = null;
49
+ try {
50
+ prev = JSON.parse(readFileSync(debounceRecordPath(markerDir, file), "utf8"));
51
+ } catch {
52
+ prev = null;
53
+ }
54
+ const generation = (prev?.generation ?? 0) + 1;
55
+ const burstInProgress = prev && prev.reviewedGen < prev.generation;
56
+ const burstStartedAt = burstInProgress ? prev.burstStartedAt : now;
57
+ const record = { file, generation, burstStartedAt, reviewedGen: prev?.reviewedGen ?? 0, sessionId };
58
+ writeAtomicSync(debounceRecordPath(markerDir, file), record);
59
+ return record;
60
+ }
61
+
62
+ export function readEditRecord(markerDir, file) {
63
+ try {
64
+ return JSON.parse(readFileSync(debounceRecordPath(markerDir, file), "utf8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // Pure decision: should the worker born for `myGeneration` review now?
71
+ export function decideReview({ record, myGeneration, now, maxMs }) {
72
+ if (!record) return { review: false, reason: "record-missing" };
73
+ if (record.reviewedGen >= myGeneration) return { review: false, reason: "already-reviewed" };
74
+ if (record.generation === myGeneration) return { review: true, reason: "settled" };
75
+ if (now - record.burstStartedAt >= maxMs) return { review: true, reason: "max-cap" };
76
+ return { review: false, reason: "superseded" };
77
+ }
78
+
79
+ // Advance reviewedGen so the next edit starts a fresh burst. Best-effort.
80
+ // Note: a cap-triggered worker (generation N < the current latest M) advances
81
+ // reviewedGen to N, not M — so the latest-gen worker still reviews the SETTLED
82
+ // state once editing stops. A long continuous burst therefore yields a mid-burst
83
+ // cap review (state at N) plus a final settled review (state at M): two reviews
84
+ // of two DIFFERENT states, which is intended. The per-file inflight lock bounds
85
+ // the worst case to one in-flight Codex call at a time (extra wakers coalesce).
86
+ export function markReviewed(markerDir, file, generation) {
87
+ const p = debounceRecordPath(markerDir, file);
88
+ let rec;
89
+ try {
90
+ rec = JSON.parse(readFileSync(p, "utf8"));
91
+ } catch {
92
+ return;
93
+ }
94
+ if (rec.reviewedGen < generation) {
95
+ rec.reviewedGen = generation;
96
+ writeAtomicSync(p, rec);
97
+ }
98
+ }
99
+
100
+ export function writePending(markerDir, file, message) {
101
+ writeAtomicSync(pendingPath(markerDir, file), { file, message });
102
+ }
103
+
104
+ // Worker handoff marker (2026-07-02 seamless-pairing design, dogfood finding).
105
+ // The worker advances reviewedGen BEFORE the forced-sync hook acquires the
106
+ // per-file inflight lock, so a Stop-gate check in that gap would see neither
107
+ // "settling" nor "reviewing" and let the turn end mid-review. The worker holds
108
+ // this marker across the whole handoff (markReviewing → spawn → clearReviewing)
109
+ // so the gate always has an observable signal. Best-effort like all debounce
110
+ // state; a leaked marker ages out via the gate's freshness window + TTL sweep.
111
+ export const reviewingPath = (markerDir, file) => join(reviewingRoot(markerDir), `${fileHash(file)}.json`);
112
+
113
+ export function markReviewing(markerDir, file) {
114
+ writeAtomicSync(reviewingPath(markerDir, file), { file, at: Date.now() });
115
+ }
116
+
117
+ export function clearReviewing(markerDir, file) {
118
+ try {
119
+ unlinkSync(reviewingPath(markerDir, file));
120
+ } catch {
121
+ // already gone
122
+ }
123
+ }
124
+
125
+ // Read + clear every pending verdict (surfaced exactly once). Returns messages.
126
+ export function drainPending(markerDir) {
127
+ const root = pendingRoot(markerDir);
128
+ const messages = [];
129
+ let names;
130
+ try {
131
+ names = readdirSync(root);
132
+ } catch {
133
+ return messages;
134
+ }
135
+ for (const name of names) {
136
+ if (!name.endsWith(".json")) continue;
137
+ const full = join(root, name);
138
+ try {
139
+ const { message } = JSON.parse(readFileSync(full, "utf8"));
140
+ if (typeof message === "string" && message.length > 0) messages.push(message);
141
+ } catch {
142
+ // skip malformed
143
+ }
144
+ try {
145
+ unlinkSync(full);
146
+ } catch {
147
+ // already gone
148
+ }
149
+ }
150
+ return messages;
151
+ }
152
+
153
+ // Bound how many drained verdicts are surfaced inline. drainPending still
154
+ // clears ALL pending files; only the surfaced text is capped, so a burst that
155
+ // touches many files can't inject an unbounded blob into Claude's context —
156
+ // the overflow stays in the log. Trailer points there.
157
+ export const MAX_SURFACE_VERDICTS = 8;
158
+ export function joinPendingForSurface(messages) {
159
+ if (messages.length <= MAX_SURFACE_VERDICTS) return messages.join("\n\n");
160
+ const extra = messages.length - MAX_SURFACE_VERDICTS;
161
+ return `${messages
162
+ .slice(0, MAX_SURFACE_VERDICTS)
163
+ .join("\n\n")}\n\n[codex-pair] +${extra} more verdict(s) drained — see .codex-pair/log.jsonl`;
164
+ }
165
+
166
+ // SessionEnd cancel: drop all debounce + pending state so orphaned sleepers
167
+ // self-cancel (decideReview → record-missing) and no stale verdict leaks into
168
+ // a later session.
169
+ export function clearAllDebounceState(markerDir) {
170
+ for (const root of [debounceRoot(markerDir), pendingRoot(markerDir), reviewingRoot(markerDir)]) {
171
+ let names;
172
+ try {
173
+ names = readdirSync(root);
174
+ } catch {
175
+ continue;
176
+ }
177
+ for (const name of names) {
178
+ try {
179
+ unlinkSync(join(root, name));
180
+ } catch {
181
+ // best-effort
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // Probabilistic TTL sweep (mirrors ADR-097). Best-effort; never throws.
188
+ export function sweepStaleDebounce(markerDir, maxMs) {
189
+ const cutoff = Date.now() - (maxMs + DEBOUNCE_STALE_BUFFER_MS);
190
+ for (const root of [debounceRoot(markerDir), pendingRoot(markerDir), reviewingRoot(markerDir)]) {
191
+ let names;
192
+ try {
193
+ names = readdirSync(root);
194
+ } catch {
195
+ continue;
196
+ }
197
+ for (const name of names) {
198
+ const full = join(root, name);
199
+ try {
200
+ if (statSync(full).mtimeMs < cutoff) unlinkSync(full);
201
+ } catch {
202
+ // skip
203
+ }
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,57 @@
1
+ // Source of truth: frontmatter.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
2
+ export function parseFrontmatter(content) {
3
+ if (typeof content !== "string" || content.length === 0) {
4
+ return { frontmatter: {}, body: "", malformed: false };
5
+ }
6
+ const firstNewline = content.indexOf("\n");
7
+ if (firstNewline === -1)
8
+ return { frontmatter: {}, body: content, malformed: false };
9
+ const opener = content.slice(0, firstNewline).replace(/\r$/, "");
10
+ if (opener !== "---")
11
+ return { frontmatter: {}, body: content, malformed: false };
12
+ const rest = content.slice(firstNewline + 1);
13
+ const closerMatch = rest.match(/^---\s*$/m);
14
+ if (!closerMatch || typeof closerMatch.index !== "number") {
15
+ return { frontmatter: {}, body: content, malformed: true };
16
+ }
17
+ const fmText = rest.slice(0, closerMatch.index);
18
+ let body = rest.slice(closerMatch.index + closerMatch[0].length);
19
+ if (body.startsWith("\r"))
20
+ body = body.slice(1);
21
+ if (body.startsWith("\n"))
22
+ body = body.slice(1);
23
+ const frontmatter = {};
24
+ for (const rawLine of fmText.split("\n")) {
25
+ const line = rawLine.replace(/\r$/, "");
26
+ const trimmed = line.trim();
27
+ if (trimmed.length === 0 || trimmed.startsWith("#"))
28
+ continue;
29
+ const colon = line.indexOf(":");
30
+ if (colon === -1)
31
+ continue;
32
+ const key = line.slice(0, colon).trim();
33
+ if (key.length === 0)
34
+ continue;
35
+ let valueRaw = line.slice(colon + 1);
36
+ // Strip inline comment, but only when `#` follows whitespace.
37
+ const inlineComment = valueRaw.match(/\s+#.*$/);
38
+ if (inlineComment && typeof inlineComment.index === "number") {
39
+ valueRaw = valueRaw.slice(0, inlineComment.index);
40
+ }
41
+ let value = valueRaw.trim();
42
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
43
+ value = value.slice(1, -1);
44
+ }
45
+ if (value === "true")
46
+ frontmatter[key] = true;
47
+ else if (value === "false")
48
+ frontmatter[key] = false;
49
+ else if (/^-?\d+$/.test(value))
50
+ frontmatter[key] = Number(value);
51
+ else if (/^-?\d+\.\d+$/.test(value))
52
+ frontmatter[key] = Number(value);
53
+ else
54
+ frontmatter[key] = value;
55
+ }
56
+ return { frontmatter, body, malformed: false };
57
+ }