@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
@@ -1,271 +1,221 @@
1
1
  #!/usr/bin/env node
2
- // codex-pair Stop-gate (#142, ADR-118). Blocks turn-end while unaddressed HIGH
3
- // findings remain — opt-in via `blockOn: HIGH` in .codex-pair/context.md.
4
- // MUST exit 0 on every path: a throw/non-zero here would wedge every turn-end.
5
- // Fail-open and LOUD (warn to stderr) on any internal error.
6
- //
7
- // findMarkerUp is duplicated by design (zero-workspace-imports; see prompt-drain).
8
-
2
+ // Source of truth: codex-pair-stop-gate.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
3
+ // The Stop-gate blocks opted-in HIGH findings but fails open on internal errors.
9
4
  import { execFileSync } from "node:child_process";
10
5
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
11
6
  import { homedir } from "node:os";
12
7
  import { dirname, join, resolve } from "node:path";
13
8
  import { debounceRoot, drainPending, joinPendingForSurface, reviewingRoot } from "./lib/debounce-state.mjs";
14
9
  import { collectSessionMarkers } from "./lib/session-registry.mjs";
15
- import {
16
- CONTEXT_FILENAME,
17
- contextPath,
18
- INFLIGHT_TTL_MIN_MS,
19
- inflightRoot,
20
- logPath,
21
- PAIR_ROOT_DIR,
22
- readAcks,
23
- } from "./lib/state.mjs";
24
- import {
25
- collectBlockingHighs,
26
- collectInFlight,
27
- formatBlockMessage,
28
- formatInFlightMessage,
29
- parseGitPorcelain,
30
- selectLatestEntries,
31
- } from "./lib/stop-gate.mjs";
32
-
10
+ import { CONTEXT_FILENAME, contextPath, INFLIGHT_TTL_MIN_MS, inflightRoot, logPath, PAIR_ROOT_DIR, readAcks, } from "./lib/state.mjs";
11
+ import { collectBlockingHighs, collectInFlight, formatBlockMessage, formatInFlightMessage, parseGitPorcelain, selectLatestEntries, } from "./lib/stop-gate.mjs";
33
12
  const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
34
-
35
13
  function findMarkerUp(startDir) {
36
- const home = homedir();
37
- let current = resolve(startDir);
38
- for (let depth = 0; depth < 20; depth++) {
39
- if (existsSync(join(current, MARKER_FILE))) return current;
40
- const parent = dirname(current);
41
- if (parent === current || current === home) return null;
42
- current = parent;
43
- }
44
- return null;
14
+ const home = homedir();
15
+ let current = resolve(startDir);
16
+ for (let depth = 0; depth < 20; depth++) {
17
+ if (existsSync(join(current, MARKER_FILE)))
18
+ return current;
19
+ const parent = dirname(current);
20
+ if (parent === current || current === home)
21
+ return null;
22
+ current = parent;
23
+ }
24
+ return null;
45
25
  }
46
-
47
26
  function readStdin() {
48
- return new Promise((r) => {
49
- let data = "";
50
- process.stdin.on("data", (c) => (data += c.toString()));
51
- process.stdin.on("end", () => r(data));
52
- process.stdin.on("error", () => r(""));
53
- });
27
+ return new Promise((r) => {
28
+ let data = "";
29
+ process.stdin.on("data", (c) => (data += c.toString()));
30
+ process.stdin.on("end", () => r(data));
31
+ process.stdin.on("error", () => r(""));
32
+ });
54
33
  }
