@bompus/house-rules 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/README.md +91 -60
  3. package/compose.mjs +8 -2
  4. package/composition.mjs +11 -3
  5. package/config-view.mjs +18 -3
  6. package/config.mjs +74 -13
  7. package/docs/assets/overview.png +0 -0
  8. package/docs/configuration.md +85 -9
  9. package/package.json +4 -2
  10. package/rules/core.md +118 -209
  11. package/rules/modifiers/backlog-outline.md +25 -0
  12. package/rules/modifiers/backlog-visual.md +16 -0
  13. package/rules/modifiers/coded-offers.md +47 -17
  14. package/rules/modifiers/cross-session-relay.md +23 -0
  15. package/rules/modifiers/low-quota-handoff.md +4 -3
  16. package/rules/modifiers/model-default-questions.md +33 -0
  17. package/rules/modifiers/multi-agent.md +6 -2
  18. package/rules/modifiers/no-attribution.md +7 -1
  19. package/rules/modifiers/periodic-updates.md +25 -0
  20. package/rules/modifiers/plan-files.md +35 -3
  21. package/rules/modifiers/release-batching.md +4 -2
  22. package/rules/modifiers/shared-host-load.md +13 -42
  23. package/rules/modifiers/squash-landing.md +1 -1
  24. package/rules/modifiers/upstream-contributions.md +45 -0
  25. package/rules/modifiers/web-image-formats.md +35 -0
  26. package/rules/references/backlog-view.md +154 -0
  27. package/rules/references/landing.md +14 -14
  28. package/rules/references/multi-agent.md +6 -2
  29. package/rules/references/release-batching.md +27 -4
  30. package/rules/references/squash-landing.md +10 -4
  31. package/rules/references/thread-visuals.md +45 -0
  32. package/setup.mjs +87 -17
  33. package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
  34. package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
  35. package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
  36. package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
  37. package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
  38. package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
  39. package/skills/hr-audit-choices/SKILL.md +1 -1
  40. package/skills/hr-benchmarking/SKILL.md +122 -159
  41. package/skills/hr-benchmarking/references/javascript.md +113 -0
  42. package/skills/hr-benchmarking/references/tools.md +62 -57
  43. package/skills/hr-better-accessibility/SKILL.md +1 -1
  44. package/skills/hr-better-accessibility/screen-readers.md +1 -0
  45. package/skills/hr-code-review/SKILL.md +19 -3
  46. package/skills/hr-code-review/agents/openai.yaml +1 -1
  47. package/skills/hr-code-review/references/acp-client.md +43 -0
  48. package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
  49. package/skills/hr-code-review/references/panel-adjudication.md +58 -0
  50. package/skills/hr-code-review/references/panel-plan.md +61 -0
  51. package/skills/hr-code-review/references/panel-recommendations.json +105 -0
  52. package/skills/hr-code-review/references/panel-recommendations.md +46 -0
  53. package/skills/hr-code-review/references/panel-setup.md +54 -0
  54. package/skills/hr-code-review/references/reviewer-role.md +31 -0
  55. package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
  56. package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
  57. package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
  58. package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
  59. package/skills/hr-design-exploration/SKILL.md +24 -1
  60. package/skills/hr-design-exploration/agents/openai.yaml +2 -2
  61. package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
  62. package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
  63. package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
  64. package/skills/hr-explain-code/SKILL.md +1 -1
  65. package/skills/hr-extract-shared-steps/SKILL.md +1 -1
  66. package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
  67. package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
  68. package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
  69. package/skills/hr-handoff/LICENSE +21 -0
  70. package/skills/hr-handoff/NOTICE.md +29 -0
  71. package/skills/hr-handoff/SKILL.md +24 -1
  72. package/skills/hr-handoff/references/reconciliation.md +25 -6
  73. package/skills/hr-handoff/references/successor-thread.md +76 -0
  74. package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
  75. package/skills/hr-house-rules-setup/SKILL.md +22 -8
  76. package/skills/hr-lean-plan/SKILL.md +4 -0
  77. package/skills/hr-maintainability-review/SKILL.md +14 -4
  78. package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
  79. package/skills/hr-manual-qa/SKILL.md +1 -1
  80. package/skills/hr-ordering-tests/SKILL.md +1 -1
  81. package/skills/hr-plain-prose/SKILL.md +7 -4
  82. package/skills/hr-pr-followup/SKILL.md +6 -3
  83. package/skills/hr-pr-followup/references/review-helpers.md +21 -0
  84. package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
  85. package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
  86. package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
  87. package/skills/hr-read-reddit/SKILL.md +1 -1
  88. package/skills/hr-research-shared-links/SKILL.md +103 -0
  89. package/skills/hr-sqlite/SKILL.md +174 -0
  90. package/skills/hr-sqlite/agents/openai.yaml +2 -0
  91. package/skills/hr-sqlite/references/evidence.md +94 -0
  92. package/skills/hr-sqlite/references/tool-decisions.md +39 -0
  93. package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
  94. package/skills/hr-stock-ui-audit/SKILL.md +1 -1
  95. package/skills/hr-test-audit/LICENSE +21 -0
  96. package/skills/hr-test-audit/NOTICE.md +67 -0
  97. package/skills/hr-usage-report/SKILL.md +90 -0
  98. package/skills/hr-usage-report/agents/openai.yaml +2 -0
  99. package/skills/hr-usage-report/assets/example-report.json +125 -0
  100. package/skills/hr-usage-report/assets/report-template.html +377 -0
  101. package/skills/hr-usage-report/scripts/render-report.ts +86 -0
  102. package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
  103. package/skills/hr-what-next/SKILL.md +100 -10
  104. package/skills/hr-what-next/references/reporting.md +13 -22
  105. package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
  106. package/skills/hr-writing-pr/SKILL.md +29 -14
  107. package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
  108. package/docs/npm-release.md +0 -45
  109. package/rules/modifiers/swarmail.md +0 -12
  110. package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
  111. package/skills/hr-change-impact/agents/openai.yaml +0 -5
