pitroom 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.codex-plugin/plugin.json +29 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +471 -0
- package/dist/lib.mjs +1222 -0
- package/dist/pitroom.mjs +3685 -0
- package/docs/backends.md +113 -0
- package/hooks/hooks.json +28 -0
- package/hooks/session-start.mjs +42 -0
- package/package.json +60 -0
- package/skills/pitroom-brainstorming/SKILL.md +243 -0
- package/skills/pitroom-brainstorming/spec-document-reviewer-prompt.md +22 -0
- package/skills/pitroom-crew/SKILL.md +80 -0
- package/skills/pitroom-debugging/SKILL.md +287 -0
- package/skills/pitroom-debugging/condition-based-waiting-example.ts +170 -0
- package/skills/pitroom-debugging/condition-based-waiting.md +115 -0
- package/skills/pitroom-debugging/defense-in-depth.md +122 -0
- package/skills/pitroom-debugging/find-polluter.sh +72 -0
- package/skills/pitroom-debugging/root-cause-tracing.md +169 -0
- package/skills/pitroom-driven-development/SKILL.md +138 -0
- package/skills/pitroom-driven-development/implementer-prompt.md +68 -0
- package/skills/pitroom-driven-development/re-review-prompt.md +41 -0
- package/skills/pitroom-driven-development/task-reviewer-prompt.md +59 -0
- package/skills/pitroom-finishing/SKILL.md +226 -0
- package/skills/pitroom-implement/SKILL.md +42 -0
- package/skills/pitroom-receiving-review/SKILL.md +209 -0
- package/skills/pitroom-research/SKILL.md +35 -0
- package/skills/pitroom-review/SKILL.md +47 -0
- package/skills/pitroom-review/code-reviewer.md +38 -0
- package/skills/pitroom-tdd/SKILL.md +322 -0
- package/skills/pitroom-tdd/writing-good-tests.md +198 -0
- package/skills/pitroom-verification/SKILL.md +124 -0
- package/skills/pitroom-worktrees/SKILL.md +171 -0
- package/skills/pitroom-writing-plans/SKILL.md +183 -0
- package/skills/pitroom-writing-plans/plan-document-reviewer-prompt.md +23 -0
- package/skills/using-pitroom/SKILL.md +66 -0
package/dist/lib.mjs
ADDED
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
// src/core/errors.ts
|
|
2
|
+
var UserError = class extends Error {
|
|
3
|
+
constructor(message, code = 2) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// src/backends/claude/index.ts
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import fs2 from "node:fs";
|
|
12
|
+
import os2 from "node:os";
|
|
13
|
+
import path2 from "node:path";
|
|
14
|
+
|
|
15
|
+
// src/backends/exec.ts
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
function findBinary(name, envVar, known = []) {
|
|
20
|
+
const override = process.env[envVar];
|
|
21
|
+
if (override) return override;
|
|
22
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
|
|
23
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
24
|
+
for (const ext of exts) {
|
|
25
|
+
const p = path.join(dir, `${name}${ext}`);
|
|
26
|
+
if (isFile(p)) return p;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return known.map((k) => k.replace(/^~/, os.homedir())).find(isFile) ?? name;
|
|
30
|
+
}
|
|
31
|
+
function isFile(p) {
|
|
32
|
+
try {
|
|
33
|
+
return fs.statSync(p).isFile();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function resolveCommand(bin) {
|
|
39
|
+
try {
|
|
40
|
+
const real = fs.realpathSync(bin);
|
|
41
|
+
const fd = fs.openSync(real, "r");
|
|
42
|
+
const head = Buffer.alloc(128);
|
|
43
|
+
const n = fs.readSync(fd, head, 0, 128, 0);
|
|
44
|
+
fs.closeSync(fd);
|
|
45
|
+
const firstLine = head.subarray(0, n).toString("utf8").split("\n")[0] ?? "";
|
|
46
|
+
if (/^#!.*\bnode\b/.test(firstLine)) return { command: process.execPath, prefix: [real] };
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
return { command: bin, prefix: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/backends/claude/events.ts
|
|
53
|
+
var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
54
|
+
var DENIED = /permission|not allowed|denied|blocked|disallowed/i;
|
|
55
|
+
function parseEvents(jsonl) {
|
|
56
|
+
const usage = {
|
|
57
|
+
input: 0,
|
|
58
|
+
output: 0,
|
|
59
|
+
reasoning: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
total: 0,
|
|
63
|
+
steps: 0,
|
|
64
|
+
toolCalls: 0,
|
|
65
|
+
denied: 0
|
|
66
|
+
};
|
|
67
|
+
const tools = {};
|
|
68
|
+
const edits = [];
|
|
69
|
+
const pendingEdits = /* @__PURE__ */ new Map();
|
|
70
|
+
const steps = /* @__PURE__ */ new Set();
|
|
71
|
+
let sessionId;
|
|
72
|
+
let model;
|
|
73
|
+
let lastText = "";
|
|
74
|
+
let finalText;
|
|
75
|
+
let lastActivity;
|
|
76
|
+
let error;
|
|
77
|
+
for (const line of jsonl.split("\n")) {
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
let e;
|
|
80
|
+
try {
|
|
81
|
+
e = JSON.parse(line);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
sessionId ??= e.session_id;
|
|
86
|
+
if (e.type === "system" && e.subtype === "init") {
|
|
87
|
+
model = e.model;
|
|
88
|
+
} else if (e.type === "assistant") {
|
|
89
|
+
const content = e.message?.content ?? [];
|
|
90
|
+
if (e.is_api_error_message || e.error) {
|
|
91
|
+
error = content.map((c) => c.text).filter(Boolean).join(" ") || String(e.error);
|
|
92
|
+
if (e.error) error = `${error} [${e.error}]`;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
steps.add(String(e.message?.id ?? e.uuid ?? steps.size));
|
|
96
|
+
for (const c of content) {
|
|
97
|
+
if (c.type === "text" && String(c.text ?? "").trim()) {
|
|
98
|
+
lastText = String(c.text).trim();
|
|
99
|
+
lastActivity = `says: ${oneLine(lastText)}`;
|
|
100
|
+
} else if (c.type === "tool_use") {
|
|
101
|
+
usage.toolCalls++;
|
|
102
|
+
tools[c.name] = (tools[c.name] ?? 0) + 1;
|
|
103
|
+
const target = c.input?.file_path ?? c.input?.path ?? c.input?.pattern ?? c.input?.command ?? c.input?.url;
|
|
104
|
+
lastActivity = `${c.name} ${target ? oneLine(String(target), 60) : ""}`.trim();
|
|
105
|
+
if (EDIT_TOOLS.has(c.name) && c.input?.file_path) pendingEdits.set(c.id, String(c.input.file_path));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else if (e.type === "user") {
|
|
109
|
+
for (const c of e.message?.content ?? []) {
|
|
110
|
+
if (c?.type !== "tool_result") continue;
|
|
111
|
+
const text = typeof c.content === "string" ? c.content : JSON.stringify(c.content ?? "");
|
|
112
|
+
if (c.is_error) {
|
|
113
|
+
if (DENIED.test(text)) usage.denied++;
|
|
114
|
+
} else if (pendingEdits.has(c.tool_use_id)) {
|
|
115
|
+
edits.push(pendingEdits.get(c.tool_use_id));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (e.type === "result") {
|
|
119
|
+
const u = e.usage ?? {};
|
|
120
|
+
usage.input += num(u.input_tokens);
|
|
121
|
+
usage.cacheRead += num(u.cache_read_input_tokens);
|
|
122
|
+
usage.cacheWrite += num(u.cache_creation_input_tokens);
|
|
123
|
+
usage.reasoning += num(u.output_tokens_details?.thinking_tokens);
|
|
124
|
+
usage.output += Math.max(0, num(u.output_tokens) - num(u.output_tokens_details?.thinking_tokens));
|
|
125
|
+
usage.total += num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) + num(u.output_tokens);
|
|
126
|
+
usage.cost = (usage.cost ?? 0) + num(e.total_cost_usd);
|
|
127
|
+
usage.denied = Math.max(usage.denied, Array.isArray(e.permission_denials) ? e.permission_denials.length : 0);
|
|
128
|
+
if (e.is_error) {
|
|
129
|
+
const tags = [e.terminal_reason, e.api_error_status && `HTTP ${e.api_error_status}`].filter(Boolean);
|
|
130
|
+
error ??= `${String(e.result ?? e.subtype ?? "Claude Code error")}${tags.length ? ` [${tags.join(", ")}]` : ""}`;
|
|
131
|
+
} else if (typeof e.result === "string") {
|
|
132
|
+
finalText = e.result.trim();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
usage.steps = steps.size;
|
|
137
|
+
return { sessionId, model, finalText: finalText ?? (error ? "" : lastText), usage, tools, edits, lastActivity, error };
|
|
138
|
+
}
|
|
139
|
+
function num(v) {
|
|
140
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
141
|
+
}
|
|
142
|
+
function oneLine(s, max = 80) {
|
|
143
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
144
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/backends/claude/index.ts
|
|
148
|
+
var binary = () => findBinary("claude", "PITROOM_CLAUDE_BIN", ["~/.local/bin/claude", "~/.claude/local/claude"]);
|
|
149
|
+
function cl(args, timeout = 6e4) {
|
|
150
|
+
const { command, prefix } = resolveCommand(binary());
|
|
151
|
+
const r = spawnSync(command, [...prefix, ...args], {
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
timeout,
|
|
154
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
155
|
+
maxBuffer: 16 * 1024 * 1024
|
|
156
|
+
});
|
|
157
|
+
return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
|
|
158
|
+
}
|
|
159
|
+
var READ_TOOLS = ["Read", "Grep", "Glob"];
|
|
160
|
+
var WRITE_TOOLS = [...READ_TOOLS, "Edit", "Write", "Bash"];
|
|
161
|
+
var WEB_TOOLS = ["WebFetch", "WebSearch"];
|
|
162
|
+
var DENY_BASH = [
|
|
163
|
+
"git commit",
|
|
164
|
+
"git push",
|
|
165
|
+
"git pull",
|
|
166
|
+
"git reset",
|
|
167
|
+
"git checkout",
|
|
168
|
+
"git restore",
|
|
169
|
+
"git clean",
|
|
170
|
+
"git stash",
|
|
171
|
+
"git rebase",
|
|
172
|
+
"git merge",
|
|
173
|
+
"git switch",
|
|
174
|
+
"git branch",
|
|
175
|
+
"git tag",
|
|
176
|
+
"git rm",
|
|
177
|
+
"git worktree",
|
|
178
|
+
"git update-ref",
|
|
179
|
+
"git config",
|
|
180
|
+
"git remote",
|
|
181
|
+
"git cherry-pick",
|
|
182
|
+
"git revert",
|
|
183
|
+
"git am",
|
|
184
|
+
"git add",
|
|
185
|
+
"rm -rf",
|
|
186
|
+
"rm -fr",
|
|
187
|
+
"sudo",
|
|
188
|
+
"chown",
|
|
189
|
+
"kill",
|
|
190
|
+
"pkill",
|
|
191
|
+
"killall",
|
|
192
|
+
"gh",
|
|
193
|
+
"npm publish",
|
|
194
|
+
"pitroom",
|
|
195
|
+
"opencode",
|
|
196
|
+
"claude",
|
|
197
|
+
"codex"
|
|
198
|
+
].map((c) => `Bash(${c}:*)`);
|
|
199
|
+
var SECRETS = ["**/.env", "**/.env.*", "**/*.pem", "**/id_rsa*", "**/id_ed25519*"].map((p) => `Read(${p})`);
|
|
200
|
+
function toolsFor(mode, web2) {
|
|
201
|
+
return [...mode === "read" ? READ_TOOLS : WRITE_TOOLS, ...web2 ? WEB_TOOLS : []];
|
|
202
|
+
}
|
|
203
|
+
function invocation(req) {
|
|
204
|
+
const tools = toolsFor(req.mode, req.web).join(",");
|
|
205
|
+
const args = [
|
|
206
|
+
"-p",
|
|
207
|
+
"--safe-mode",
|
|
208
|
+
"--restricted",
|
|
209
|
+
"--strict-mcp-config",
|
|
210
|
+
"--tools",
|
|
211
|
+
tools,
|
|
212
|
+
"--allowedTools",
|
|
213
|
+
tools
|
|
214
|
+
];
|
|
215
|
+
if (req.mode !== "read") args.push("--disallowedTools", ...DENY_BASH);
|
|
216
|
+
args.push(
|
|
217
|
+
"--settings",
|
|
218
|
+
JSON.stringify({ permissions: { deny: SECRETS } }),
|
|
219
|
+
// Non-variadic options last, so no list above can swallow what follows.
|
|
220
|
+
"--permission-mode",
|
|
221
|
+
"dontAsk",
|
|
222
|
+
"--output-format",
|
|
223
|
+
"stream-json",
|
|
224
|
+
"--verbose"
|
|
225
|
+
);
|
|
226
|
+
if (req.model) args.push("--model", req.model);
|
|
227
|
+
if (req.sessionId) args.push("--resume", req.sessionId);
|
|
228
|
+
args.push("--", req.prompt);
|
|
229
|
+
const { command, prefix } = resolveCommand(binary());
|
|
230
|
+
return { command, args: [...prefix, ...args], env: { DISABLE_AUTOUPDATER: "1" } };
|
|
231
|
+
}
|
|
232
|
+
function classify(message) {
|
|
233
|
+
if (/authentication_failed|failed to authenticate|oauth|not logged in|\/login|invalid api key|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
|
|
234
|
+
return "auth";
|
|
235
|
+
}
|
|
236
|
+
if (/rate.?limit|too many requests|\b429\b|\b529\b|overloaded|usage limit|limit reached|quota|capacity/i.test(message)) return "rate-limited";
|
|
237
|
+
if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
|
|
238
|
+
return "other";
|
|
239
|
+
}
|
|
240
|
+
function failure(run, stderr, exitCode) {
|
|
241
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
242
|
+
const detail = stderr.trim().split("\n").filter(Boolean).pop();
|
|
243
|
+
const message = run.error ?? detail ?? `claude exited with code ${exitCode}`;
|
|
244
|
+
return { kind: classify(message), message };
|
|
245
|
+
}
|
|
246
|
+
function defaultModel() {
|
|
247
|
+
try {
|
|
248
|
+
const settings = JSON.parse(fs2.readFileSync(path2.join(os2.homedir(), ".claude", "settings.json"), "utf8"));
|
|
249
|
+
return typeof settings.model === "string" ? settings.model : void 0;
|
|
250
|
+
} catch {
|
|
251
|
+
return void 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function doctor({ models, hasFallback }) {
|
|
255
|
+
const version = cl(["--version"]);
|
|
256
|
+
if (version.missing || !version.ok) {
|
|
257
|
+
return [{ level: "fail", message: `claude not runnable (${binary()}). Install Claude Code, or set PITROOM_CLAUDE_BIN` }];
|
|
258
|
+
}
|
|
259
|
+
const checks = [{ level: "ok", message: `Claude Code ${version.out.trim()} at ${binary()}` }];
|
|
260
|
+
let loggedIn = false;
|
|
261
|
+
try {
|
|
262
|
+
loggedIn = JSON.parse(cl(["auth", "status"]).out).loggedIn === true;
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
checks.push(
|
|
266
|
+
loggedIn || process.env.ANTHROPIC_API_KEY ? { level: "ok", message: `Claude Code: ${loggedIn ? "logged in" : "ANTHROPIC_API_KEY set"}` } : { level: "fail", message: "Claude Code is not logged in (or the session expired): run `claude auth login`" }
|
|
267
|
+
);
|
|
268
|
+
for (const m of models) {
|
|
269
|
+
const model = m ?? defaultModel();
|
|
270
|
+
const pricey = model && /opus|fable/i.test(model);
|
|
271
|
+
checks.push({
|
|
272
|
+
level: pricey ? "warn" : "ok",
|
|
273
|
+
message: pricey ? `Claude Code model: ${model} is the most expensive tier for a worker; consider -W claude:haiku or claude:sonnet` : model ? `Claude Code model: ${model}` : "Claude Code model: Claude Code's default (often the largest model; a cheaper worker is -W claude:haiku)"
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Claude Code runs" });
|
|
277
|
+
return checks;
|
|
278
|
+
}
|
|
279
|
+
var claude = {
|
|
280
|
+
id: "claude",
|
|
281
|
+
name: "Claude Code",
|
|
282
|
+
capabilities: { readOnly: "tool-allowlist", resume: "by-id", reportsCost: true, attachFiles: false },
|
|
283
|
+
binary,
|
|
284
|
+
invocation,
|
|
285
|
+
parse: parseEvents,
|
|
286
|
+
failure,
|
|
287
|
+
defaultModel,
|
|
288
|
+
doctor
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/backends/codex/index.ts
|
|
292
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
293
|
+
import fs3 from "node:fs";
|
|
294
|
+
import os3 from "node:os";
|
|
295
|
+
import path3 from "node:path";
|
|
296
|
+
|
|
297
|
+
// src/backends/codex/events.ts
|
|
298
|
+
var WORK = /* @__PURE__ */ new Set(["agent_message", "reasoning", "command_execution", "file_change", "mcp_tool_call", "web_search", "todo_list"]);
|
|
299
|
+
var TOOLS = /* @__PURE__ */ new Set(["command_execution", "file_change", "mcp_tool_call", "web_search"]);
|
|
300
|
+
function parseEvents2(jsonl) {
|
|
301
|
+
const usage = {
|
|
302
|
+
input: 0,
|
|
303
|
+
output: 0,
|
|
304
|
+
reasoning: 0,
|
|
305
|
+
cacheRead: 0,
|
|
306
|
+
cacheWrite: 0,
|
|
307
|
+
total: 0,
|
|
308
|
+
steps: 0,
|
|
309
|
+
toolCalls: 0,
|
|
310
|
+
denied: 0
|
|
311
|
+
};
|
|
312
|
+
const tools = {};
|
|
313
|
+
const edits = [];
|
|
314
|
+
let sessionId;
|
|
315
|
+
let finalText = "";
|
|
316
|
+
let lastActivity;
|
|
317
|
+
let error;
|
|
318
|
+
for (const line of jsonl.split("\n")) {
|
|
319
|
+
if (!line.trim()) continue;
|
|
320
|
+
let e;
|
|
321
|
+
try {
|
|
322
|
+
e = JSON.parse(line);
|
|
323
|
+
} catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
switch (e.type) {
|
|
327
|
+
case "thread.started":
|
|
328
|
+
sessionId ??= e.thread_id;
|
|
329
|
+
break;
|
|
330
|
+
case "item.completed": {
|
|
331
|
+
const it = e.item ?? {};
|
|
332
|
+
if (!WORK.has(it.type)) break;
|
|
333
|
+
usage.steps++;
|
|
334
|
+
if (TOOLS.has(it.type)) {
|
|
335
|
+
usage.toolCalls++;
|
|
336
|
+
tools[it.type] = (tools[it.type] ?? 0) + 1;
|
|
337
|
+
}
|
|
338
|
+
if (it.type === "agent_message" && String(it.text ?? "").trim()) {
|
|
339
|
+
finalText = String(it.text).trim();
|
|
340
|
+
lastActivity = `says: ${oneLine2(finalText)}`;
|
|
341
|
+
} else if (it.type === "command_execution") {
|
|
342
|
+
if (it.status === "declined" || /operation not permitted|sandbox/i.test(String(it.aggregated_output ?? ""))) usage.denied++;
|
|
343
|
+
lastActivity = `shell ${oneLine2(String(it.command ?? ""), 60)}`;
|
|
344
|
+
} else if (it.type === "file_change") {
|
|
345
|
+
const paths = (it.changes ?? []).map((c) => String(c.path));
|
|
346
|
+
if (it.status !== "failed" && it.status !== "declined") edits.push(...paths);
|
|
347
|
+
else usage.denied++;
|
|
348
|
+
lastActivity = `edit ${oneLine2(paths.join(", "), 60)}`;
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case "turn.completed": {
|
|
353
|
+
const u = e.usage ?? {};
|
|
354
|
+
const cached = num2(u.cached_input_tokens);
|
|
355
|
+
const reasoning = num2(u.reasoning_output_tokens);
|
|
356
|
+
usage.input += Math.max(0, num2(u.input_tokens) - cached);
|
|
357
|
+
usage.cacheRead += cached;
|
|
358
|
+
usage.cacheWrite += num2(u.cache_write_input_tokens);
|
|
359
|
+
usage.output += Math.max(0, num2(u.output_tokens) - reasoning);
|
|
360
|
+
usage.reasoning += reasoning;
|
|
361
|
+
usage.total += num2(u.input_tokens) + num2(u.output_tokens);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "error":
|
|
365
|
+
error = describe(e.message);
|
|
366
|
+
break;
|
|
367
|
+
case "turn.failed":
|
|
368
|
+
error = describe(e.error?.message ?? e.message);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
373
|
+
}
|
|
374
|
+
function describe(raw) {
|
|
375
|
+
const s = String(raw ?? "unknown Codex error");
|
|
376
|
+
try {
|
|
377
|
+
const j = JSON.parse(s);
|
|
378
|
+
const inner = j.error ?? j;
|
|
379
|
+
const tags = [inner.type, j.status && `HTTP ${j.status}`].filter(Boolean);
|
|
380
|
+
return `${inner.message ?? s}${tags.length ? ` [${tags.join(", ")}]` : ""}`;
|
|
381
|
+
} catch {
|
|
382
|
+
return s;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function num2(v) {
|
|
386
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
387
|
+
}
|
|
388
|
+
function oneLine2(s, max = 80) {
|
|
389
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
390
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/backends/codex/index.ts
|
|
394
|
+
var binary2 = () => findBinary("codex", "PITROOM_CODEX_BIN");
|
|
395
|
+
var codexHome = () => process.env.CODEX_HOME ?? path3.join(os3.homedir(), ".codex");
|
|
396
|
+
function cx(args, timeout = 6e4) {
|
|
397
|
+
const { command, prefix } = resolveCommand(binary2());
|
|
398
|
+
const r = spawnSync2(command, [...prefix, ...args], {
|
|
399
|
+
encoding: "utf8",
|
|
400
|
+
timeout,
|
|
401
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
402
|
+
maxBuffer: 16 * 1024 * 1024
|
|
403
|
+
});
|
|
404
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`, missing: !!r.error };
|
|
405
|
+
}
|
|
406
|
+
var SANDBOX = { read: "read-only", write: "workspace-write", isolate: "workspace-write" };
|
|
407
|
+
var toml = (s) => JSON.stringify(s);
|
|
408
|
+
function splitModel(model) {
|
|
409
|
+
if (!model) return {};
|
|
410
|
+
const [m, effort] = model.split("#");
|
|
411
|
+
return { model: m || void 0, effort: effort || void 0 };
|
|
412
|
+
}
|
|
413
|
+
function invocation2(req) {
|
|
414
|
+
const { model, effort } = splitModel(req.model);
|
|
415
|
+
const args = ["exec"];
|
|
416
|
+
if (req.sessionId) args.push("resume", req.sessionId);
|
|
417
|
+
args.push(
|
|
418
|
+
"--json",
|
|
419
|
+
"--skip-git-repo-check",
|
|
420
|
+
"--ignore-user-config",
|
|
421
|
+
"-c",
|
|
422
|
+
`sandbox_mode=${toml(SANDBOX[req.mode])}`,
|
|
423
|
+
"-c",
|
|
424
|
+
'approval_policy="never"'
|
|
425
|
+
);
|
|
426
|
+
if (req.web) args.push("-c", 'web_search="live"');
|
|
427
|
+
if (model) args.push("--model", model);
|
|
428
|
+
if (effort) args.push("-c", `model_reasoning_effort=${toml(effort)}`);
|
|
429
|
+
args.push(req.prompt);
|
|
430
|
+
const { command, prefix } = resolveCommand(binary2());
|
|
431
|
+
return { command, args: [...prefix, ...args], env: {} };
|
|
432
|
+
}
|
|
433
|
+
function classify2(message) {
|
|
434
|
+
if (/not supported|model[^.]*(not found|does not exist|unknown|unavailable)|unsupported model|no such model/i.test(message)) {
|
|
435
|
+
return "model-unavailable";
|
|
436
|
+
}
|
|
437
|
+
if (/rate.?limit|too many requests|\b429\b|usage limit|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
|
|
438
|
+
if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
|
|
439
|
+
return "other";
|
|
440
|
+
}
|
|
441
|
+
function failure2(run, stderr, exitCode) {
|
|
442
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
443
|
+
const detail = stderr.split("\n").find((l) => /^Error[: ]|ERROR/.test(l) && !/rmcp|models cache/.test(l));
|
|
444
|
+
let message = run.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
|
|
445
|
+
if (/not supported when using Codex/i.test(message)) message += " (an outdated Codex CLI says this too: npm i -g @openai/codex@latest)";
|
|
446
|
+
return { kind: classify2(message), message };
|
|
447
|
+
}
|
|
448
|
+
function resolveModel(sessionId) {
|
|
449
|
+
const root = path3.join(codexHome(), "sessions");
|
|
450
|
+
const day = (d) => path3.join(root, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0"));
|
|
451
|
+
const now = /* @__PURE__ */ new Date();
|
|
452
|
+
for (const dir of [day(now), day(new Date(now.getTime() - 864e5))]) {
|
|
453
|
+
let files = [];
|
|
454
|
+
try {
|
|
455
|
+
files = fs3.readdirSync(dir).filter((f) => f.includes(sessionId));
|
|
456
|
+
} catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
for (const f of files) {
|
|
460
|
+
const m = /"model":"([^"]+)"/.exec(fs3.readFileSync(path3.join(dir, f), "utf8"));
|
|
461
|
+
if (m) return m[1];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return void 0;
|
|
465
|
+
}
|
|
466
|
+
function doctor2({ models, hasFallback }) {
|
|
467
|
+
const version = cx(["--version"]);
|
|
468
|
+
if (version.missing || !version.ok) {
|
|
469
|
+
return [{ level: "fail", message: `codex not runnable (${binary2()}). Install: npm i -g @openai/codex, or set PITROOM_CODEX_BIN` }];
|
|
470
|
+
}
|
|
471
|
+
const checks = [{ level: "ok", message: `${version.out.trim()} at ${binary2()}` }];
|
|
472
|
+
const login = cx(["login", "status"]);
|
|
473
|
+
checks.push(
|
|
474
|
+
login.ok && /logged in/i.test(login.out) ? { level: "ok", message: `Codex: ${login.out.trim().split("\n")[0]}` } : { level: "fail", message: "Codex is not logged in: run `codex login`" }
|
|
475
|
+
);
|
|
476
|
+
for (const m of models) {
|
|
477
|
+
checks.push({
|
|
478
|
+
level: "ok",
|
|
479
|
+
message: m ? `Codex model: ${m} (checked on first use; unsupported models fail over)` : "Codex model: Codex's built-in default (your ~/.codex/config.toml is ignored for workers; pick one with -W codex:<model>)"
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Codex runs" });
|
|
483
|
+
return checks;
|
|
484
|
+
}
|
|
485
|
+
var codex = {
|
|
486
|
+
id: "codex",
|
|
487
|
+
name: "Codex",
|
|
488
|
+
capabilities: { readOnly: "os-sandbox", resume: "by-id", reportsCost: false, attachFiles: false },
|
|
489
|
+
binary: binary2,
|
|
490
|
+
invocation: invocation2,
|
|
491
|
+
parse: parseEvents2,
|
|
492
|
+
failure: failure2,
|
|
493
|
+
resolveModel,
|
|
494
|
+
doctor: doctor2
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// src/backends/opencode/index.ts
|
|
498
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
499
|
+
|
|
500
|
+
// src/backends/opencode/events.ts
|
|
501
|
+
var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "multiedit", "apply_patch"]);
|
|
502
|
+
var DENIED2 = /rule which prevents you|permission denied|permission\.rejected/i;
|
|
503
|
+
function parseEvents3(ndjson) {
|
|
504
|
+
const usage = {
|
|
505
|
+
input: 0,
|
|
506
|
+
output: 0,
|
|
507
|
+
reasoning: 0,
|
|
508
|
+
cacheRead: 0,
|
|
509
|
+
cacheWrite: 0,
|
|
510
|
+
total: 0,
|
|
511
|
+
cost: 0,
|
|
512
|
+
steps: 0,
|
|
513
|
+
toolCalls: 0,
|
|
514
|
+
denied: 0
|
|
515
|
+
};
|
|
516
|
+
const tools = {};
|
|
517
|
+
const edits = [];
|
|
518
|
+
const textByMessage = /* @__PURE__ */ new Map();
|
|
519
|
+
let sessionId;
|
|
520
|
+
let lastActivity;
|
|
521
|
+
let error;
|
|
522
|
+
for (const line of ndjson.split("\n")) {
|
|
523
|
+
if (!line.trim()) continue;
|
|
524
|
+
let e;
|
|
525
|
+
try {
|
|
526
|
+
e = JSON.parse(line);
|
|
527
|
+
} catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
sessionId ??= e.sessionID ?? e.part?.sessionID;
|
|
531
|
+
const p = e.part ?? {};
|
|
532
|
+
switch (e.type) {
|
|
533
|
+
case "text": {
|
|
534
|
+
const text = String(p.text ?? "");
|
|
535
|
+
if (!text.trim()) break;
|
|
536
|
+
const key = String(p.messageID ?? "");
|
|
537
|
+
if (!textByMessage.has(key)) textByMessage.set(key, []);
|
|
538
|
+
textByMessage.get(key).push(text);
|
|
539
|
+
lastActivity = `says: ${oneLine3(text)}`;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
case "tool_use": {
|
|
543
|
+
const name = String(p.tool ?? "tool");
|
|
544
|
+
const st = p.state ?? {};
|
|
545
|
+
usage.toolCalls++;
|
|
546
|
+
tools[name] = (tools[name] ?? 0) + 1;
|
|
547
|
+
if (st.status === "error" && DENIED2.test(String(st.error ?? ""))) usage.denied++;
|
|
548
|
+
if (st.status === "completed" && EDIT_TOOLS2.has(name)) {
|
|
549
|
+
edits.push(String(st.input?.filePath ?? st.input?.path ?? name));
|
|
550
|
+
}
|
|
551
|
+
lastActivity = `${name} ${describe2(st.input)}`.trim();
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
case "step_finish": {
|
|
555
|
+
const t = p.tokens ?? {};
|
|
556
|
+
usage.steps++;
|
|
557
|
+
usage.input += num3(t.input);
|
|
558
|
+
usage.output += num3(t.output);
|
|
559
|
+
usage.reasoning += num3(t.reasoning);
|
|
560
|
+
usage.cacheRead += num3(t.cache?.read);
|
|
561
|
+
usage.cacheWrite += num3(t.cache?.write);
|
|
562
|
+
usage.total += num3(t.total) || num3(t.input) + num3(t.output) + num3(t.reasoning) + num3(t.cache?.read);
|
|
563
|
+
usage.cost = (usage.cost ?? 0) + num3(p.cost);
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
case "error": {
|
|
567
|
+
error = describeError(e.error);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const groups = [...textByMessage.values()];
|
|
573
|
+
const finalText = (groups[groups.length - 1] ?? []).join("\n").trim();
|
|
574
|
+
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
575
|
+
}
|
|
576
|
+
function describeError(err) {
|
|
577
|
+
if (!err || typeof err !== "object") return "unknown OpenCode error";
|
|
578
|
+
const message = String(err.data?.message ?? err.message ?? err.name ?? "unknown OpenCode error");
|
|
579
|
+
let inner;
|
|
580
|
+
try {
|
|
581
|
+
inner = JSON.parse(err.response?.body ?? "{}")?.error?.type;
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
const tags = [err.type, inner, err.status && `HTTP ${err.status}`].filter(Boolean);
|
|
585
|
+
return tags.length ? `${message} [${tags.join(", ")}]` : message;
|
|
586
|
+
}
|
|
587
|
+
function num3(v) {
|
|
588
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
589
|
+
}
|
|
590
|
+
function oneLine3(s, max = 80) {
|
|
591
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
592
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
593
|
+
}
|
|
594
|
+
function describe2(input) {
|
|
595
|
+
if (!input || typeof input !== "object") return "";
|
|
596
|
+
const v = input.filePath ?? input.path ?? input.pattern ?? input.command ?? input.url ?? input.query;
|
|
597
|
+
return v ? oneLine3(String(v), 60) : "";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/backends/opencode/profiles.ts
|
|
601
|
+
import fs4 from "node:fs";
|
|
602
|
+
import os4 from "node:os";
|
|
603
|
+
import path4 from "node:path";
|
|
604
|
+
var AGENT = { read: "pitroom-read", write: "pitroom-write" };
|
|
605
|
+
var READ_FILES = {
|
|
606
|
+
"*": "allow",
|
|
607
|
+
// Keep secrets away from third-party / free model providers.
|
|
608
|
+
"*.env": "deny",
|
|
609
|
+
"*.env.*": "deny",
|
|
610
|
+
"*.env.example": "allow",
|
|
611
|
+
"*.env.sample": "allow",
|
|
612
|
+
"*.pem": "deny",
|
|
613
|
+
"*id_rsa*": "deny",
|
|
614
|
+
"*id_ed25519*": "deny"
|
|
615
|
+
};
|
|
616
|
+
var READ_ONLY_BASH = {
|
|
617
|
+
"*": "deny",
|
|
618
|
+
"git status*": "allow",
|
|
619
|
+
"git diff*": "allow",
|
|
620
|
+
"git log*": "allow",
|
|
621
|
+
"git show*": "allow",
|
|
622
|
+
"git grep*": "allow",
|
|
623
|
+
"git blame*": "allow",
|
|
624
|
+
"git ls-files*": "allow",
|
|
625
|
+
"ls*": "allow",
|
|
626
|
+
pwd: "allow",
|
|
627
|
+
"rg *": "allow",
|
|
628
|
+
"wc *": "allow",
|
|
629
|
+
// Flags that would let "read" commands execute or write.
|
|
630
|
+
"rg *--pre*": "deny",
|
|
631
|
+
"git *--output*": "deny",
|
|
632
|
+
"git *--ext-diff*": "deny"
|
|
633
|
+
};
|
|
634
|
+
var FORBIDDEN_BASH = [
|
|
635
|
+
// history, branches and anything that can discard uncommitted work
|
|
636
|
+
"git commit*",
|
|
637
|
+
"git push*",
|
|
638
|
+
"git reset*",
|
|
639
|
+
"git checkout*",
|
|
640
|
+
"git restore*",
|
|
641
|
+
"git clean*",
|
|
642
|
+
"git stash*",
|
|
643
|
+
"git rebase*",
|
|
644
|
+
"git merge*",
|
|
645
|
+
"git switch*",
|
|
646
|
+
"git branch*",
|
|
647
|
+
"git tag*",
|
|
648
|
+
"git rm*",
|
|
649
|
+
"git worktree*",
|
|
650
|
+
"git update-ref*",
|
|
651
|
+
"git filter-branch*",
|
|
652
|
+
"git filter-repo*",
|
|
653
|
+
"git gc*",
|
|
654
|
+
"git prune*",
|
|
655
|
+
"git reflog*",
|
|
656
|
+
"git config*",
|
|
657
|
+
"git remote*",
|
|
658
|
+
"git cherry-pick*",
|
|
659
|
+
"git revert*",
|
|
660
|
+
"git am*",
|
|
661
|
+
"git add*",
|
|
662
|
+
"git mv*",
|
|
663
|
+
"git pull*",
|
|
664
|
+
"git -C*",
|
|
665
|
+
"git --git-dir*",
|
|
666
|
+
"git --work-tree*",
|
|
667
|
+
// git by absolute path would bypass the PATH shim (see guard.ts)
|
|
668
|
+
"/*/git*",
|
|
669
|
+
// bulk deletion and privilege
|
|
670
|
+
"rm -rf*",
|
|
671
|
+
"rm -fr*",
|
|
672
|
+
"rm -r *",
|
|
673
|
+
"rm -R *",
|
|
674
|
+
"rm --recursive*",
|
|
675
|
+
"sudo *",
|
|
676
|
+
"su *",
|
|
677
|
+
"doas *",
|
|
678
|
+
"chown *",
|
|
679
|
+
"chmod -R*",
|
|
680
|
+
"dd *",
|
|
681
|
+
"mkfs*",
|
|
682
|
+
"shutdown*",
|
|
683
|
+
"reboot*",
|
|
684
|
+
// killing processes could kill the primary agent
|
|
685
|
+
"kill *",
|
|
686
|
+
"pkill*",
|
|
687
|
+
"killall*",
|
|
688
|
+
// publishing / remote side effects
|
|
689
|
+
"gh *",
|
|
690
|
+
"npm publish*",
|
|
691
|
+
"pnpm publish*",
|
|
692
|
+
"yarn publish*",
|
|
693
|
+
"cargo publish*",
|
|
694
|
+
"twine upload*",
|
|
695
|
+
// no recursive delegation
|
|
696
|
+
"opencode*",
|
|
697
|
+
"pitroom *",
|
|
698
|
+
"*pitroom*"
|
|
699
|
+
];
|
|
700
|
+
function scratchDirs() {
|
|
701
|
+
const data = process.env.XDG_DATA_HOME ?? path4.join(os4.homedir(), ".local", "share");
|
|
702
|
+
const rules = {
|
|
703
|
+
"*": "deny",
|
|
704
|
+
[path4.join(data, "opencode", "tool-output", "*")]: "allow",
|
|
705
|
+
[path4.join(data, "opencode", "shell", "*", "*")]: "allow"
|
|
706
|
+
};
|
|
707
|
+
for (const tmp of /* @__PURE__ */ new Set([os4.tmpdir(), realpath(os4.tmpdir())])) rules[path4.join(tmp, "opencode", "*")] = "allow";
|
|
708
|
+
return rules;
|
|
709
|
+
}
|
|
710
|
+
function realpath(p) {
|
|
711
|
+
try {
|
|
712
|
+
return fs4.realpathSync(p);
|
|
713
|
+
} catch {
|
|
714
|
+
return p;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
var COMMON = {
|
|
718
|
+
skill: { "*": "allow", "*pitroom*": "deny" },
|
|
719
|
+
task: "deny",
|
|
720
|
+
// subagents carry their own, broader permissions
|
|
721
|
+
// v2 "execute" runs namespaced tools (browser automation, the OpenCode API itself).
|
|
722
|
+
execute: "deny",
|
|
723
|
+
question: "deny",
|
|
724
|
+
// nobody is there to answer
|
|
725
|
+
doom_loop: "deny"
|
|
726
|
+
};
|
|
727
|
+
var web = (on) => ({ webfetch: on ? "allow" : "deny", websearch: on ? "allow" : "deny" });
|
|
728
|
+
function readProfile(allowWeb = false) {
|
|
729
|
+
return {
|
|
730
|
+
"*": "deny",
|
|
731
|
+
read: READ_FILES,
|
|
732
|
+
glob: "allow",
|
|
733
|
+
grep: "allow",
|
|
734
|
+
list: "allow",
|
|
735
|
+
lsp: "allow",
|
|
736
|
+
codesearch: "allow",
|
|
737
|
+
todowrite: "allow",
|
|
738
|
+
todoread: "allow",
|
|
739
|
+
...web(allowWeb),
|
|
740
|
+
// v1 calls the shell tool "bash", v2 "shell"; both get the same rules.
|
|
741
|
+
bash: READ_ONLY_BASH,
|
|
742
|
+
shell: READ_ONLY_BASH,
|
|
743
|
+
edit: "deny",
|
|
744
|
+
external_directory: scratchDirs(),
|
|
745
|
+
...COMMON
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function writeProfile(allowWeb = false) {
|
|
749
|
+
const bash = { "*": "allow" };
|
|
750
|
+
for (const pattern of FORBIDDEN_BASH) bash[pattern] = "deny";
|
|
751
|
+
return {
|
|
752
|
+
"*": "allow",
|
|
753
|
+
read: READ_FILES,
|
|
754
|
+
...web(allowWeb),
|
|
755
|
+
bash,
|
|
756
|
+
shell: bash,
|
|
757
|
+
external_directory: scratchDirs(),
|
|
758
|
+
...COMMON
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function agentFor(mode) {
|
|
762
|
+
return mode === "read" ? AGENT.read : AGENT.write;
|
|
763
|
+
}
|
|
764
|
+
function configContent(existing, allowWeb = false) {
|
|
765
|
+
const ours = {
|
|
766
|
+
agent: {
|
|
767
|
+
[AGENT.read]: {
|
|
768
|
+
mode: "primary",
|
|
769
|
+
description: "pitroom: read-only worker (research, search, review)",
|
|
770
|
+
permission: readProfile(allowWeb)
|
|
771
|
+
},
|
|
772
|
+
[AGENT.write]: {
|
|
773
|
+
mode: "primary",
|
|
774
|
+
description: "pitroom: editing worker (no git history changes, no bulk deletes)",
|
|
775
|
+
permission: writeProfile(allowWeb)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
let base = {};
|
|
780
|
+
if (existing?.trim()) {
|
|
781
|
+
try {
|
|
782
|
+
base = JSON.parse(existing);
|
|
783
|
+
} catch {
|
|
784
|
+
base = {};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return JSON.stringify(deepMerge(base, ours));
|
|
788
|
+
}
|
|
789
|
+
function deepMerge(a, b) {
|
|
790
|
+
if (!isObject(a) || !isObject(b)) return b;
|
|
791
|
+
const out = { ...a };
|
|
792
|
+
for (const [k, v] of Object.entries(b)) out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
function isObject(v) {
|
|
796
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/backends/opencode/index.ts
|
|
800
|
+
var binary3 = () => findBinary("opencode", "PITROOM_OPENCODE_BIN", ["~/.opencode/bin/opencode"]);
|
|
801
|
+
function oc(args, opts = {}) {
|
|
802
|
+
const { command, prefix } = resolveCommand(binary3());
|
|
803
|
+
const r = spawnSync3(command, [...prefix, ...args], {
|
|
804
|
+
encoding: "utf8",
|
|
805
|
+
timeout: opts.timeout ?? 6e4,
|
|
806
|
+
env: opts.env ?? process.env,
|
|
807
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
808
|
+
maxBuffer: 64 * 1024 * 1024
|
|
809
|
+
});
|
|
810
|
+
return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
|
|
811
|
+
}
|
|
812
|
+
var jsonIn = (s) => JSON.parse(s.slice(s.indexOf("{")));
|
|
813
|
+
function invocation3(req) {
|
|
814
|
+
const args = [
|
|
815
|
+
"run",
|
|
816
|
+
"--standalone",
|
|
817
|
+
"--agent",
|
|
818
|
+
agentFor(req.mode),
|
|
819
|
+
"--format",
|
|
820
|
+
"json",
|
|
821
|
+
"--title",
|
|
822
|
+
req.title,
|
|
823
|
+
// Some failure causes only appear in the server logs.
|
|
824
|
+
"--print-logs",
|
|
825
|
+
"--log-level",
|
|
826
|
+
"error"
|
|
827
|
+
];
|
|
828
|
+
if (req.model) args.push("--model", req.model);
|
|
829
|
+
if (req.sessionId) args.push("--session", req.sessionId);
|
|
830
|
+
for (const f of req.files) args.push("--file", f);
|
|
831
|
+
args.push(req.prompt);
|
|
832
|
+
const { command, prefix } = resolveCommand(binary3());
|
|
833
|
+
return {
|
|
834
|
+
command,
|
|
835
|
+
args: [...prefix, ...args],
|
|
836
|
+
env: {
|
|
837
|
+
OPENCODE_CONFIG_CONTENT: configContent(process.env.OPENCODE_CONFIG_CONTENT, req.web),
|
|
838
|
+
// A worker run must not upgrade OpenCode underneath the user.
|
|
839
|
+
OPENCODE_DISABLE_AUTOUPDATE: "1"
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function majorVersion(output) {
|
|
844
|
+
const m = /(\d+)\.\d+\.\d+/.exec(output);
|
|
845
|
+
return m ? Number(m[1]) : void 0;
|
|
846
|
+
}
|
|
847
|
+
function classify3(message) {
|
|
848
|
+
if (/model not found|ModelNotFound|unknown model|no such model|deprecated/i.test(message)) return "model-unavailable";
|
|
849
|
+
if (/rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
|
|
850
|
+
if (/provider\.auth|FreeTierError|free tier|unauthori[sz]ed|\b40[13]\b|api.?key|insufficient|credits?\b|billing|payment/i.test(message)) {
|
|
851
|
+
return "auth";
|
|
852
|
+
}
|
|
853
|
+
return "other";
|
|
854
|
+
}
|
|
855
|
+
function failure3(run, stderr, exitCode) {
|
|
856
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
857
|
+
const detail = stderr.match(/error="([^"]+)"/g)?.pop()?.slice(7, -1);
|
|
858
|
+
const raw = run.error ?? `opencode exited with code ${exitCode}`;
|
|
859
|
+
const message = detail && !raw.includes(detail) ? `${raw}: ${detail}` : raw;
|
|
860
|
+
return { kind: classify3(message), message };
|
|
861
|
+
}
|
|
862
|
+
function modelId(m) {
|
|
863
|
+
if (typeof m === "string") return m;
|
|
864
|
+
const id = m?.model ?? m?.id;
|
|
865
|
+
return m?.providerID && id ? `${m.providerID}/${id}` : void 0;
|
|
866
|
+
}
|
|
867
|
+
function defaultModel2() {
|
|
868
|
+
try {
|
|
869
|
+
const out = oc(["debug", "config"], { timeout: 3e4 }).out;
|
|
870
|
+
const json = JSON.parse(out.slice(out.search(/[[{]/)));
|
|
871
|
+
if (Array.isArray(json)) {
|
|
872
|
+
const withModel = json.filter((s) => s?.info?.model);
|
|
873
|
+
return modelId(withModel[withModel.length - 1]?.info.model);
|
|
874
|
+
}
|
|
875
|
+
return modelId(json.model);
|
|
876
|
+
} catch {
|
|
877
|
+
return void 0;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function resolveModel2(sessionId) {
|
|
881
|
+
try {
|
|
882
|
+
const json = jsonIn(oc(["session", "export", sessionId], { timeout: 2e4 }).out);
|
|
883
|
+
const msgs = [...json.messages ?? []].reverse();
|
|
884
|
+
const v2 = msgs.find((m) => m.type === "assistant" && m.model)?.model;
|
|
885
|
+
if (v2) return modelId(v2);
|
|
886
|
+
const v1 = msgs.find((m) => m.info?.role === "assistant")?.info;
|
|
887
|
+
return v1?.providerID && v1?.modelID ? `${v1.providerID}/${v1.modelID}` : void 0;
|
|
888
|
+
} catch {
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function listModels() {
|
|
893
|
+
return oc(["models"]).out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
894
|
+
}
|
|
895
|
+
function doctor3({ models, hasFallback }) {
|
|
896
|
+
const checks = [];
|
|
897
|
+
const version = oc(["--version"]);
|
|
898
|
+
if (version.missing || !version.ok) {
|
|
899
|
+
return [{ level: "fail", message: `opencode not runnable (${binary3()}). Install: https://opencode.ai or set PITROOM_OPENCODE_BIN` }];
|
|
900
|
+
}
|
|
901
|
+
const v = version.out.trim().replace(/^opencode\s+/i, "");
|
|
902
|
+
const major = majorVersion(v);
|
|
903
|
+
if (major !== void 0 && major < 2) {
|
|
904
|
+
return [{ level: "fail", message: `OpenCode ${v} is too old: Pitroom needs OpenCode v2 or newer (run \`opencode upgrade\`)` }];
|
|
905
|
+
}
|
|
906
|
+
checks.push({ level: "ok", message: `opencode ${v} at ${binary3()} (private --standalone server per run)` });
|
|
907
|
+
const known = new Set(listModels());
|
|
908
|
+
const def = defaultModel2();
|
|
909
|
+
for (const m of models) {
|
|
910
|
+
const model = m ?? def;
|
|
911
|
+
const label = m ? "model" : "default model";
|
|
912
|
+
if (!model) {
|
|
913
|
+
checks.push({ level: "warn", message: 'OpenCode has no default model; it will pick one (set "model" in opencode.json)' });
|
|
914
|
+
} else if (known.size && !known.has(model)) {
|
|
915
|
+
checks.push({ level: "fail", message: `OpenCode ${label} "${model}" is not in \`opencode models\`` });
|
|
916
|
+
} else {
|
|
917
|
+
checks.push({ level: "ok", message: `OpenCode ${label}: ${model}` });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!hasFallback) {
|
|
921
|
+
const free = [...known].filter((m) => /-free$/.test(m) && m !== def).slice(0, 4);
|
|
922
|
+
checks.push({
|
|
923
|
+
level: "warn",
|
|
924
|
+
message: `no fallback workers: if the model is rate-limited or removed, runs fail.${free.length ? ` Free OpenCode models you have: ${free.join(", ")}` : ""}`
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
checks.push({ level: "ok", message: `permission profiles ${AGENT.read}/${AGENT.write} injected per run (verify: pitroom doctor --probe)` });
|
|
928
|
+
return checks;
|
|
929
|
+
}
|
|
930
|
+
var opencode = {
|
|
931
|
+
id: "opencode",
|
|
932
|
+
name: "OpenCode",
|
|
933
|
+
capabilities: { readOnly: "permission-rules", resume: "by-id", reportsCost: true, attachFiles: true },
|
|
934
|
+
binary: binary3,
|
|
935
|
+
invocation: invocation3,
|
|
936
|
+
parse: parseEvents3,
|
|
937
|
+
failure: failure3,
|
|
938
|
+
defaultModel: defaultModel2,
|
|
939
|
+
resolveModel: resolveModel2,
|
|
940
|
+
listModels,
|
|
941
|
+
doctor: doctor3
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// src/backends/index.ts
|
|
945
|
+
var REGISTRY = new Map([opencode, codex, claude].map((b) => [b.id, b]));
|
|
946
|
+
var DEFAULT_BACKEND = opencode.id;
|
|
947
|
+
var PLANNED = ["gemini"];
|
|
948
|
+
var backendIds = () => [...REGISTRY.keys()];
|
|
949
|
+
var allBackends = () => [...REGISTRY.values()];
|
|
950
|
+
var isBackendId = (id) => REGISTRY.has(id) || PLANNED.includes(id);
|
|
951
|
+
function getBackend(id) {
|
|
952
|
+
const backend = REGISTRY.get(id);
|
|
953
|
+
if (backend) return backend;
|
|
954
|
+
const available = backendIds().join(", ");
|
|
955
|
+
throw new UserError(
|
|
956
|
+
PLANNED.includes(id) ? `the "${id}" worker is not supported yet (available: ${available})` : `unknown worker "${id}" (available: ${available})`,
|
|
957
|
+
3
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/core/target.ts
|
|
962
|
+
function parseTarget(spec, defaultBackend) {
|
|
963
|
+
const s = spec.trim();
|
|
964
|
+
if (!s) throw new UserError("empty worker target");
|
|
965
|
+
const i = s.indexOf(":");
|
|
966
|
+
if (i > 0 && isBackendId(s.slice(0, i))) {
|
|
967
|
+
const model = s.slice(i + 1).trim();
|
|
968
|
+
return model ? { backend: s.slice(0, i), model } : { backend: s.slice(0, i) };
|
|
969
|
+
}
|
|
970
|
+
if (isBackendId(s)) return { backend: s };
|
|
971
|
+
return { backend: defaultBackend, model: s };
|
|
972
|
+
}
|
|
973
|
+
var formatTarget = (t) => t.model ? `${t.backend}:${t.model}` : t.backend;
|
|
974
|
+
var describeTarget = (t) => t.model ? formatTarget(t) : `${t.backend} (default model)`;
|
|
975
|
+
|
|
976
|
+
// src/core/refs.ts
|
|
977
|
+
import fs5 from "node:fs";
|
|
978
|
+
import path5 from "node:path";
|
|
979
|
+
var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
|
|
980
|
+
var REF = new RegExp(
|
|
981
|
+
String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
|
|
982
|
+
"g"
|
|
983
|
+
);
|
|
984
|
+
var IDENT = /`([A-Za-z_$][\w$]*)(?:\([^`]*\))?`/g;
|
|
985
|
+
var CLAUSE = /[;|—]|\.\s|:\s/g;
|
|
986
|
+
var MAX_BYTES = 5 * 1024 * 1024;
|
|
987
|
+
var WINDOW = 5;
|
|
988
|
+
var DEFINITION_LOOKBACK = 80;
|
|
989
|
+
function looksLikeSymbol(m) {
|
|
990
|
+
const name = m[1];
|
|
991
|
+
return m[0].includes("(") || /[A-Z_$0-9]/.test(name) || name.length >= 5;
|
|
992
|
+
}
|
|
993
|
+
function extractRefs(answer) {
|
|
994
|
+
const seen = /* @__PURE__ */ new Map();
|
|
995
|
+
for (const line of answer.split("\n")) {
|
|
996
|
+
const matches = [...line.matchAll(REF)];
|
|
997
|
+
const seps = [...line.matchAll(CLAUSE)].map((s) => s.index ?? 0);
|
|
998
|
+
const sepBetween = (a, b) => seps.some((s) => s >= a && s < b);
|
|
999
|
+
const idents = [...line.matchAll(IDENT)].filter((i) => {
|
|
1000
|
+
if (!looksLikeSymbol(i)) return false;
|
|
1001
|
+
const pos = i.index ?? 0;
|
|
1002
|
+
const before = matches.filter((m) => (m.index ?? 0) + m[0].length <= pos).at(-1);
|
|
1003
|
+
const after = matches.find((m) => (m.index ?? 0) >= pos + i[0].length);
|
|
1004
|
+
return !(before && after && !sepBetween((before.index ?? 0) + before[0].length, after.index ?? 0));
|
|
1005
|
+
});
|
|
1006
|
+
matches.forEach((m, k) => {
|
|
1007
|
+
const start = Number(m[2]);
|
|
1008
|
+
const end = m[3] ? Number(m[3]) : start;
|
|
1009
|
+
if (start < 1 || end < start) return;
|
|
1010
|
+
const at = m.index ?? 0;
|
|
1011
|
+
const prev = matches[k - 1];
|
|
1012
|
+
const next = matches[k + 1];
|
|
1013
|
+
const from = Math.max(at - 80, prev ? (prev.index ?? 0) + prev[0].length : 0, ...seps.filter((s) => s < at).map((s) => s + 1));
|
|
1014
|
+
const to = Math.min(at + m[0].length + 40, next?.index ?? Infinity, ...seps.filter((s) => s >= at + m[0].length));
|
|
1015
|
+
const near = new Set(idents.filter((i) => (i.index ?? 0) >= from && (i.index ?? 0) < to).map((i) => i[1]));
|
|
1016
|
+
const symbol = near.size === 1 ? [...near][0] : void 0;
|
|
1017
|
+
const key = `${m[1]}:${start}-${end}:${symbol ?? ""}`;
|
|
1018
|
+
if (!seen.has(key)) seen.set(key, { text: m[0], file: m[1], start, end, symbol });
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
return [...seen.values()];
|
|
1022
|
+
}
|
|
1023
|
+
function verifyRefs(refs, dirs) {
|
|
1024
|
+
const roots = [...new Set(dirs.filter(Boolean).map((d) => path5.resolve(d)))];
|
|
1025
|
+
const invalid = [];
|
|
1026
|
+
const lineCache = /* @__PURE__ */ new Map();
|
|
1027
|
+
const load = (file) => {
|
|
1028
|
+
if (!lineCache.has(file)) {
|
|
1029
|
+
const size = fs5.statSync(file).size;
|
|
1030
|
+
const lines = size > MAX_BYTES ? null : fs5.readFileSync(file, "utf8").split("\n");
|
|
1031
|
+
if (lines && lines[lines.length - 1] === "") lines.pop();
|
|
1032
|
+
lineCache.set(file, lines);
|
|
1033
|
+
}
|
|
1034
|
+
return lineCache.get(file);
|
|
1035
|
+
};
|
|
1036
|
+
for (const ref of refs) {
|
|
1037
|
+
const file = resolve(ref.file, roots);
|
|
1038
|
+
if (!file) {
|
|
1039
|
+
invalid.push({ ref: ref.text, reason: path5.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const lines = load(file);
|
|
1043
|
+
if (!lines) continue;
|
|
1044
|
+
if (ref.end > lines.length) {
|
|
1045
|
+
invalid.push({ ref: ref.text, reason: `file has ${lines.length} lines` });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (ref.symbol && !mentions(lines, ref) && !enclosedBy(lines, ref)) {
|
|
1049
|
+
invalid.push({ ref: ref.text, reason: `\`${ref.symbol}\` not near line ${ref.start}` });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return { total: refs.length, valid: refs.length - invalid.length, invalid };
|
|
1053
|
+
}
|
|
1054
|
+
function mentions(lines, ref) {
|
|
1055
|
+
const near = lines.slice(Math.max(0, ref.start - 1 - WINDOW), ref.end + WINDOW).join("\n");
|
|
1056
|
+
return new RegExp(`(^|[^\\w$])${escape(ref.symbol)}([^\\w$]|$)`).test(near);
|
|
1057
|
+
}
|
|
1058
|
+
function enclosedBy(lines, ref) {
|
|
1059
|
+
const name = escape(ref.symbol);
|
|
1060
|
+
const def = new RegExp(
|
|
1061
|
+
`\\b(function|def|fn|func|class|interface|struct|impl|type|const|let|var)\\s+\\*?${name}\\b|^\\s*(export\\s+)?(default\\s+)?(async\\s+)?(static\\s+)?${name}\\s*[(:=]`
|
|
1062
|
+
);
|
|
1063
|
+
return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
|
|
1064
|
+
}
|
|
1065
|
+
function resolve(ref, roots) {
|
|
1066
|
+
const candidates = path5.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path5.join(r, ref));
|
|
1067
|
+
return candidates.find((c) => {
|
|
1068
|
+
try {
|
|
1069
|
+
return fs5.statSync(c).isFile() && inside(c, roots);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
function inside(file, roots) {
|
|
1076
|
+
return roots.some((r) => {
|
|
1077
|
+
const rel = path5.relative(r, path5.resolve(file));
|
|
1078
|
+
return rel !== "" && !rel.startsWith("..") && !path5.isAbsolute(rel);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1082
|
+
|
|
1083
|
+
// src/core/templates.ts
|
|
1084
|
+
import fs6 from "node:fs";
|
|
1085
|
+
import path7 from "node:path";
|
|
1086
|
+
|
|
1087
|
+
// src/core/install.ts
|
|
1088
|
+
import path6 from "node:path";
|
|
1089
|
+
import { fileURLToPath } from "node:url";
|
|
1090
|
+
function packageRoot() {
|
|
1091
|
+
return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/core/templates.ts
|
|
1095
|
+
var FILES = {
|
|
1096
|
+
implementer: "pitroom-driven-development/implementer-prompt.md",
|
|
1097
|
+
"task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
|
|
1098
|
+
"re-review": "pitroom-driven-development/re-review-prompt.md",
|
|
1099
|
+
"code-reviewer": "pitroom-review/code-reviewer.md"
|
|
1100
|
+
};
|
|
1101
|
+
function loadTemplate(name, root = packageRoot()) {
|
|
1102
|
+
const file = path7.join(root, "skills", FILES[name]);
|
|
1103
|
+
if (!fs6.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
|
|
1104
|
+
return fs6.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
|
|
1105
|
+
}
|
|
1106
|
+
function fill(template, values) {
|
|
1107
|
+
const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
|
|
1108
|
+
if (missing.length) throw new UserError(`no value for template placeholder(s): ${[...new Set(missing)].join(", ")}`, 3);
|
|
1109
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// src/core/answers.ts
|
|
1113
|
+
var TASK_STATUSES = ["DONE", "DONE_WITH_CONCERNS", "NEEDS_CONTEXT", "BLOCKED"];
|
|
1114
|
+
function parseStatus(text) {
|
|
1115
|
+
const value = /^\s*STATUS:\s*([A-Za-z_]+)/im.exec(text)?.[1]?.toUpperCase() ?? "";
|
|
1116
|
+
return TASK_STATUSES.includes(value) ? value : "unknown";
|
|
1117
|
+
}
|
|
1118
|
+
function parseVerdict(text) {
|
|
1119
|
+
const line = /^\s*SUMMARY:.*\bSPEC:.*$/im.exec(text)?.[0] ?? /^.*\bSPEC:.*$/im.exec(text)?.[0] ?? "";
|
|
1120
|
+
const spec = /\bSPEC:\s*(PASS|FAIL)\b/i.exec(line)?.[1]?.toLowerCase();
|
|
1121
|
+
const quality = /\bQUALITY:\s*(APPROVED|NEEDS[_ -]?FIXES)\b/i.exec(line)?.[1]?.toUpperCase();
|
|
1122
|
+
const count = (k) => Number(new RegExp(`\\b${k}=(\\d+)`, "i").exec(line)?.[1] ?? 0);
|
|
1123
|
+
return {
|
|
1124
|
+
spec: spec === "pass" || spec === "fail" ? spec : "unknown",
|
|
1125
|
+
quality: quality === "APPROVED" ? "approved" : quality ? "needs-fixes" : "unknown",
|
|
1126
|
+
critical: count("critical"),
|
|
1127
|
+
important: count("important"),
|
|
1128
|
+
minor: count("minor")
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/core/plan.ts
|
|
1133
|
+
import fs7 from "node:fs";
|
|
1134
|
+
import path8 from "node:path";
|
|
1135
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
1136
|
+
var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
|
|
1137
|
+
function structure(lines) {
|
|
1138
|
+
const headings = [];
|
|
1139
|
+
const rules = [];
|
|
1140
|
+
const fenced = [];
|
|
1141
|
+
let open;
|
|
1142
|
+
lines.forEach((l, i) => {
|
|
1143
|
+
const f = FENCE.exec(l);
|
|
1144
|
+
if (f) {
|
|
1145
|
+
fenced[i] = true;
|
|
1146
|
+
const mark = f[1];
|
|
1147
|
+
if (!open) open = mark;
|
|
1148
|
+
else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
fenced[i] = !!open;
|
|
1152
|
+
if (open) return;
|
|
1153
|
+
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
|
|
1154
|
+
if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
|
|
1155
|
+
else if (RULE.test(l)) rules.push(i);
|
|
1156
|
+
});
|
|
1157
|
+
return { headings, rules, fenced };
|
|
1158
|
+
}
|
|
1159
|
+
function parsePlan(text, file = "") {
|
|
1160
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
1161
|
+
const { headings, rules, fenced } = structure(lines);
|
|
1162
|
+
const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
|
|
1163
|
+
const titleHeading = headings.find((h) => h.level === 1);
|
|
1164
|
+
const start = titleHeading ? titleHeading.line + 1 : 0;
|
|
1165
|
+
const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
|
|
1166
|
+
const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
|
|
1167
|
+
const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
|
|
1168
|
+
const tasks = [];
|
|
1169
|
+
for (const h of headings) {
|
|
1170
|
+
const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
|
|
1171
|
+
if (!m) continue;
|
|
1172
|
+
const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
|
|
1173
|
+
const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
|
|
1174
|
+
const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
|
|
1175
|
+
const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
|
|
1176
|
+
tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
|
|
1177
|
+
}
|
|
1178
|
+
return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
|
|
1179
|
+
}
|
|
1180
|
+
function loadPlan(file) {
|
|
1181
|
+
const abs = path8.resolve(file);
|
|
1182
|
+
if (!fs7.existsSync(abs) || !fs7.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
|
|
1183
|
+
const plan = parsePlan(fs7.readFileSync(abs, "utf8"), fs7.realpathSync(abs));
|
|
1184
|
+
if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
|
|
1185
|
+
return plan;
|
|
1186
|
+
}
|
|
1187
|
+
function planTask(plan, step) {
|
|
1188
|
+
const t = plan.tasks.find((x) => x.step === step);
|
|
1189
|
+
if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
|
|
1190
|
+
return t;
|
|
1191
|
+
}
|
|
1192
|
+
function brief(plan, task) {
|
|
1193
|
+
return `${[
|
|
1194
|
+
`# ${plan.title || planName(plan.file)}`,
|
|
1195
|
+
plan.header,
|
|
1196
|
+
"## Global Constraints",
|
|
1197
|
+
plan.constraints || "(none stated in the plan)",
|
|
1198
|
+
task.text
|
|
1199
|
+
].filter(Boolean).join("\n\n")}
|
|
1200
|
+
`;
|
|
1201
|
+
}
|
|
1202
|
+
var planName = (file) => path8.basename(file).replace(/\.md$/i, "");
|
|
1203
|
+
export {
|
|
1204
|
+
DEFAULT_BACKEND,
|
|
1205
|
+
allBackends,
|
|
1206
|
+
backendIds,
|
|
1207
|
+
brief,
|
|
1208
|
+
describeTarget,
|
|
1209
|
+
extractRefs,
|
|
1210
|
+
fill,
|
|
1211
|
+
formatTarget,
|
|
1212
|
+
getBackend,
|
|
1213
|
+
loadPlan,
|
|
1214
|
+
loadTemplate,
|
|
1215
|
+
parsePlan,
|
|
1216
|
+
parseStatus,
|
|
1217
|
+
parseTarget,
|
|
1218
|
+
parseVerdict,
|
|
1219
|
+
planName,
|
|
1220
|
+
planTask,
|
|
1221
|
+
verifyRefs
|
|
1222
|
+
};
|