cheap-agent 0.0.0-stage → 1.1.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/dist/plan.js ADDED
@@ -0,0 +1,117 @@
1
+ import { DIFFICULTIES } from "./routing.js";
2
+ const REQUIRED = ["files", "done-when"];
3
+ /** The plan grammar in one line, for Claude: the delegate tool description and every plan-error FAIL carry it. */
4
+ export const PLAN_FORMAT = 'Plan format: one "# <title>" line, then per step a "## <id> <title>" heading followed by "files: <paths>", "do: <what>", "done-when: <check>" (optional "dont:", "needs: <step ids>", "difficulty: easy|medium|hard"), each key at the start of its line, not a bullet. In done-when, `backticked` text is run as a shell command that must succeed; put only commands in backticks.';
5
+ /** Parses a plan; throws with a precise, line-numbered reason when it does not follow the format. */
6
+ export function parsePlan(source) {
7
+ const lines = source.split(/\r?\n/);
8
+ let title;
9
+ const context = [];
10
+ const steps = [];
11
+ let field;
12
+ lines.forEach((line, i) => {
13
+ const at = `line ${i + 1}`;
14
+ if (/^# /.test(line)) {
15
+ if (title !== undefined)
16
+ throw new Error(`${at}: a plan has exactly one H1 title`);
17
+ title = line.slice(2).trim();
18
+ return;
19
+ }
20
+ if (/^## /.test(line)) {
21
+ if (title === undefined)
22
+ throw new Error(`${at}: step before the plan's H1 title`);
23
+ const m = line.match(/^## +(\d+[a-z]?)(?: +(.*))?$/);
24
+ if (!m)
25
+ throw new Error(`${at}: step heading needs an id (integer or integer+letter), e.g. "## 3 add route"`);
26
+ if (steps.some((s) => s.id === m[1]))
27
+ throw new Error(`${at}: duplicate step id ${m[1]}`);
28
+ steps.push({ id: m[1], title: (m[2] ?? "").trim(), fields: {}, text: line, line: i + 1 });
29
+ field = undefined;
30
+ return;
31
+ }
32
+ const step = steps.at(-1);
33
+ if (!step) {
34
+ if (title !== undefined && line.trim())
35
+ context.push(line);
36
+ return;
37
+ }
38
+ step.text += `\n${line}`;
39
+ const kv = line.match(/^([a-z][a-z-]*):\s?(.*)$/);
40
+ if (kv) {
41
+ field = kv[1];
42
+ step.fields[field] = kv[2].trim();
43
+ }
44
+ else if (/^ {2,}\S/.test(line) && field) {
45
+ step.fields[field] += `\n${line.trim()}`;
46
+ }
47
+ else if (line.trim()) {
48
+ field = undefined; // free prose inside a step is kept in its text, not a field
49
+ }
50
+ });
51
+ if (title === undefined)
52
+ throw new Error("plan has no H1 title (\"# <title>\")");
53
+ if (!steps.length)
54
+ throw new Error("plan has no steps (\"## <id> <title>\")");
55
+ for (const step of steps) {
56
+ const missing = REQUIRED.filter((key) => !step.fields[key]);
57
+ if (missing.length)
58
+ throw new Error(`step ${step.id} (line ${step.line}): missing ${missing.map((k) => `${k}:`).join(", ")}`);
59
+ const d = step.fields.difficulty;
60
+ if (d !== undefined && !DIFFICULTIES.includes(d))
61
+ throw new Error(`step ${step.id} (line ${step.line}): difficulty: must be ${DIFFICULTIES.join(", ")} or left out`);
62
+ }
63
+ return { title, context: context.join("\n").trim(), steps: steps.map(({ line, ...step }) => ({ ...step, text: step.text.trim() })) };
64
+ }
65
+ /**
66
+ * Resolves a steps string to groups (spec D2): `"3"` one step, `"3-5"` a range run in order as one group, and commas
67
+ * separate groups (`"1-2,3,4"`). Groups come back in plan order; a step may be in only one group.
68
+ */
69
+ export function selectGroups(plan, spec) {
70
+ const index = (id) => {
71
+ const i = plan.steps.findIndex((s) => s.id === id);
72
+ if (i < 0)
73
+ throw new Error(`step ${id} not found (plan steps: ${plan.steps.map((s) => s.id).join(", ")})`);
74
+ return i;
75
+ };
76
+ const seen = new Set();
77
+ const groups = spec.split(",").map((raw) => {
78
+ const part = raw.trim();
79
+ const m = part.match(/^(\d+[a-z]?)(?:\s*-\s*(\d+[a-z]?))?$/);
80
+ if (!m)
81
+ throw new Error(`steps "${spec}": "${part}" is not a step id or range; use e.g. "3", "3-5" or "1-2,3,4"`);
82
+ const [from, to] = [index(m[1]), index(m[2] ?? m[1])];
83
+ if (to < from)
84
+ throw new Error(`steps "${spec}": range ${part} runs backwards`);
85
+ const steps = plan.steps.slice(from, to + 1);
86
+ for (const s of steps) {
87
+ if (seen.has(s.id))
88
+ throw new Error(`steps "${spec}": step ${s.id} is in more than one group`);
89
+ seen.add(s.id);
90
+ }
91
+ return { label: m[2] ? `${m[1]}-${m[2]}` : m[1], steps, at: from };
92
+ });
93
+ const sorted = groups.sort((a, b) => a.at - b.at).map(({ at, ...g }) => g);
94
+ // groups land in step order, so a group waiting on a later one would never land
95
+ sorted.forEach((g, i) => {
96
+ const later = needsOf(g).find((id) => sorted.slice(i + 1).some((o) => o.steps.some((s) => s.id === id)));
97
+ if (later)
98
+ throw new Error(`steps "${spec}": step ${g.label} needs ${later}, which comes later in the plan; put dependencies first`);
99
+ });
100
+ return sorted;
101
+ }
102
+ /** Where two groups' `files:` overlap: an equal entry, or one inside the other's directory entry. Returns the inner path. */
103
+ export function overlap(a, b) {
104
+ const files = (g) => g.steps.flatMap((s) => s.fields.files.split(/[,\n]/)).map((f) => f.trim().replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
105
+ const theirs = files(b);
106
+ for (const x of files(a)) {
107
+ for (const y of theirs) {
108
+ if (x === y || x.startsWith(`${y}/`))
109
+ return x;
110
+ if (y.startsWith(`${x}/`))
111
+ return y;
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+ /** The step ids a group's `needs:` names, outside the group itself (a range already runs in order). */
117
+ export const needsOf = (g) => [...new Set(g.steps.flatMap((s) => (s.fields.needs ?? "").split(/[\s,]+/).filter(Boolean)))].filter((id) => !g.steps.some((s) => s.id === id));
@@ -0,0 +1,112 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, lstatSync, readdirSync, symlinkSync, unlinkSync } from "node:fs";
3
+ import { delimiter, dirname, join, sep } from "node:path";
4
+ /**
5
+ * Platform: everything OS-specific lives here (spec A18). Windows is supported through junctions, taskkill and
6
+ * PATHEXT lookup here; callers stay platform-free.
7
+ */
8
+ /** Links a dependency directory into a worktree. Junctions need no privilege on Windows, unlike directory symlinks. */
9
+ export function link(target, path) {
10
+ symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
11
+ }
12
+ /** Removes a link made by `link`, never what it points to; does nothing if `path` is not a link. */
13
+ export function removeLink(path) {
14
+ try {
15
+ if (lstatSync(path).isSymbolicLink())
16
+ unlinkSync(path);
17
+ }
18
+ catch {
19
+ // already gone
20
+ }
21
+ }
22
+ /** Forward-slash form of a relative path, as result blocks show paths. */
23
+ export const normalisePath = (path) => path.split(sep).join("/");
24
+ /** Whether a process is still running (one we may not signal still counts as running). */
25
+ export function isAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ return err.code === "EPERM";
32
+ }
33
+ }
34
+ /** How OpenCode's per-platform npm packages name this OS, and its binary's file name. */
35
+ export function opencodePlatform() {
36
+ const os = process.platform === "win32" ? "windows" : process.platform;
37
+ return { os, binary: os === "windows" ? "opencode.exe" : "opencode" };
38
+ }
39
+ /** Signals a whole process group (a child spawned `detached`), ignoring one that is already gone. */
40
+ export function killProcessGroup(pid, signal = "SIGTERM") {
41
+ if (!pid)
42
+ return;
43
+ if (process.platform === "win32") {
44
+ // no process groups on Windows; taskkill walks the tree instead and ignores the signal
45
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
46
+ return;
47
+ }
48
+ try {
49
+ process.kill(-pid, signal);
50
+ }
51
+ catch {
52
+ // already gone
53
+ }
54
+ }
55
+ /** The first executable named `name` on `env`'s PATH, or undefined. On Windows it tries PATHEXT extensions, bare name last. */
56
+ export function which(name, platform = process.platform, env = process.env) {
57
+ const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
58
+ const candidates = platform === "win32"
59
+ ? [...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => name + ext), name]
60
+ : [name];
61
+ for (const dir of dirs) {
62
+ for (const candidate of candidates) {
63
+ if (platform !== "win32") {
64
+ const full = join(dir, candidate);
65
+ if (existsSync(full))
66
+ return full;
67
+ continue;
68
+ }
69
+ // Windows filesystems are case-insensitive; read the dir so a case difference still matches
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(dir);
73
+ }
74
+ catch {
75
+ break;
76
+ }
77
+ const hit = entries.find((entry) => entry.toLowerCase() === candidate.toLowerCase());
78
+ if (hit)
79
+ return join(dir, hit);
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+ /**
85
+ * Git for Windows' bash, for running bash-syntax commands on Windows. Never a bare `bash` from PATH: on Windows that
86
+ * can be WSL's System32\bash.exe, which resolves Windows paths differently.
87
+ */
88
+ export function gitBash(platform = process.platform, env = process.env) {
89
+ if (platform !== "win32")
90
+ return undefined;
91
+ // Claude Code's own variable; trust it when it points at a real file
92
+ const override = env.CLAUDE_CODE_GIT_BASH_PATH;
93
+ if (override && existsSync(override))
94
+ return override;
95
+ const git = which("git", platform, env);
96
+ if (!git)
97
+ return undefined;
98
+ // Git for Windows: git.exe in <Git>\cmd, bash.exe in <Git>\bin
99
+ const bash = join(dirname(git), "..", "bin", "bash.exe");
100
+ return existsSync(bash) ? bash : undefined;
101
+ }
102
+ /** Double-quotes an argument Node will re-split under `shell: true` on Windows. */
103
+ const quoteWinArg = (arg) => (/[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg);
104
+ export function runCli(name, args, opts) {
105
+ const file = which(name);
106
+ if (!file)
107
+ return undefined;
108
+ if (/\.(cmd|bat)$/i.test(file)) {
109
+ return spawnSync(file, args.map(quoteWinArg), { ...opts, shell: true, windowsHide: true });
110
+ }
111
+ return spawnSync(file, args, opts);
112
+ }
package/dist/report.js ADDED
@@ -0,0 +1,178 @@
1
+ const STATUS_SECTIONS = {
2
+ OK: [],
3
+ PARTIAL: ["found", "missing", "worktree"],
4
+ FAIL: ["error", "found", "tried", "reason", "worktree"],
5
+ BLOCKED: ["question"],
6
+ CONFLICT: ["conflict", "worktree"],
7
+ CANCELLED: ["reason", "worktree"],
8
+ TIMEOUT: ["found", "reason", "missing", "worktree"],
9
+ BUDGET: ["found", "reason", "worktree"],
10
+ };
11
+ // D6 urgency; CANCELLED is not ranked by the spec and sits with BUDGET/TIMEOUT
12
+ const URGENCY = ["BLOCKED", "CONFLICT", "FAIL", "BUDGET", "TIMEOUT", "CANCELLED", "PARTIAL", "RUNNING", "OK"];
13
+ const rank = (s) => (s === "TIMEOUT" || s === "CANCELLED" ? URGENCY.indexOf("BUDGET") : URGENCY.indexOf(s));
14
+ const BUDGET_TOKENS = 400;
15
+ const LINE_MAX = 200;
16
+ const FULL = { changed: 15, tried: Infinity, risks: 5, review: 5, error: Infinity, summary: 160 };
17
+ /** A FAIL before any job exists (bad input, setup). */
18
+ export const failBlock = (reason) => `FAIL reason: ${reason}`;
19
+ /** A job block within `budget` tokens (chars/4), cutting in D9 order only while over it. Never cut: headline, question/options, conflict, reason, worktree, log. */
20
+ export function renderJob(r, budget = BUDGET_TOKENS) {
21
+ if (r.status === "RUNNING")
22
+ return fit(`RUNNING ${r.job} step=${r.steps} turns=${r.usage.turns}`);
23
+ let cuts = FULL;
24
+ let text = layout(r, cuts);
25
+ const over = () => text.length / 4 > budget;
26
+ const cut = (c) => {
27
+ if (over())
28
+ text = layout(r, (cuts = { ...cuts, ...c }));
29
+ };
30
+ cut({ changed: 5 });
31
+ cut({ tried: 1 });
32
+ cut({ risks: 3 });
33
+ cut({ review: 3 });
34
+ for (let n = r.error?.length ?? 0; n > 12 && over();)
35
+ cut({ error: --n });
36
+ cut({ summary: 100 });
37
+ return text;
38
+ }
39
+ function layout(r, c) {
40
+ const sections = STATUS_SECTIONS[r.status];
41
+ const has = (s) => sections.includes(s);
42
+ const add = r.changed.reduce((n, x) => n + x.add, 0);
43
+ const del = r.changed.reduce((n, x) => n + x.del, 0);
44
+ const lines = [
45
+ [
46
+ r.status, r.job, `step=${r.steps}`, `files=${r.changed.length}`, `+${add}/-${del}`, r.tests && `tests=${r.tests}`,
47
+ `turns=${r.usage.turns}`, `cost=$${r.usage.costUsd.toFixed(3)}`,
48
+ r.selfFix && `fix=${r.selfFix}`, r.models.escalated && "esc=1",
49
+ r.models.final !== r.models.requested && `model=${r.models.final}`, r.redo && "redo=1", r.billing && `billing=${r.billing}`, r.repro && `repro=${r.repro}`, r.test && `test=${r.test}`,
50
+ ].filter(Boolean).join(" "),
51
+ ];
52
+ const keep = new Set(); // question/options lines are never elided
53
+ if (r.summary)
54
+ lines.push(`summary: ${clip(oneLine(r.summary), c.summary)}`);
55
+ // D5: a BLOCKED block counts its files in the headline but lists them only once the job settles
56
+ if (r.changed.length && r.status !== "BLOCKED") {
57
+ lines.push("changed:", ...r.changed.slice(0, c.changed).map((x) => ` ${x.status} ${x.path}${x.add ? ` +${x.add}` : ""}${x.del ? ` -${x.del}` : ""}`));
58
+ if (r.changed.length > c.changed)
59
+ lines.push(` … +${r.changed.length - c.changed} more`);
60
+ }
61
+ if (r.risks.length)
62
+ lines.push("risks:", ...r.risks.slice(0, c.risks).map((x) => ` - ${clip(oneLine(x), 120)}`));
63
+ if (r.review.length)
64
+ lines.push(`review: ${r.review.slice(0, c.review).map((v) => `${v.path}:${v.start}-${v.end}`).join(", ")}`);
65
+ const asked = r.questions ?? (r.question ? [r.question] : []);
66
+ if (has("question"))
67
+ asked.forEach((question, n) => {
68
+ const q = [`question${asked.length > 1 ? ` ${n + 1}` : ""}: ${oneLine(question.text)}`];
69
+ if (question.options.length)
70
+ q.push("options:", ...question.options.slice(0, 4).map((o, i) => ` ${"abcd"[i]}) ${oneLine(o)}`));
71
+ q.forEach((l) => keep.add(l));
72
+ lines.push(...q);
73
+ });
74
+ if (has("conflict") && r.conflict?.length)
75
+ lines.push("conflict:", ...r.conflict.map((x) => ` ${oneLine(x)}`));
76
+ if (has("error") && r.error?.length) {
77
+ const e = r.error.slice(0, c.error);
78
+ if (e.length < r.error.length)
79
+ e[e.length - 1] = ` … +${r.error.length - e.length + 1} more lines (see log)`;
80
+ lines.push(`error: ${e[0]}`, ...e.slice(1));
81
+ }
82
+ if (has("found") && r.found)
83
+ lines.push(...wrap(`found: ${oneLine(r.found)}`));
84
+ if (has("tried") && r.tried?.length)
85
+ lines.push("tried:", ...r.tried.slice(-c.tried).map((x) => ` - ${oneLine(x)}`));
86
+ if (has("reason") && r.reason)
87
+ lines.push(`reason: ${oneLine(r.reason)}`);
88
+ if (has("missing") && r.missing.length)
89
+ lines.push("missing:", ...r.missing.map((x) => ` - ${oneLine(x)}`));
90
+ if (has("worktree") && r.worktree)
91
+ lines.push(`worktree: ${r.worktree}/`);
92
+ lines.push(`log: .cheapag/jobs/${r.job}/`);
93
+ return lines.map((l) => (keep.has(l) ? l.replaceAll("\t", " ") : fit(l))).join("\n");
94
+ }
95
+ /** D7. A research CONSENT renders through `renderConsent`. */
96
+ export function renderResearch(r) {
97
+ const lines = [
98
+ [
99
+ r.status, r.job, `files_read=${r.filesRead}`, `turns=${r.usage.turns}`, `cost=$${r.usage.costUsd.toFixed(3)}`,
100
+ r.models && r.models.final !== r.models.requested && `model=${r.models.final}`,
101
+ ].filter(Boolean).join(" "),
102
+ ];
103
+ // a scout brief (ticket 23.4): ranked files, the reproduction, dead ends, then the prose
104
+ if (r.refs.length)
105
+ lines.push(`files: ${r.refs.map((v) => `${v.path}:${v.start}${v.end !== v.start ? `-${v.end}` : ""}`).join(", ")}`);
106
+ if (r.repro)
107
+ lines.push(`repro: ${oneLine(r.repro)}`);
108
+ if (r.deadEnds)
109
+ lines.push(`dead ends: ${oneLine(r.deadEnds)}`);
110
+ // prose is word-wrapped, not elided like other long lines
111
+ if (r.answer.trim())
112
+ lines.push("notes:", ...r.answer.trim().split("\n").flatMap((l) => wrap(l.trimEnd())));
113
+ if (r.confidence)
114
+ lines.push(`confidence: ${r.confidence}`);
115
+ if (r.reason)
116
+ lines.push(`reason: ${oneLine(r.reason)}`);
117
+ const n = r.unlanded.length;
118
+ if (n)
119
+ lines.push(`note: ${n} job${n > 1 ? "s" : ""} not yet landed (${r.unlanded.join(", ")}); their changes are not visible to research`);
120
+ lines.push(`log: .cheapag/jobs/${r.job}/`);
121
+ return lines.map(fit).join("\n");
122
+ }
123
+ /**
124
+ * D6: header, notes, integration error, the integration fix job's block, then job blocks by urgency (RUNNING ones as
125
+ * adjacent single lines), then `next:`. The fix job is not a group: it counts in the cost only.
126
+ */
127
+ export function renderBatch(b, jobs, fix) {
128
+ const sorted = [...jobs].sort((x, y) => rank(x.status) - rank(y.status));
129
+ const counts = URGENCY.map((s) => [s, sorted.filter((j) => j.status === s).length]).filter(([, n]) => n);
130
+ const head = [
131
+ [
132
+ `BATCH ${b.batch} groups=${b.groups.length}`, ...counts.map(([s, n]) => `${s}=${n}`),
133
+ `landed=${jobs.filter((j) => j.landed).length}`, `integration=${b.integration}`,
134
+ `cost=$${[...jobs, ...(fix ? [fix] : [])].reduce((n, j) => n + j.usage.costUsd, 0).toFixed(3)}`,
135
+ ].join(" "),
136
+ ];
137
+ if (b.notes.length)
138
+ head.push(`note: ${b.notes.join("; ")}`);
139
+ if (b.integrationError?.length)
140
+ head.push(`error: ${b.integrationError[0]}`, ...b.integrationError.slice(1));
141
+ const chunks = fix ? [renderJob(fix)] : [];
142
+ sorted.forEach((j, i) => {
143
+ if (j.status === "RUNNING" && sorted[i - 1]?.status === "RUNNING")
144
+ chunks[chunks.length - 1] += `\n${renderJob(j)}`;
145
+ else
146
+ chunks.push(renderJob(j));
147
+ });
148
+ const blocked = sorted.find((j) => j.status === "BLOCKED");
149
+ const next = [
150
+ blocked && `reply(job="${blocked.job}", ...)`,
151
+ (b.integration === "running" || sorted.some((j) => j.status === "BLOCKED" || j.status === "RUNNING")) && `jobs(action="wait", job="${b.batch}")`,
152
+ ].filter(Boolean).join(" then ");
153
+ return [head.map(fit).join("\n"), chunks.join("\n\n"), next && fit(`next: ${next}`)].filter(Boolean).join("\n");
154
+ }
155
+ /** D5 CONSENT: no job is created. */
156
+ export const renderConsent = (c) => [`CONSENT repo=${c.repo}`, `send: ${c.send}`, `retention: ${c.retention}`, `approve: ${c.approve}`].map(fit).join("\n");
157
+ const oneLine = (s) => s.trim().replace(/\s*\n\s*/g, " ");
158
+ const clip = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
159
+ /** A line broken at spaces into lines of at most 200 chars (a longer word is left to `fit`). */
160
+ function wrap(line) {
161
+ const out = [""];
162
+ for (const word of line.replaceAll("\t", " ").split(" ")) {
163
+ const last = out[out.length - 1];
164
+ if (last && last.length + 1 + word.length > LINE_MAX)
165
+ out.push(word);
166
+ else
167
+ out[out.length - 1] = last ? `${last} ${word}` : word;
168
+ }
169
+ return out;
170
+ }
171
+ /** D9 line rules: no tabs (2 spaces), at most 200 chars with the middle elided. */
172
+ function fit(line) {
173
+ const l = line.replaceAll("\t", " ");
174
+ if (l.length <= LINE_MAX)
175
+ return l;
176
+ const half = (LINE_MAX - 1) / 2;
177
+ return `${l.slice(0, Math.ceil(half))}…${l.slice(l.length - Math.floor(half))}`;
178
+ }