cheap-agent 0.0.0-stage → 1.0.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/LICENSE +95 -0
- package/README.md +151 -2
- package/dist/allow.js +53 -0
- package/dist/cleanup.js +177 -0
- package/dist/config.js +133 -0
- package/dist/consent.js +129 -0
- package/dist/credentials.js +37 -0
- package/dist/hook.js +263 -0
- package/dist/index.js +16 -0
- package/dist/install.js +74 -0
- package/dist/jobs.js +1170 -0
- package/dist/login.js +223 -0
- package/dist/opencode.js +258 -0
- package/dist/permissions.js +122 -0
- package/dist/plan.js +117 -0
- package/dist/platform.js +52 -0
- package/dist/report.js +178 -0
- package/dist/research.js +264 -0
- package/dist/routing.js +212 -0
- package/dist/rules.js +25 -0
- package/dist/server.js +203 -0
- package/dist/status.js +105 -0
- package/dist/verify.js +121 -0
- package/dist/worker.js +237 -0
- package/dist/workspace.js +278 -0
- package/package.json +53 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/README.md +38 -0
- package/plugin/hooks/hooks.json +40 -0
- package/plugin/skills/allow/SKILL.md +11 -0
- package/plugin/skills/clean/SKILL.md +11 -0
- package/plugin/skills/hook/SKILL.md +11 -0
- package/plugin/skills/plan-writing/SKILL.md +72 -0
- package/plugin/skills/status/SKILL.md +9 -0
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));
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { lstatSync, symlinkSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Platform: everything OS-specific lives here (spec A18). POSIX only in v1; a Windows port swaps in directory
|
|
5
|
+
* junctions, long-path handling and `taskkill /T` behind these same functions.
|
|
6
|
+
*/
|
|
7
|
+
export const UNSUPPORTED_REASON = "Windows not supported yet; use WSL";
|
|
8
|
+
export const UNSUPPORTED = `FAIL reason: ${UNSUPPORTED_REASON}`;
|
|
9
|
+
/** macOS and Linux (WSL reports linux) are supported; native Windows is not yet. */
|
|
10
|
+
export const isSupported = (platform = process.platform) => platform !== "win32";
|
|
11
|
+
/** Links a dependency directory into a worktree. */
|
|
12
|
+
export function link(target, path) {
|
|
13
|
+
symlinkSync(target, path, "dir");
|
|
14
|
+
}
|
|
15
|
+
/** Removes a link made by `link`, never what it points to; does nothing if `path` is not a link. */
|
|
16
|
+
export function removeLink(path) {
|
|
17
|
+
try {
|
|
18
|
+
if (lstatSync(path).isSymbolicLink())
|
|
19
|
+
unlinkSync(path);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// already gone
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Forward-slash form of a relative path, as result blocks show paths. */
|
|
26
|
+
export const normalisePath = (path) => path.split(sep).join("/");
|
|
27
|
+
/** Whether a process is still running (one we may not signal still counts as running). */
|
|
28
|
+
export function isAlive(pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return err.code === "EPERM";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** How OpenCode's per-platform npm packages name this OS, and its binary's file name. */
|
|
38
|
+
export function opencodePlatform() {
|
|
39
|
+
const os = process.platform === "win32" ? "windows" : process.platform;
|
|
40
|
+
return { os, binary: os === "windows" ? "opencode.exe" : "opencode" };
|
|
41
|
+
}
|
|
42
|
+
/** Signals a whole process group (a child spawned `detached`), ignoring one that is already gone. */
|
|
43
|
+
export function killProcessGroup(pid, signal = "SIGTERM") {
|
|
44
|
+
if (!pid)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-pid, signal);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// already gone
|
|
51
|
+
}
|
|
52
|
+
}
|
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
|
+
}
|
package/dist/research.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { consentGate } from "./consent.js";
|
|
6
|
+
import { redact } from "./credentials.js";
|
|
7
|
+
import { records } from "./jobs.js";
|
|
8
|
+
import { holdOpencode } from "./opencode.js";
|
|
9
|
+
import { projectScripts, researchRuleset } from "./permissions.js";
|
|
10
|
+
import { normalisePath } from "./platform.js";
|
|
11
|
+
import { failBlock, renderResearch } from "./report.js";
|
|
12
|
+
import { chain, modelName, recordSpend, runRouted } from "./routing.js";
|
|
13
|
+
import { openSession } from "./worker.js";
|
|
14
|
+
import { createWorktree, projectRoot, removeWorktree } from "./workspace.js";
|
|
15
|
+
/**
|
|
16
|
+
* Research: one question about the codebase, answered by a worker that reads, searches and runs test commands but
|
|
17
|
+
* never edits (agent `cheapag-research`, ruleset `researchRuleset`), in a throwaway worktree of the developer's current
|
|
18
|
+
* state (uncommitted work included) so a command can't touch their tree. It runs alongside jobs. The worker reports a
|
|
19
|
+
* scout brief (ticket 23.4) in a fenced `cheapag-research` block in its final text; a claimed reproduction is checked
|
|
20
|
+
* against the commands it actually ran.
|
|
21
|
+
*/
|
|
22
|
+
export const RESEARCH_AGENT = "cheapag-research";
|
|
23
|
+
export const MAX_WORDS = 600;
|
|
24
|
+
const DEFAULT_WORDS = 200;
|
|
25
|
+
/** At most this many refs: the refs line must stay within the 200-char line rule. */
|
|
26
|
+
const MAX_REFS = 5;
|
|
27
|
+
export async function research(input, ctx) {
|
|
28
|
+
const root = projectRoot(process.cwd());
|
|
29
|
+
// first: an unapproved repo gets CONSENT before any record, OpenCode boot or provider call
|
|
30
|
+
const gate = consentGate(root, ctx.provider.provider);
|
|
31
|
+
if ("block" in gate)
|
|
32
|
+
return gate.block;
|
|
33
|
+
const scope = [];
|
|
34
|
+
for (const path of (input.paths ?? "").split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
35
|
+
const rel = relative(root, join(root, path));
|
|
36
|
+
if (isAbsolute(path) || rel === ".." || rel.startsWith("../"))
|
|
37
|
+
return failBlock(`path ${path} must be inside the repository, relative to its root`);
|
|
38
|
+
if (!existsSync(join(root, path)))
|
|
39
|
+
return failBlock(`path ${path} not found`);
|
|
40
|
+
if (rel)
|
|
41
|
+
scope.push(normalisePath(rel)); // "." is the whole repo: no scope
|
|
42
|
+
}
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = loadConfig(root);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return failBlock(err.message);
|
|
49
|
+
}
|
|
50
|
+
const { limits } = config;
|
|
51
|
+
const maxWords = Math.min(input.max_words ?? DEFAULT_WORDS, MAX_WORDS);
|
|
52
|
+
const opencode = await ctx.opencode(gate.route);
|
|
53
|
+
const release = holdOpencode();
|
|
54
|
+
const { id, dir } = newResearch(root);
|
|
55
|
+
const write = (name, value) => writeFileSync(join(dir, name), redact(typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n"));
|
|
56
|
+
// the research model, then its fallbacks, each only if the repo's allowlist kept it (src/consent.ts)
|
|
57
|
+
const models = chain(gate.route.research ?? gate.route.implement, gate.route.researchFallbacks);
|
|
58
|
+
const startedAt = new Date();
|
|
59
|
+
write("request.json", { ...input, scope, maxWords, models: [models.first, ...models.rest].map(modelName), startedAt });
|
|
60
|
+
const prompt = [
|
|
61
|
+
`Question: ${input.question}`,
|
|
62
|
+
scope.length && `Scope: look only under ${scope.join(", ")}; cite only files there.`,
|
|
63
|
+
REPRO_RULE,
|
|
64
|
+
`Answer in at most ${maxWords} words.`,
|
|
65
|
+
// repeated here because real models skipped the agent prompt's format (seen live): without it a right answer is UNSURE
|
|
66
|
+
BRIEF_FORMAT,
|
|
67
|
+
].filter(Boolean).join("\n");
|
|
68
|
+
const files = new Set();
|
|
69
|
+
const ran = []; // the commands the worker actually ran: a claimed reproduction must be one of them
|
|
70
|
+
const spent = { turns: 0, costUsd: 0 };
|
|
71
|
+
let text = "";
|
|
72
|
+
let error;
|
|
73
|
+
let budget;
|
|
74
|
+
// set from event callbacks: keep TS from narrowing it to undefined
|
|
75
|
+
let stopped = undefined;
|
|
76
|
+
let worktree;
|
|
77
|
+
try {
|
|
78
|
+
worktree = createWorktree(root, id, config.link_dirs);
|
|
79
|
+
const { scripts, exact } = projectScripts(worktree.path);
|
|
80
|
+
const permission = researchRuleset({ scripts, exact: [...exact, ...(config.test_cmd ? [config.test_cmd] : [])], allowCommands: config.allow_commands });
|
|
81
|
+
const halt = new AbortController();
|
|
82
|
+
const stop = (why) => {
|
|
83
|
+
stopped ??= why;
|
|
84
|
+
halt.abort();
|
|
85
|
+
};
|
|
86
|
+
const timer = setTimeout(() => stop({ status: "FAIL", reason: `minute cap (${limits.max_minutes} min) reached` }), limits.max_minutes * 60_000);
|
|
87
|
+
const session = await openSession({
|
|
88
|
+
opencode, directory: worktree.path, model: models.first, agent: RESEARCH_AGENT, permission, signal: halt.signal,
|
|
89
|
+
onEvent: (event, turns) => {
|
|
90
|
+
appendFileSync(join(dir, "events.jsonl"), redact(JSON.stringify(event)) + "\n");
|
|
91
|
+
if (event.type === "message.part.updated" && event.properties.part.type === "tool") {
|
|
92
|
+
const { tool, state } = event.properties.part;
|
|
93
|
+
if (state.status === "completed" && tool === "read")
|
|
94
|
+
files.add(String(state.input.filePath));
|
|
95
|
+
if (state.status === "completed" && tool === "bash")
|
|
96
|
+
ran.push(String(state.input.command ?? ""));
|
|
97
|
+
if (state.status === "completed" || state.status === "error") {
|
|
98
|
+
ctx.progress(turns, `turn ${turns}: ${tool}`);
|
|
99
|
+
if (turns >= limits.max_turns)
|
|
100
|
+
stop({ status: "FAIL", reason: `turn cap (${limits.max_turns}) reached` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (event.type === "message.updated" && session.totals().usage.costUsd >= limits.max_cost_usd) {
|
|
104
|
+
stop({ status: "BUDGET", reason: `job cap ($${limits.max_cost_usd.toFixed(2)}) reached` });
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
// a provider limit continues the same session on the next fallback (src/routing.ts), never waiting it out
|
|
110
|
+
({ text, error, budget } = await runRouted(session, prompt, models, ctx.provider.provider));
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
const totals = session.totals();
|
|
115
|
+
spent.turns = totals.turns;
|
|
116
|
+
spent.costUsd = totals.usage.costUsd;
|
|
117
|
+
try {
|
|
118
|
+
recordSpend(totals.spend); // research draws on the same Go windows as jobs
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// the ledger only steers later escalations: never fail an answer over it
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
error = err.message;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
release(); // the session is done: a config change may reboot OpenCode now
|
|
130
|
+
if (worktree)
|
|
131
|
+
removeWorktree(root, worktree);
|
|
132
|
+
}
|
|
133
|
+
const answer = stopped ? { status: stopped.status, answer: "", refs: [] }
|
|
134
|
+
: budget ? { status: "BUDGET", answer: "", refs: [] }
|
|
135
|
+
: error ? { status: "FAIL", answer: "", refs: [] }
|
|
136
|
+
: verifyRepro(parseAnswer(text, root, scope, maxWords), ran) ?? { status: "FAIL", answer: "", refs: [] };
|
|
137
|
+
const reason = stopped?.reason
|
|
138
|
+
?? budget
|
|
139
|
+
?? (error ? `worker error: ${error}` : answer.status === "FAIL" ? "the research worker gave no answer" : undefined);
|
|
140
|
+
const final = models.current;
|
|
141
|
+
const record = {
|
|
142
|
+
format: "cheapag/1", job: id, kind: "research", provider: ctx.provider.provider, question: input.question, scope,
|
|
143
|
+
...answer, reason, filesRead: files.size, unlanded: unlandedJobs(root),
|
|
144
|
+
models: { requested: modelName(models.first), final: modelName(final) },
|
|
145
|
+
usage: spent,
|
|
146
|
+
timing: { startedAt, endedAt: new Date(), ms: Date.now() - startedAt.getTime() },
|
|
147
|
+
};
|
|
148
|
+
const block = renderResearch(record);
|
|
149
|
+
// research.json, not result.json: `jobs list` reads only job records
|
|
150
|
+
write("research.json", record);
|
|
151
|
+
write("result.txt", block + "\n");
|
|
152
|
+
return block;
|
|
153
|
+
}
|
|
154
|
+
/** Research verifies before it claims (ticket 23.4: unverified handoffs inject false confidence). */
|
|
155
|
+
export const REPRO_RULE = "If the question is about a failure or wrong behaviour, reproduce it: run the relevant test or command and report exactly what it showed. Never claim a reproduction you did not run.";
|
|
156
|
+
export const BRIEF_FORMAT = "End your reply with exactly one fenced ```cheapag-research block: status (answer or unsure), confidence (high, medium or low), files (path:start-end, most relevant first, comma-separated), repro (`command` → what it showed, or not attempted), dead ends (what you ruled out, or none), then notes: and your prose.";
|
|
157
|
+
/**
|
|
158
|
+
* A claimed reproduction stands only if its `command` is one the worker ran; otherwise it is marked unverified and the
|
|
159
|
+
* confidence drops to low, so Claude doesn't build on a guess.
|
|
160
|
+
*/
|
|
161
|
+
export function verifyRepro(answer, ran) {
|
|
162
|
+
const repro = answer?.repro?.trim();
|
|
163
|
+
if (!answer || !repro || /^not attempted|^none$/i.test(repro))
|
|
164
|
+
return answer && { ...answer, repro: repro || undefined };
|
|
165
|
+
const norm = (c) => c.replace(/\s+/g, " ").trim();
|
|
166
|
+
const claimed = norm(repro.match(/`([^`]+)`/)?.[1] ?? "");
|
|
167
|
+
const verified = !!claimed && ran.some((c) => norm(c).includes(claimed) || claimed.includes(norm(c)));
|
|
168
|
+
return verified ? { ...answer, repro } : { ...answer, repro: `unverified (not run): ${repro}`, confidence: "low" };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Jobs whose changes research cannot see (D7 note): not landed, and either still in flight (RUNNING or BLOCKED: their
|
|
172
|
+
* changes may land later) or holding changes in a worktree that still exists (PARTIAL, FAIL, CONFLICT, TIMEOUT,
|
|
173
|
+
* BUDGET, CANCELLED with edits; `land apply` could still land them). A discarded or cleaned-up job, or one that changed
|
|
174
|
+
* nothing, has nothing to miss. Oldest first.
|
|
175
|
+
*/
|
|
176
|
+
export function unlandedJobs(root) {
|
|
177
|
+
return records(root)
|
|
178
|
+
.filter((r) => !r.landed && (r.status === "RUNNING" || r.status === "BLOCKED" || (!!r.worktree && !!r.changed?.length && existsSync(join(root, r.worktree)))))
|
|
179
|
+
.sort((a, b) => String(a.timing?.startedAt ?? "").localeCompare(String(b.timing?.startedAt ?? "")))
|
|
180
|
+
.map((r) => r.job);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The worker's last fenced `cheapag-research` block, parsed leniently. Without one, its final text is the answer,
|
|
184
|
+
* UNSURE with low confidence (the worker never rated it). Nothing at all → undefined.
|
|
185
|
+
*/
|
|
186
|
+
export function parseAnswer(text, root, scope, maxWords) {
|
|
187
|
+
const block = [...text.matchAll(/```cheapag-research[^\n]*\n([\s\S]*?)```/g)].at(-1)?.[1];
|
|
188
|
+
if (!block)
|
|
189
|
+
return text.trim() ? { status: "UNSURE", answer: capWords(text.trim(), maxWords), refs: refs(text, root, scope), confidence: "low" } : undefined;
|
|
190
|
+
const fields = {};
|
|
191
|
+
let key;
|
|
192
|
+
for (const line of block.split("\n")) {
|
|
193
|
+
const kv = line.match(/^\s*(status|confidence|refs|files|repro|dead[ _]ends|answer|notes):\s*(.*)$/i);
|
|
194
|
+
// files/notes are the brief's names (ticket 23.4); refs/answer still read
|
|
195
|
+
if (kv)
|
|
196
|
+
fields[(key = { files: "refs", notes: "answer", "dead ends": "deadEnds", dead_ends: "deadEnds" }[kv[1].toLowerCase()] ?? kv[1].toLowerCase())] = kv[2];
|
|
197
|
+
else if (key === "answer")
|
|
198
|
+
fields.answer += `\n${line}`; // the answer is prose: it may span lines
|
|
199
|
+
}
|
|
200
|
+
const answer = (fields.answer ?? "").trim();
|
|
201
|
+
if (!answer)
|
|
202
|
+
return undefined;
|
|
203
|
+
const listed = refs(fields.refs ?? "", root, scope);
|
|
204
|
+
const confidence = fields.confidence?.trim().toLowerCase().match(/^(high|medium|low)/)?.[1];
|
|
205
|
+
return {
|
|
206
|
+
status: /^unsure/i.test(fields.status?.trim() ?? "") ? "UNSURE" : "ANSWER",
|
|
207
|
+
answer: capWords(answer, maxWords),
|
|
208
|
+
// refs from the refs line; failing that, any cited in the prose
|
|
209
|
+
refs: listed.length ? listed : refs(answer, root, scope),
|
|
210
|
+
confidence: confidence ?? "low",
|
|
211
|
+
repro: fields.repro?.trim() || undefined,
|
|
212
|
+
deadEnds: fields.deadEnds?.trim() && !/^none\.?$/i.test(fields.deadEnds.trim()) ? fields.deadEnds.trim() : undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Validated `path:line(-line)` refs from free text, at most 5: the file exists in the main checkout, inside the repo
|
|
217
|
+
* (and the scope, when there is one), and has those lines. Absolute paths inside the repo are made relative.
|
|
218
|
+
*/
|
|
219
|
+
function refs(text, root, scope) {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const [, path, from, to] of text.matchAll(/([\w./@-]+):(\d+)(?:-(\d+))?/g)) {
|
|
222
|
+
const abs = isAbsolute(path) ? path : join(root, path);
|
|
223
|
+
const rel = normalisePath(relative(root, abs));
|
|
224
|
+
const start = Number(from);
|
|
225
|
+
const end = Number(to ?? from);
|
|
226
|
+
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || start < 1 || end < start)
|
|
227
|
+
continue;
|
|
228
|
+
if (scope.length && !scope.some((s) => rel === s || rel.startsWith(`${s}/`)))
|
|
229
|
+
continue;
|
|
230
|
+
if (out.some((r) => r.path === rel && r.start === start && r.end === end))
|
|
231
|
+
continue;
|
|
232
|
+
try {
|
|
233
|
+
if (!statSync(abs).isFile())
|
|
234
|
+
continue;
|
|
235
|
+
const content = readFileSync(abs, "utf8");
|
|
236
|
+
const lines = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
237
|
+
if (end > lines)
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
continue; // no such file
|
|
242
|
+
}
|
|
243
|
+
out.push({ path: rel, start, end });
|
|
244
|
+
if (out.length === MAX_REFS)
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** The text cut after `max` words, line breaks kept. */
|
|
250
|
+
function capWords(text, max) {
|
|
251
|
+
const words = [...text.matchAll(/\S+/g)];
|
|
252
|
+
return words.length <= max ? text : `${text.slice(0, words[max].index).trimEnd()} …`;
|
|
253
|
+
}
|
|
254
|
+
/** A fresh `r` + 4 base36 id whose folder (beside the job folders, D7 `log:`) does not exist yet. */
|
|
255
|
+
function newResearch(root) {
|
|
256
|
+
for (;;) {
|
|
257
|
+
const id = `r${randomBytes(4).readUInt32BE().toString(36).slice(-4).padStart(4, "0")}`;
|
|
258
|
+
const dir = join(root, ".cheapag", "jobs", id);
|
|
259
|
+
if (existsSync(dir))
|
|
260
|
+
continue;
|
|
261
|
+
mkdirSync(dir, { recursive: true });
|
|
262
|
+
return { id, dir };
|
|
263
|
+
}
|
|
264
|
+
}
|