@bompus/house-rules 0.11.0 → 0.14.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/CODE_OF_CONDUCT.md +83 -0
- package/README.md +102 -63
- package/compose.mjs +8 -2
- package/composition.mjs +11 -3
- package/config-view.mjs +18 -3
- package/config.mjs +74 -13
- package/docs/assets/overview.png +0 -0
- package/docs/configuration.md +85 -9
- package/package.json +4 -2
- package/rules/core.md +118 -209
- package/rules/modifiers/backlog-outline.md +25 -0
- package/rules/modifiers/backlog-visual.md +16 -0
- package/rules/modifiers/coded-offers.md +47 -17
- package/rules/modifiers/cross-session-relay.md +23 -0
- package/rules/modifiers/low-quota-handoff.md +4 -3
- package/rules/modifiers/model-default-questions.md +33 -0
- package/rules/modifiers/multi-agent.md +6 -2
- package/rules/modifiers/no-attribution.md +7 -1
- package/rules/modifiers/periodic-updates.md +25 -0
- package/rules/modifiers/plan-files.md +35 -3
- package/rules/modifiers/release-batching.md +4 -2
- package/rules/modifiers/shared-host-load.md +13 -42
- package/rules/modifiers/squash-landing.md +1 -1
- package/rules/modifiers/upstream-contributions.md +45 -0
- package/rules/modifiers/web-image-formats.md +35 -0
- package/rules/references/backlog-view.md +154 -0
- package/rules/references/landing.md +14 -14
- package/rules/references/multi-agent.md +6 -2
- package/rules/references/release-batching.md +37 -4
- package/rules/references/squash-landing.md +10 -4
- package/rules/references/thread-visuals.md +45 -0
- package/setup.mjs +87 -17
- package/skills/hr-agent-guidance-audit/SKILL.md +10 -5
- package/skills/hr-agent-guidance-audit/references/prune-workflow.md +72 -0
- package/skills/hr-agent-guidance-audit/references/records-and-lessons.md +1 -1
- package/skills/hr-agent-guidance-audit/references/retirement-verdicts.md +49 -0
- package/skills/hr-agent-guidance-refresh/SKILL.md +25 -57
- package/skills/hr-agent-guidance-refresh/references/verification.md +1 -1
- package/skills/hr-audit-choices/SKILL.md +1 -1
- package/skills/hr-benchmarking/SKILL.md +122 -159
- package/skills/hr-benchmarking/references/javascript.md +113 -0
- package/skills/hr-benchmarking/references/tools.md +62 -57
- package/skills/hr-better-accessibility/SKILL.md +1 -1
- package/skills/hr-better-accessibility/screen-readers.md +1 -0
- package/skills/hr-code-review/SKILL.md +19 -3
- package/skills/hr-code-review/agents/openai.yaml +1 -1
- package/skills/hr-code-review/references/acp-client.md +43 -0
- package/skills/{hr-change-impact/SKILL.md → hr-code-review/references/impact.md} +2 -12
- package/skills/hr-code-review/references/panel-adjudication.md +58 -0
- package/skills/hr-code-review/references/panel-plan.md +61 -0
- package/skills/hr-code-review/references/panel-recommendations.json +105 -0
- package/skills/hr-code-review/references/panel-recommendations.md +46 -0
- package/skills/hr-code-review/references/panel-setup.md +54 -0
- package/skills/hr-code-review/references/reviewer-role.md +31 -0
- package/skills/hr-code-review/scripts/acp-panel-client.mjs +299 -0
- package/skills/hr-code-review/scripts/check-receipts.mjs +263 -0
- package/skills/hr-code-review/scripts/panel-plan.mjs +69 -0
- package/skills/hr-code-review/scripts/panel-preferences.mjs +220 -0
- package/skills/hr-design-exploration/SKILL.md +24 -1
- package/skills/hr-design-exploration/agents/openai.yaml +2 -2
- package/skills/hr-diagnosing-bugs/SKILL.md +2 -2
- package/skills/hr-diagnosing-bugs/references/reporting.md +29 -4
- package/skills/hr-diagnosing-bugs/scripts/check-pr-offer.mjs +47 -0
- package/skills/hr-explain-code/SKILL.md +1 -1
- package/skills/hr-extract-shared-steps/SKILL.md +1 -1
- package/skills/hr-forgotten-work-audit/SKILL.md +130 -0
- package/skills/hr-forgotten-work-audit/references/brief-template.md +56 -0
- package/skills/hr-forgotten-work-audit/references/result-format.md +46 -0
- package/skills/hr-handoff/LICENSE +21 -0
- package/skills/hr-handoff/NOTICE.md +29 -0
- package/skills/hr-handoff/SKILL.md +24 -1
- package/skills/hr-handoff/references/reconciliation.md +25 -6
- package/skills/hr-handoff/references/successor-thread.md +76 -0
- package/skills/hr-handoff/scripts/check-reconciliation.mjs +48 -6
- package/skills/hr-house-rules-setup/SKILL.md +22 -8
- package/skills/hr-lean-plan/SKILL.md +4 -0
- package/skills/hr-maintainability-review/SKILL.md +14 -4
- package/skills/hr-maintainability-review/agents/openai.yaml +1 -1
- package/skills/hr-manual-qa/SKILL.md +1 -1
- package/skills/hr-ordering-tests/SKILL.md +1 -1
- package/skills/hr-plain-prose/SKILL.md +7 -4
- package/skills/hr-pr-followup/SKILL.md +6 -3
- package/skills/hr-pr-followup/references/review-helpers.md +40 -7
- package/skills/hr-pr-followup/scripts/check-landing.py +204 -0
- package/skills/hr-pr-followup/scripts/check-pr-review.ts +19 -8
- package/skills/hr-pr-followup/scripts/merge-pr.sh +100 -0
- package/skills/hr-pr-followup/scripts/verify-landed.sh +184 -0
- package/skills/hr-read-reddit/SKILL.md +1 -1
- package/skills/hr-research-shared-links/SKILL.md +103 -0
- package/skills/hr-sqlite/SKILL.md +174 -0
- package/skills/hr-sqlite/agents/openai.yaml +2 -0
- package/skills/hr-sqlite/references/evidence.md +94 -0
- package/skills/hr-sqlite/references/tool-decisions.md +39 -0
- package/skills/hr-sqlite/scripts/inspect-sqlite.py +245 -0
- package/skills/hr-stock-ui-audit/SKILL.md +1 -1
- package/skills/hr-test-audit/LICENSE +21 -0
- package/skills/hr-test-audit/NOTICE.md +67 -0
- package/skills/hr-usage-report/SKILL.md +90 -0
- package/skills/hr-usage-report/agents/openai.yaml +2 -0
- package/skills/hr-usage-report/assets/example-report.json +125 -0
- package/skills/hr-usage-report/assets/report-template.html +377 -0
- package/skills/hr-usage-report/scripts/render-report.ts +86 -0
- package/skills/hr-usage-report/scripts/usage-by-model.ts +499 -0
- package/skills/hr-what-next/SKILL.md +100 -10
- package/skills/hr-what-next/references/reporting.md +13 -22
- package/skills/hr-what-next/scripts/check-open-work.mjs +711 -0
- package/skills/hr-writing-pr/SKILL.md +29 -14
- package/skills/hr-writing-pr/scripts/benefit-chart.py +200 -0
- package/docs/npm-release.md +0 -45
- package/rules/modifiers/swarmail.md +0 -12
- package/skills/hr-benchmarking/references/priority-findings-20261006.md +0 -115
- 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
|
+
}
|