55
-
56
- // Minimal frontmatter scalar read — the gate needs `blockOn` and `timeoutMs`
57
- // only, so a full parser stays unnecessary. Looks for `<key>: X` inside the
58
- // leading `---` block.
34
+ // Read only the frontmatter scalars needed by the Stop-gate.
59
35
  function readMarkerScalar(markerDir, key) {
60
- let text;
61
- try {
62
- text = readFileSync(contextPath(markerDir), "utf8");
63
- } catch {
64
- return null;
65
- }
66
- const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); // tolerate CRLF (Windows)
67
- if (!fm) return null;
68
- const m = fm[1].match(new RegExp(`^\\s*${key}:\\s*(\\S+)\\s*$`, "m"));
69
- return m ? m[1].trim() : null;
36
+ let text;
37
+ try {
38
+ text = readFileSync(contextPath(markerDir), "utf8");
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); // tolerate CRLF (Windows)
44
+ if (!fm)
45
+ return null;
46
+ const m = fm[1].match(new RegExp(`^\\s*${key}:\\s*(\\S+)\\s*$`, "m"));
47
+ return m ? m[1].trim() : null;
70
48
  }
71
-
72
49
  function readBlockOn(markerDir) {
73
- return readMarkerScalar(markerDir, "blockOn");
50
+ return readMarkerScalar(markerDir, "blockOn");
74
51
  }
75
-
76
52
  function gitDirtySet(markerDir) {
77
- // timeout guards against a hung git (locked index, slow FS) wedging turn-end.
78
- const opts = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 };
79
- try {
80
- const repoRoot = execFileSync("git", ["-C", markerDir, "rev-parse", "--show-toplevel"], opts).trim();
81
- const porcelain = execFileSync("git", ["-C", markerDir, "status", "--porcelain=v1", "-z"], opts);
82
- return parseGitPorcelain(porcelain, repoRoot);
83
- } catch {
84
- return null; // not a repo / git missing / timeout → skip the [B] filter
85
- }
53
+ // timeout guards against a hung git (locked index, slow FS) wedging turn-end.
54
+ const opts = {
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ timeout: 5000,
58
+ };
59
+ try {
60
+ const repoRoot = execFileSync("git", ["-C", markerDir, "rev-parse", "--show-toplevel"], opts).trim();
61
+ const porcelain = execFileSync("git", ["-C", markerDir, "status", "--porcelain=v1", "-z"], opts);
62
+ return parseGitPorcelain(porcelain, repoRoot);
63
+ }
64
+ catch {
65
+ return null; // not a repo / git missing / timeout → skip the [B] filter
66
+ }
86
67
  }