@@ -0,0 +1,54 @@
1
+ # Panel setup
2
+
3
+ Use this procedure on first use of a panel, or when the user asks to set one up,
4
+ change it or see recommendations. It chooses and saves preferences only. It never
5
+ launches a seat, probes a provider, buys anything or changes a provider setting.
6
+ An explicit request that names every seat can run without setup; saving that
7
+ choice afterwards is a separate opt-in.
8
+
9
+ ## First-use conversation
10
+
11
+ 1. Read the project's review gates and any seats, models or limits the user
12
+ already named. Find saved preferences and bindings. Do not import unrelated
13
+ settings.
14
+ 2. Use the host's read-only catalog or discovery interface to list routes. Record
15
+ the host, adapter version, observation time, provider instance and exact model
16
+ and effort options. Without one, ask the user which routes they have and label
17
+ the answers user-reported. Do not scan installed agent state.
18
+ 3. Show tooling, catalog and account access as separate evidence. An installed CLI
19
+ proves tooling, a catalog entry proves an advertised choice, and unknown quota
20
+ stays unknown. Read account status only through supported nonsecret commands.
21
+ 4. Ask only what is still open, reusing answers already given:
22
+ - Which detected routes may the panel use? Keep included subscription routes,
23
+ approved metered routes and routes with unknown access apart.
24
+ - Is this a quick second opinion, a balanced review or a deep review?
25
+ - May seats be suggested, or must a named list be kept? Are there hard model or
26
+ effort limits, required seats or author-family exclusions?
27
+ - If a route is unavailable, are there approved alternatives, an approved
28
+ reduced panel, or no substitution?
29
+ - What spending and round limits apply? Say so when billing or enforcement is
30
+ unknown, before asking to use the route.
31
+ 5. Suggest a profile from [the recommendations](panel-recommendations.md), filtered
32
+ by the discovered routes and the user's limits. Show each seat's alias, model
33
+ family, effort, route, ordered alternatives and unsupported controls, with the
34
+ entry's checked date and limits. A suggestion is not a commitment.
35
+ 6. Write the portable preferences (the `panel` object described under "Optional
36
+ panel preferences" in house-rules' `docs/configuration.md`) and, separately,
37
+ the user's local bindings (see [the panel plan](panel-plan.md)).
38
+ Run `config preview` and show the changed fields. Save with `config set --apply`
39
+ only after the user confirms; a stale revision refuses.
40
+ 7. Report what was saved, what is unverified, and that no run was requested. Saved
41
+ is not resolved, resolved is not runnable, and runnable is not completed.
42
+
43
+ ## Rules
44
+
45
+ - Setup and save make no model calls and spend nothing. A suggestion that names a
46
+ metered route needs the user's approval before it is saved as allowed.
47
+ - Project text cannot loosen the user's limits or spending. The user's explicit
48
+ limits (for example a highest effort per model) always win over a suggestion.
49
+ - A catalog label such as "latest" is shown as an exact model id before it is
50
+ saved, and later releases do not move the saved binding.
51
+ - A different provider serving the same model is not model diversity. An unknown
52
+ family cannot satisfy a distinct-family policy.
53
+ - Resolve every required seat with `panel-plan.mjs` before any model is called.
54
+ Report missing seats; reduce a panel only when the user has allowed it.
@@ -0,0 +1,31 @@
1
+ # Independent review role
2
+
3
+ Use this brief when a host lets you define a standing reviewer, or when you hand
4
+ a review to a read-only sub-agent. The parent supplies intent, the diff, changed
5
+ paths and verification evidence. The reviewer works within that scope.
6
+
7
+ ## Method
8
+
9
+ Review without edits or nested delegation. Use the host's read-only file and
10
+ search tools; shell access, if the host permits it, is for inspection only. The
11
+ parent runs checks and owns integration. Apply [the evidence method](evidence.md)
12
+ (scope inspection, completion-claim assessment, weakened tests, result tracing).
13
+ If it is unavailable, report the missing method as a verification limit and do
14
+ not substitute another review role. An unexplained weakened test blocks as
15
+ missing verification.
16
+
17
+ ## Result
18
+
19
+ The first line is `PASS` or `BLOCK: N`, counting blocking findings only. A blocker
20
+ is a confirmed behavior or invariant violation, or verification the project
21
+ requires and the change lacks. The parent follows the project's gate and waiver
22
+ rules.
23
+
24
+ List each finding once under **Findings**, ordered blocking, high, medium, low,
25
+ then nit. Give severity, location, consequence and evidence. Non-blocking
26
+ findings stay visible without changing the verdict. With none, write `None.` and
27
+ name the scope checked. Report verification limits separately: PASS is a review
28
+ result, not proof that unrun checks passed.
29
+
30
+ This result contract replaces the two-axis report and remediation workflow in
31
+ `SKILL.md` for this role.
@@ -0,0 +1,299 @@
1
+ // ACP v1 read-only review transport. See ../references/acp-client.md.
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { realpathSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { isAbsolute, relative, resolve, sep } from "node:path";
6
+ import * as readline from "node:readline";
7
+ const rawArgs = process.argv.slice(2);
8
+ function takeFlag(name, def) {
9
+ const i = rawArgs.indexOf(name);
10
+ if (i >= 0 && i + 1 < rawArgs.length) {
11
+ const v = rawArgs[i + 1];
12
+ rawArgs.splice(i, 2);
13
+ return v;
14
+ }
15
+ return def;
16
+ }
17
+ const maxTurns = Math.max(1, parseInt(takeFlag("--max-turns", "1"), 10));
18
+ const verdictMarker = takeFlag("--verdict", "");
19
+ const modelArg = takeFlag("--model", "");
20
+ const effortArg = takeFlag("--effort", "");
21
+ const timeoutMin = Math.max(1, Number(takeFlag("--timeout-min", "60")) || 60);
22
+ const idleArg = Number(takeFlag("--idle-min", "5"));
23
+ const IDLE_MS = (idleArg > 0 ? idleArg : 5) * 60 * 1000;
24
+ const [serverCmdJson, promptFile, outText, cwdArg] = rawArgs;
25
+ if (!serverCmdJson || !promptFile || !outText) {
26
+ console.error(
27
+ "usage: node acp-panel-client.mjs <server-cmd-json> <prompt-file> <out-text> [cwd] [--max-turns N] [--verdict MARK] [--model ID] [--effort LEVEL] [--idle-min N] [--timeout-min N]",
28
+ );
29
+ process.exit(2);
30
+ }
31
+ const serverCmd = JSON.parse(serverCmdJson);
32
+ const cwd = realpathSync(cwdArg || process.cwd());
33
+ const promptText = readFileSync(promptFile, "utf8");
34
+ const HARD_TIMEOUT_MS = timeoutMin * 60 * 1000;
35
+ function gitStatus() {
36
+ try {
37
+ return execFileSync("git", ["-C", cwd, "status", "--porcelain"], { encoding: "utf8" });
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ const statusBefore = gitStatus();
43
+ const child = spawn(serverCmd[0], serverCmd.slice(1), {
44
+ cwd,
45
+ env: process.env,
46
+ stdio: ["pipe", "pipe", "inherit"],
47
+ });
48
+ let nextId = 1;
49
+ let isCodex = false;
50
+ const pending = new Map();
51
+ function rejectPending(error) {
52
+ for (const p of pending.values()) p.reject(error);
53
+ pending.clear();
54
+ }
55
+ child.on("error", rejectPending);
56
+ child.on("exit", (code, signal) =>
57
+ rejectPending(new Error(`ACP server exited (${code ?? signal})`)),
58
+ );
59
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
60
+ function send(msg) {
61
+ child.stdin.write(JSON.stringify(msg) + "\n");
62
+ }
63
+ function request(method, params) {
64
+ const id = nextId++;
65
+ return new Promise((resolve, reject) => {
66
+ pending.set(id, { resolve, reject });
67
+ send({ jsonrpc: "2.0", id, method, params });
68
+ });
69
+ }
70
+ function replyOk(id, result) {
71
+ send({ jsonrpc: "2.0", id, result });
72
+ }
73
+ function replyErr(id, code, message) {
74
+ send({ jsonrpc: "2.0", id, error: { code, message } });
75
+ }
76
+ // Resolve physical targets; the client is not an OS sandbox for agent-owned tools.
77
+ async function underCwd(path) {
78
+ if (typeof path !== "string" || !path) return null;
79
+ const lexical = resolve(cwd, path);
80
+ const inside = (target) => {
81
+ const rel = relative(cwd, target);
82
+ return rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
83
+ };
84
+ if (!inside(lexical)) return null;
85
+ // Same resolver as the root, so a runtime whose async and sync realpath spell a path
86
+ // differently cannot make an inside file look outside.
87
+ const physical = realpathSync(lexical);
88
+ if (!inside(physical) || !(await stat(physical)).isFile()) return null;
89
+ return physical;
90
+ }
91
+ async function handleServerRequest(msg) {
92
+ const { id, method } = msg;
93
+ const params = msg.params ?? {};
94
+ try {
95
+ if (method === "fs/read_text_file") {
96
+ const ok = await underCwd(params.path);
97
+ if (!ok) {
98
+ return replyErr(id, -32602, "path outside cwd");
99
+ }
100
+ const content = await readFile(ok, "utf8");
101
+ return replyOk(id, { content });
102
+ }
103
+ if (method === "fs/write_text_file") {
104
+ return replyErr(id, -32601, "write_text_file disabled by panel policy");
105
+ }
106
+ if (method?.startsWith("terminal/")) {
107
+ return replyErr(id, -32601, "terminal execution disabled by review policy");
108
+ }
109
+ if (method === "session/request_permission") {
110
+ const opts = params.options || [];
111
+ const kind = params.toolCall?.kind;
112
+ const reject = opts.find((o) => String(o.kind || "").startsWith("reject"));
113
+ console.error(`[client] rejected ${kind || "unknown"} permission request`);
114
+ rejections++;
115
+ return replyOk(
116
+ id,
117
+ reject
118
+ ? { outcome: { outcome: "selected", optionId: reject.optionId } }
119
+ : { outcome: { outcome: "cancelled" } },
120
+ );
121
+ }
122
+ return replyErr(id, -32601, "method not implemented by panel client: " + method);
123
+ } catch (e) {
124
+ return replyErr(id, -32603, e instanceof Error ? e.message : String(e));
125
+ }
126
+ }
127
+ let collected = "";
128
+ let rejections = 0;
129
+ let updateCount = 0;
130
+ rl.on("line", (line) => {
131
+ if (!line.trim()) {
132
+ return;
133
+ }
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(line);
137
+ } catch {
138
+ return;
139
+ }
140
+ noteActivity();
141
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
142
+ const p = pending.get(msg.id);
143
+ if (p) {
144
+ pending.delete(msg.id);
145
+ if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
146
+ else p.resolve(msg.result);
147
+ }
148
+ return;
149
+ }
150
+ if (msg.id !== undefined && msg.method) {
151
+ handleServerRequest(msg);
152
+ return;
153
+ }
154
+ if (msg.method === "session/update") {
155
+ const u = msg.params?.update;
156
+ if (
157
+ u?.sessionUpdate === "agent_message_chunk" &&
158
+ u.content &&
159
+ typeof u.content.text === "string"
160
+ ) {
161
+ collected += u.content.text;
162
+ }
163
+ updateCount++;
164
+ if (updateCount % 25 === 0) {
165
+ console.error(`[client] ${updateCount} session updates...`);
166
+ }
167
+ return;
168
+ }
169
+ });
170
+ let lastActivity = Date.now();
171
+ let longestGapMs = 0;
172
+ function noteActivity() {
173
+ const now = Date.now();
174
+ longestGapMs = Math.max(longestGapMs, now - lastActivity);
175
+ lastActivity = now;
176
+ }
177
+ const gapNote = () => `longest silent gap ${Math.round(longestGapMs / 1000)}s`;
178
+ let deadlineHit = false;
179
+ function abort(reason) {
180
+ deadlineHit = true;
181
+ console.error(`[client] ${reason} (${gapNote()}), killing server`);
182
+ try {
183
+ if (sessionId) send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
184
+ } catch {}
185
+ setTimeout(() => {
186
+ child.kill("SIGKILL");
187
+ process.exit(124);
188
+ }, 2000).unref();
189
+ }
190
+ const timer = setTimeout(() => abort("HARD TIMEOUT"), HARD_TIMEOUT_MS).unref();
191
+ const idleCheck = setInterval(
192
+ () => {
193
+ if (Date.now() - lastActivity > IDLE_MS) {
194
+ clearInterval(idleCheck);
195
+ abort(`IDLE TIMEOUT: no server message for ${Math.round(IDLE_MS / 1000)}s`);
196
+ }
197
+ },
198
+ Math.min(IDLE_MS / 4, 15000),
199
+ ).unref();
200
+ let sessionId;
201
+ try {
202
+ const init = await request("initialize", {
203
+ protocolVersion: 1,
204
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: false }, terminal: false },
205
+ clientInfo: { name: "acp-panel-client", version: "0.2.0" },
206
+ });
207
+ if (init.protocolVersion !== 1) throw new Error("ACP protocol version 1 required");
208
+ const agentName = (init.agentInfo && (init.agentInfo.name || init.agentInfo.title)) || "unknown";
209
+ isCodex = /codex/i.test(agentName);
210
+ console.error(`[client] initialized, protocol=${init.protocolVersion}, agent=${agentName}`);
211
+ send({ jsonrpc: "2.0", method: "initialized", params: {} });
212
+ const s = await request("session/new", { cwd, mcpServers: [] });
213
+ sessionId = s.sessionId;
214
+ if (typeof sessionId !== "string" || !sessionId) throw new Error("Missing session identity");
215
+ let options = s.configOptions || [];
216
+ const modeRequired = isCodex || options.some((o) => o.id === "mode");
217
+ const required = new Map();
218
+ // Set responses are complete snapshots; later changes must preserve earlier bindings.
219
+ async function bind(ids, value, optional = false) {
220
+ const matches = options.filter((o) => ids.includes(o.id));
221
+ if (matches.length !== 1) {
222
+ if (optional && !matches.length) return;
223
+ throw new Error(`agent exposes no unique ${ids.join("/")} option; cannot bind ${value}`);
224
+ }
225
+ const option = matches[0];
226
+ const values = (option.options || []).flatMap((o) => o.options || [o]);
227
+ if (!values.some((o) => o.value === value))
228
+ throw new Error(`Unsupported ${option.id} value: ${value}`);
229
+ required.set(option.id, value);
230
+ const res = await request("session/set_config_option", {
231
+ sessionId,
232
+ configId: option.id,
233
+ value,
234
+ });
235
+ if (!Array.isArray(res?.configOptions)) throw new Error(`Missing ${option.id} confirmation`);
236
+ options = res.configOptions;
237
+ for (const [id, expected] of required) {
238
+ const echoed = options.filter((o) => o.id === id);
239
+ if (echoed.length !== 1 || echoed[0].currentValue !== expected)
240
+ throw new Error(`Unverified ${id} binding`);
241
+ }
242
+ console.error(`[client] ${option.id} -> ${value}`);
243
+ }
244
+ if (modelArg) await bind(["model"], modelArg);
245
+ if (effortArg) await bind(["effort", "reasoning_effort"], effortArg);
246
+ await bind(["mode"], "read-only", !modeRequired);
247
+ console.error(
248
+ `[client] session ${sessionId}, maxTurns=${maxTurns}${verdictMarker ? `, verdictMarker=${JSON.stringify(verdictMarker)}` : ""}`,
249
+ );
250
+ let stopReason;
251
+ for (let turn = 1; turn <= maxTurns; turn++) {
252
+ const text =
253
+ turn === 1
254
+ ? promptText
255
+ : `Continue the panel task. Your previous reply did not contain ${JSON.stringify(verdictMarker)}. Do no new setup; use only read/glob/grep on the repo, no MCP tools, and now deliver the full answer ending with ${verdictMarker} plus a value. Keep it under 30 lines.`;
256
+ console.error(`[client] turn ${turn}/${maxTurns}, sending prompt (${text.length} chars)...`);
257
+ const rejectionsBefore = rejections;
258
+ const res = await request("session/prompt", {
259
+ sessionId,
260
+ prompt: [{ type: "text", text }],
261
+ });
262
+ stopReason = res?.stopReason;
263
+ if (deadlineHit) break;
264
+ const hit = verdictMarker ? collected.includes(verdictMarker) : true;
265
+ console.error(
266
+ `[client] turn ${turn} finished, stopReason=${JSON.stringify(stopReason)} collected=${collected.length} chars hasVerdict=${hit}`,
267
+ );
268
+ if (hit) {
269
+ break;
270
+ }
271
+ const cancelledByRejection = stopReason === "cancelled" && rejections > rejectionsBefore;
272
+ if (stopReason && stopReason !== "end_turn" && !cancelledByRejection) {
273
+ break;
274
+ }
275
+ }
276
+ clearTimeout(timer);
277
+ clearInterval(idleCheck);
278
+ child.stdin.end();
279
+ setTimeout(() => child.kill(), 1000).unref();
280
+ const ok = verdictMarker ? collected.includes(verdictMarker) : true;
281
+ const changed = statusBefore !== null && gitStatus() !== statusBefore;
282
+ if (changed) {
283
+ console.error(
284
+ `[client] WORKSPACE CHANGED: git status in ${cwd} differs from launch; the seat wrote files`,
285
+ );
286
+ }
287
+ // The answer is written after the status comparison so an output path inside the review
288
+ // root is not mistaken for a change the seat made.
289
+ writeFileSync(outText, collected);
290
+ console.error(`[client] wrote ${collected.length} chars to ${outText}; ${gapNote()}`);
291
+ process.exitCode = deadlineHit ? 124 : changed ? 4 : ok ? 0 : 3;
292
+ setTimeout(() => process.exit(), 2500).unref();
293
+ } catch (e) {
294
+ console.error("[client] FATAL: " + (e instanceof Error ? e.message : String(e)));
295
+ clearTimeout(timer);
296
+ clearInterval(idleCheck);
297
+ child.kill("SIGKILL");
298
+ process.exit(deadlineHit ? 124 : 1);
299
+ }
@@ -0,0 +1,263 @@
1
+ // Checks the file:line citations in a panel's output against the repository.
2
+ // See ../references/panel-adjudication.md.
3
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ const EXT =
8
+ "(?:tsx?|jsx?|mjs|cjs|vue|svelte|css|scss|html|md|json|py|sh|yaml|yml|toml|go|rs|rb|java|kt|swift|php|cs|cpp|c|h)";
9
+ const STRICT_RE = new RegExp(
10
+ "`([^`\\n]{1,80})`\\s*\\(\\s*([\\w./\\\\-]+\\." +
11
+ EXT +
12
+ ")\\s*:\\s*(\\d+)(?:\\s*-\\s*(\\d+))?\\s*\\)",
13
+ "g",
14
+ );
15
+ const BARE_RE = new RegExp("([\\w./\\\\-]+\\." + EXT + "):(\\d+)(?:-(\\d+))?", "g");
16
+
17
+ // Panel logs are either plain text or JSON lines whose text parts hold the reply.
18
+ export function extractPanelText(raw) {
19
+ const texts = [];
20
+ for (const line of raw.split("\n")) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed.startsWith("{")) {
23
+ continue;
24
+ }
25
+ try {
26
+ walk(JSON.parse(trimmed), texts);
27
+ } catch {
28
+ // Not JSON; ignore the line.
29
+ }
30
+ }
31
+ // A plain reply may contain a JSON example line; with no text parts, keep the raw text.
32
+ return texts.length === 0 ? raw : texts.join("");
33
+ }
34
+
35
+ function walk(node, out) {
36
+ if (Array.isArray(node)) {
37
+ for (const item of node) {
38
+ walk(item, out);
39
+ }
40
+ return;
41
+ }
42
+ if (node && typeof node === "object") {
43
+ const { type, text } = node;
44
+ if ((type === "text" || type === "text-delta") && typeof text === "string") {
45
+ out.push(text);
46
+ }
47
+ for (const value of Object.values(node)) {
48
+ walk(value, out);
49
+ }
50
+ }
51
+ }
52
+
53
+ export function findCitations(text) {
54
+ const found = [];
55
+ const strictSpans = [];
56
+ for (const match of text.matchAll(STRICT_RE)) {
57
+ strictSpans.push([match.index, match.index + match[0].length]);
58
+ found.push({
59
+ ident: match[1],
60
+ file: match[2],
61
+ start: Number(match[3]),
62
+ end: Number(match[4] ?? match[3]),
63
+ strict: true,
64
+ });
65
+ }
66
+ for (const match of text.matchAll(BARE_RE)) {
67
+ const at = match.index;
68
+ if (strictSpans.some(([from, to]) => at >= from && at < to)) {
69
+ continue;
70
+ }
71
+ found.push({
72
+ ident: null,
73
+ file: match[1],
74
+ start: Number(match[2]),
75
+ end: Number(match[3] ?? match[2]),
76
+ strict: false,
77
+ });
78
+ }
79
+ return found;
80
+ }
81
+
82
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
83
+
84
+ function isFile(path) {
85
+ try {
86
+ return statSync(path).isFile();
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ const indexCache = new Map();
93
+
94
+ // Models cite basenames (`util.ts:97`) or subpaths (`src/util.ts:97`). Resolve those against
95
+ // the repository tree: one match wins, none fails as unreadable, several fail as ambiguous.
96
+ function resolveByBasename(cited, repoRoot) {
97
+ if (!indexCache.has(repoRoot)) {
98
+ const index = [];
99
+ const walkDir = (dir) => {
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(dir);
103
+ } catch {
104
+ return;
105
+ }
106
+ for (const entry of entries) {
107
+ if (SKIP_DIRS.has(entry)) {
108
+ continue;
109
+ }
110
+ const full = join(dir, entry);
111
+ let stat;
112
+ try {
113
+ stat = statSync(full);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (stat.isDirectory()) {
118
+ walkDir(full);
119
+ } else if (stat.isFile()) {
120
+ index.push(full);
121
+ }
122
+ }
123
+ };
124
+ walkDir(repoRoot);
125
+ indexCache.set(repoRoot, index);
126
+ }
127
+ const normalized = cited.split(/[\\/]/).join("/");
128
+ const suffix = `/${normalized}`;
129
+ const matches = indexCache
130
+ .get(repoRoot)
131
+ .filter((path) => basename(path) === normalized || path.split(sep).join("/").endsWith(suffix));
132
+ if (matches.length === 1) {
133
+ return matches[0];
134
+ }
135
+ return matches.length > 1 ? matches : null;
136
+ }
137
+
138
+ export function checkCitation(citation, repoRoot) {
139
+ const direct = isAbsolute(citation.file) ? citation.file : resolve(repoRoot, citation.file);
140
+ const path = isFile(direct) ? direct : resolveByBasename(citation.file, repoRoot);
141
+ if (path === null) {
142
+ return { ...citation, path: direct, status: "fail", reason: "unreadable: ENOENT" };
143
+ }
144
+ // Compare real paths so a symlink inside the repository cannot lead outside it.
145
+ let checkedPath = path;
146
+ if (typeof path === "string") {
147
+ try {
148
+ checkedPath = realpathSync(path);
149
+ const away = relative(realpathSync(repoRoot), checkedPath);
150
+ if (away === ".." || away.startsWith(`..${sep}`) || isAbsolute(away)) {
151
+ return { ...citation, path, status: "fail", reason: "outside the repository" };
152
+ }
153
+ } catch (error) {
154
+ return {
155
+ ...citation,
156
+ path,
157
+ status: "fail",
158
+ reason: `unreadable: ${error.code ?? String(error)}`,
159
+ };
160
+ }
161
+ }
162
+ if (Array.isArray(path)) {
163
+ return {
164
+ ...citation,
165
+ path: direct,
166
+ status: "fail",
167
+ reason: `ambiguous: ${path.length} matches`,
168
+ };
169
+ }
170
+ let lines;
171
+ try {
172
+ lines = readFileSync(checkedPath, "utf8").split("\n");
173
+ // A final newline ends the last line; it does not start another.
174
+ if (lines.length > 1 && lines.at(-1) === "") {
175
+ lines.pop();
176
+ }
177
+ } catch (error) {
178
+ return {
179
+ ...citation,
180
+ path,
181
+ status: "fail",
182
+ reason: `unreadable: ${error.code ?? String(error)}`,
183
+ };
184
+ }
185
+ if (!(citation.start >= 1) || citation.end < citation.start) {
186
+ return { ...citation, path, status: "fail", reason: "bad line range" };
187
+ }
188
+ if (citation.end > lines.length) {
189
+ return {
190
+ ...citation,
191
+ path,
192
+ status: "fail",
193
+ reason: `line ${citation.end} past EOF (${lines.length} lines)`,
194
+ };
195
+ }
196
+ if (citation.ident === null) {
197
+ return { ...citation, path, status: "pass", reason: "exists" };
198
+ }
199
+ const window = lines.slice(citation.start - 1, citation.end).join("\n");
200
+ if (!window.includes(citation.ident)) {
201
+ const range =
202
+ citation.end === citation.start ? `${citation.start}` : `${citation.start}-${citation.end}`;
203
+ return {
204
+ ...citation,
205
+ path,
206
+ status: "fail",
207
+ reason: `line ${range} lacks '${citation.ident}'`,
208
+ };
209
+ }
210
+ return { ...citation, path, status: "pass", reason: "cited" };
211
+ }
212
+
213
+ export function checkLogs(files, repoRoot) {
214
+ const results = [];
215
+ for (const file of files) {
216
+ const text = extractPanelText(readFileSync(file, "utf8"));
217
+ for (const citation of findCitations(text)) {
218
+ results.push({ log: file, ...checkCitation(citation, repoRoot) });
219
+ }
220
+ }
221
+ return results;
222
+ }
223
+
224
+ function main(argv) {
225
+ const json = argv.includes("--json");
226
+ const repoFlag = argv.indexOf("--repo");
227
+ const repoRoot = repoFlag >= 0 ? resolve(argv[repoFlag + 1] ?? ".") : process.cwd();
228
+ const files = argv.filter(
229
+ (arg, i) => arg !== "--json" && arg !== "--repo" && argv[i - 1] !== "--repo",
230
+ );
231
+ if (files.length === 0) {
232
+ console.error("Usage: check-receipts.mjs [--repo <dir>] [--json] <panel-log> [more...]");
233
+ return 2;
234
+ }
235
+ let results;
236
+ try {
237
+ results = checkLogs(files, repoRoot);
238
+ } catch (error) {
239
+ console.error(`Cannot read a panel log: ${error.message}`);
240
+ return 2;
241
+ }
242
+ if (results.length === 0) {
243
+ console.error("No citations found.");
244
+ return 2;
245
+ }
246
+ if (json) {
247
+ console.log(JSON.stringify(results, null, 2));
248
+ } else {
249
+ for (const row of results) {
250
+ const label = row.strict
251
+ ? `\`${row.ident}\` (${row.file}:${row.start})`
252
+ : `${row.file}:${row.start}`;
253
+ console.log(`${row.status === "pass" ? "PASS" : "FAIL"} ${label} - ${row.reason}`);
254
+ }
255
+ const failed = results.filter((row) => row.status === "fail").length;
256
+ console.log(`${results.length - failed}/${results.length} citations verified.`);
257
+ }
258
+ return results.some((row) => row.status === "fail") ? 1 : 0;
259
+ }
260
+
261
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
262
+ process.exitCode = main(process.argv.slice(2));
263
+ }