87
-
88
- // Read the raw inputs for collectInFlight: parsed debounce records + inflight
89
- // lock mtimes. Uses the RAW (pre-realpath) markerDir — the watch hook writes
90
- // this state under the same un-canonicalized root that findMarkerUp returns.
68
+ // Use the raw marker path because the watch hook writes in-flight state there.
91
69
  function readInFlightInputs(markerDir) {
92
- const records = [];
93
- try {
94
- const root = debounceRoot(markerDir);
95
- for (const name of readdirSync(root)) {
96
- if (!name.endsWith(".json")) continue;
97
- try {
98
- records.push(JSON.parse(readFileSync(join(root, name), "utf8")));
99
- } catch {
100
- // malformed record — collectInFlight tolerates junk anyway
101
- }
102
- }
103
- } catch {
104
- // no debounce dir yet
105
- }
106
- // Inflight locks AND worker `reviewing` markers count as running reviews —
107
- // the marker covers the worker→forced-sync-hook handoff gap where the
108
- // debounce record is already consumed but the lock not yet taken.
109
- const lockMtimes = [];
110
- for (const root of [inflightRoot(markerDir), reviewingRoot(markerDir)]) {
70
+ const records = [];
111
71
  try {
112
- for (const name of readdirSync(root)) {
72
+ const root = debounceRoot(markerDir);
73
+ for (const name of readdirSync(root)) {
74
+ if (!name.endsWith(".json"))
75
+ continue;
76
+ try {
77
+ records.push(JSON.parse(readFileSync(join(root, name), "utf8")));
78
+ }
79
+ catch {
80
+ // malformed record — collectInFlight tolerates junk anyway
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // no debounce dir yet
86
+ }
87
+ // Reviewing markers cover the gap between debounce consumption and lock acquisition.
88
+ const lockMtimes = [];
89
+ for (const root of [inflightRoot(markerDir), reviewingRoot(markerDir)]) {
113
90
  try {
114
- lockMtimes.push(statSync(join(root, name)).mtimeMs);
115
- } catch {
116
- // entry vanished between readdir and stat
91
+ for (const name of readdirSync(root)) {
92
+ try {
93
+ lockMtimes.push(statSync(join(root, name)).mtimeMs);
94
+ }
95
+ catch {
96
+ // entry vanished between readdir and stat
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ // dir doesn't exist yet
117
102
  }
118
- }
119
- } catch {
120
- // dir doesn't exist yet
121
103
  }
122
- }
123
- return { records, lockMtimes };
104
+ return { records, lockMtimes };
124
105
  }
125
-
126
- // Lock freshness mirrors the watch hook's inflight-lock TTL — same precedence
127
- // (marker frontmatter `timeoutMs` > ASK_CODEX_TIMEOUT_MS > 800s default, then
128
- // the 10-min floor plus buffer) so the gate and the lock lifecycle agree on
129
- // what "still reviewing" means even for projects that pin a longer per-review
130
- // timeout (PR #208 review).
106
+ // Match the watch hook lock TTL so both agree when a review remains in flight.
131
107
  function inflightFreshMs(markerDir) {
132
- const fmTimeout = Number(readMarkerScalar(markerDir, "timeoutMs"));
133
- const timeout =
134
- Number.isFinite(fmTimeout) && fmTimeout > 0 ? fmTimeout : Number(process.env.ASK_CODEX_TIMEOUT_MS ?? 800_000);
135
- return Math.max(timeout, INFLIGHT_TTL_MIN_MS) + 60_000;
108
+ const fmTimeout = Number(readMarkerScalar(markerDir, "timeoutMs"));
109
+ const timeout = Number.isFinite(fmTimeout) && fmTimeout > 0 ? fmTimeout : Number(process.env.ASK_CODEX_TIMEOUT_MS ?? 800_000);
110
+ return Math.max(timeout, INFLIGHT_TTL_MIN_MS) + 60_000;
136
111
  }
137
-
138
112
  function writeAndExit(obj) {
139
- process.stdout.write(`${JSON.stringify(obj)}\n`, () => process.exit(0));
113
+ process.stdout.write(`${JSON.stringify(obj)}\n`, () => process.exit(0));
140
114
  }
141
-
142
- // Canonicalize file paths so they align with git's realpath'd repo root — on
143
- // macOS `/var` resolves to `/private/var`, which would otherwise make the [B]
144
- // git-status filter and the [E] relPath ack hash mismatch (ADR-118). Missing
145
- // files are left as-is; [A]'s existsFn drops them.
115
+ // Canonicalize for git paths; macOS may resolve /var to /private/var.
146
116
  function canonicalizeEntries(entries) {
147
- const out = new Map();
148
- for (const [file, entry] of entries) {
149
- let real = file;
150
- try {
151
- real = realpathSync(file);
152
- } catch {
153
- // missing/inaccessible → keep raw; collectBlockingHighs' existsFn drops it
117
+ const out = new Map();
118
+ for (const [file, entry] of entries) {
119
+ let real = file;
120
+ try {
121
+ real = realpathSync(file);
122
+ }
123
+ catch {
124
+ // missing/inaccessible → keep raw; collectBlockingHighs' existsFn drops it
125
+ }
126
+ out.set(real, { ...entry, file: real });
154
127
  }
155
- out.set(real, { ...entry, file: real });
156
- }
157
- return out;
128
+ return out;
158
129
  }
159
-
160
- // Evaluate one project marker: drain its pending verdicts and, if it opted into
161
- // blockOn:HIGH, compute whether it wants to block (unaddressed HIGH and/or an
162
- // in-flight review). Returns marker-scoped text; aggregation happens in main().
163
- // Reads in-flight state from the RAW markerDir (matches where the watch hook
164
- // writes it); canonicalizes only for git/log path alignment (macOS /var).
165
- // Only blocking I/O is gitDirtySet (two git calls, each hard-capped at 5s), so
166
- // the sequential per-marker cost is bounded even for several registered repos.
130
+ // Read in-flight state from the raw marker path and canonicalize only for git/log matching.
167
131
  function evaluateMarker(markerDir) {
168
- // Return the RAW drained verdicts — main() accumulates across markers and caps
169
- // the surfaced blob ONCE via joinPendingForSurface, so the MAX_SURFACE_VERDICTS
170
- // bound is global (not 8-per-marker). Matches codex-pair-prompt-drain.mjs.
171
- const pending = drainPending(markerDir);
172
-
173
- if (readBlockOn(markerDir) !== "HIGH") {
174
- return { pending, blockReason: null };
175
- }
176
-
177
- const inFlight = collectInFlight({
178
- ...readInFlightInputs(markerDir),
179
- now: Date.now(),
180
- freshMs: inflightFreshMs(markerDir),
181
- });
182
-
183
- let canonical = markerDir;
184
- try {
185
- canonical = realpathSync(markerDir);
186
- } catch {
187
- // keep raw on the rare realpath failure
188
- }
189
-
190
- let logText = "";
191
- try {
192
- logText = readFileSync(logPath(canonical), "utf8");
193
- } catch {
194
- // no log yet — an in-flight first-ever review can still block below
195
- }
196
-
197
- const blocking = collectBlockingHighs({
198
- entries: canonicalizeEntries(selectLatestEntries(logText)),
199
- acks: readAcks(canonical),
200
- existsFn: existsSync,
201
- gitDirty: gitDirtySet(canonical),
202
- markerDir: canonical,
203
- });
204
-
205
- let blockReason = null;
206
- if (blocking.length > 0) {
207
- blockReason = formatBlockMessage(blocking, canonical);
208
- if (inFlight.any) blockReason = `${formatInFlightMessage(inFlight, canonical)}\n\n${blockReason}`;
209
- } else if (inFlight.any) {
210
- blockReason = formatInFlightMessage(inFlight, canonical);
211
- }
212
- return { pending, blockReason };
132
+ // Aggregate raw verdicts before applying the global surface cap.
133
+ const pending = drainPending(markerDir);
134
+ if (readBlockOn(markerDir) !== "HIGH") {
135
+ return { pending, blockReason: null };
136
+ }
137
+ const inFlight = collectInFlight({
138
+ ...readInFlightInputs(markerDir),
139
+ now: Date.now(),
140
+ freshMs: inflightFreshMs(markerDir),
141
+ });
142
+ let canonical = markerDir;
143
+ try {
144
+ canonical = realpathSync(markerDir);
145
+ }
146
+ catch {
147
+ // keep raw on the rare realpath failure
148
+ }
149
+ let logText = "";
150
+ try {
151
+ logText = readFileSync(logPath(canonical), "utf8");
152
+ }
153
+ catch {
154
+ // no log yet — an in-flight first-ever review can still block below
155
+ }
156
+ const blocking = collectBlockingHighs({
157
+ entries: canonicalizeEntries(selectLatestEntries(logText)),
158
+ acks: readAcks(canonical),
159
+ existsFn: existsSync,
160
+ gitDirty: gitDirtySet(canonical),
161
+ markerDir: canonical,
162
+ });
163
+ let blockReason = null;
164
+ if (blocking.length > 0) {
165
+ blockReason = formatBlockMessage(blocking, canonical);
166
+ if (inFlight.any)
167
+ blockReason = `${formatInFlightMessage(inFlight, canonical)}\n\n${blockReason}`;
168
+ }
169
+ else if (inFlight.any) {
170
+ blockReason = formatInFlightMessage(inFlight, canonical);
171
+ }
172
+ return { pending, blockReason };
213
173
  }
214
-
215
174
  async function main() {
216
- const raw = await readStdin();
217
- let payload;
218
- try {
219
- payload = JSON.parse(raw);
220
- } catch {
175
+ const raw = await readStdin();
176
+ let payload;
177
+ try {
178
+ payload = JSON.parse(raw);
179
+ }
180
+ catch {
181
+ process.exit(0);
182
+ }
183
+ if (payload?.hook_event_name !== "Stop")
184
+ process.exit(0);
185
+ if (payload?.stop_hook_active)
186
+ process.exit(0);
187
+ // Include registered sibling repositories even when cwd has no marker.
188
+ const cwdMarker = findMarkerUp(process.cwd());
189
+ const markers = collectSessionMarkers(cwdMarker, payload?.session_id);
190
+ if (markers.length === 0)
191
+ process.exit(0);
192
+ const allPending = [];
193
+ const blockReasons = [];
194
+ for (const markerDir of markers) {
195
+ const { pending, blockReason } = evaluateMarker(markerDir);
196
+ allPending.push(...pending);
197
+ if (blockReason)
198
+ blockReasons.push(blockReason);
199
+ }
200
+ // Apply the verdict surface cap once across all markers.
201
+ const pendingCombined = allPending.length > 0 ? joinPendingForSurface(allPending) : null;
202
+ // Any blocking marker blocks the turn; pending text uses the matching Stop output channel.
203
+ if (blockReasons.length > 0) {
204
+ let reason = blockReasons.join("\n\n");
205
+ if (pendingCombined)
206
+ reason = `${pendingCombined}\n\n${reason}`;
207
+ writeAndExit({ decision: "block", reason });
208
+ return;
209
+ }
210
+ if (pendingCombined) {
211
+ writeAndExit({
212
+ hookSpecificOutput: { hookEventName: "Stop", additionalContext: pendingCombined },
213
+ });
214
+ return;
215
+ }
221
216
  process.exit(0);
222
- }
223
- if (payload?.hook_event_name !== "Stop") process.exit(0);
224
- if (payload?.stop_hook_active) process.exit(0);
225
-
226
- // ADR-131 (#209): evaluate EVERY repo active this session, not just cwd. The
227
- // cwd marker may even be null (cwd repo has no .codex-pair) while edits landed
228
- // in a registered sibling repo — so we no longer early-exit on a missing cwd
229
- // marker; collectSessionMarkers unions cwd (if any) with the registered set.
230
- const cwdMarker = findMarkerUp(process.cwd());
231
- const markers = collectSessionMarkers(cwdMarker, payload?.session_id);
232
- if (markers.length === 0) process.exit(0);
233
-
234
- const allPending = [];
235
- const blockReasons = [];
236
- for (const markerDir of markers) {
237
- const { pending, blockReason } = evaluateMarker(markerDir);
238
- allPending.push(...pending);
239
- if (blockReason) blockReasons.push(blockReason);
240
- }
241
- // Cap the surfaced blob ONCE across all markers (MAX_SURFACE_VERDICTS is global,
242
- // not per-repo) — mirrors codex-pair-prompt-drain.mjs.
243
- const pendingCombined = allPending.length > 0 ? joinPendingForSurface(allPending) : null;
244
-
245
- // Block if ANY marker blocks; each repo keeps its own blockOn/timeoutMs. The
246
- // block reason folds in every blocking marker's message plus all pending text;
247
- // a non-blocking turn with pending verdicts surfaces them as Stop
248
- // additionalContext (preserved ADR-130 channel — block path uses `reason`).
249
- if (blockReasons.length > 0) {
250
- let reason = blockReasons.join("\n\n");
251
- if (pendingCombined) reason = `${pendingCombined}\n\n${reason}`;
252
- writeAndExit({ decision: "block", reason });
253
- return;
254
- }
255
-
256
- if (pendingCombined) {
257
- writeAndExit({
258
- hookSpecificOutput: { hookEventName: "Stop", additionalContext: pendingCombined },
259
- });
260
- return;
261
- }
262
-
263
- process.exit(0);
264
217
  }
265
-
266
218
  main().catch((err) => {
267
- process.stderr.write(
268
- `[codex-pair] WARNING: stop-gate failed (${err?.message ?? err}). Allowing turn end — HIGH findings may remain.\n`,
269
- );
270
- process.exit(0);
219
+ process.stderr.write(`[codex-pair] WARNING: stop-gate failed (${err?.message ?? err}). Allowing turn end — HIGH findings may remain.\n`);
220
+ process.exit(0);
271
221
  });