backpass 0.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/LICENSE +21 -0
- package/README.md +406 -0
- package/bin/backpass.js +4 -0
- package/package.json +62 -0
- package/src/acpx.js +576 -0
- package/src/agents.js +389 -0
- package/src/analyze.js +289 -0
- package/src/apply/lavish.js +128 -0
- package/src/apply/terminal.js +119 -0
- package/src/apply/writer.js +101 -0
- package/src/bootstrap.js +74 -0
- package/src/cli.js +261 -0
- package/src/commands/analyze.js +88 -0
- package/src/commands/apply.js +103 -0
- package/src/commands/bootstrap.js +172 -0
- package/src/commands/init.js +59 -0
- package/src/commands/propose.js +136 -0
- package/src/commands/run.js +95 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/status.js +143 -0
- package/src/commands/usage.js +25 -0
- package/src/config.js +249 -0
- package/src/diff.js +305 -0
- package/src/discovery/adapters/claude.js +77 -0
- package/src/discovery/adapters/codex.js +162 -0
- package/src/discovery/adapters/cursor-cli.js +109 -0
- package/src/discovery/adapters/cursor-ide.js +130 -0
- package/src/discovery/adapters/grok.js +107 -0
- package/src/discovery/adapters/opencode.js +151 -0
- package/src/discovery/adapters/pi.js +87 -0
- package/src/discovery/adapters/shared.js +195 -0
- package/src/discovery/adapters/sqlite.js +50 -0
- package/src/discovery/association.js +100 -0
- package/src/discovery/index.js +226 -0
- package/src/discovery/self.js +62 -0
- package/src/distill.js +182 -0
- package/src/fold.js +214 -0
- package/src/gap-ledger.js +174 -0
- package/src/logger.js +74 -0
- package/src/memory.js +244 -0
- package/src/progress.js +29 -0
- package/src/prompts/analysis.md +48 -0
- package/src/prompts/annotate.md +48 -0
- package/src/prompts/synthesis.md +98 -0
- package/src/prompts.js +36 -0
- package/src/proposal.js +430 -0
- package/src/redact.js +36 -0
- package/src/repo.js +118 -0
- package/src/sample.js +99 -0
- package/src/skills.js +207 -0
- package/src/state.js +202 -0
- package/src/subprocess.js +47 -0
- package/src/synthesize.js +287 -0
- package/src/tokens.js +48 -0
- package/src/tui/index.js +336 -0
- package/src/tui/render.js +487 -0
- package/src/tui/term.js +130 -0
- package/src/tui/theme.js +111 -0
- package/src/workspace.js +162 -0
- package/templates/apply.html +928 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { UserError, color, info, warn } from "../logger.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The apply surface (captain decision 4).
|
|
10
|
+
*
|
|
11
|
+
* A static HTML template ships in the package. The CLI injects exactly one JSON payload
|
|
12
|
+
* as `window.__BACKPASS_PROPOSAL__` and serves the result through `lavish-axi`, so the
|
|
13
|
+
* review surface is instant, deterministic, and identical every run - no model
|
|
14
|
+
* regenerates it. Decisions come back as one structured vector through `lavish-axi poll`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "templates", "apply.html");
|
|
18
|
+
|
|
19
|
+
export const LAVISH_BIN = process.env.BACKPASS_LAVISH_BIN || "lavish-axi";
|
|
20
|
+
|
|
21
|
+
/** Inline a JSON payload safely: `</script>` inside data must not close the tag. */
|
|
22
|
+
export function injectPayload(template, payload, toolVersion) {
|
|
23
|
+
const json = JSON.stringify({ ...payload, toolVersion })
|
|
24
|
+
.replace(/</g, "\\u003c")
|
|
25
|
+
.replace(/\u2028/g, "\\u2028")
|
|
26
|
+
.replace(/\u2029/g, "\\u2029");
|
|
27
|
+
const tag = `<script>window.__BACKPASS_PROPOSAL__ = ${json};</script>`;
|
|
28
|
+
if (!template.includes("</head>")) return `${tag}\n${template}`;
|
|
29
|
+
return template.replace("</head>", `${tag}\n</head>`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function renderApplySurface(proposal, state, toolVersion) {
|
|
33
|
+
if (!fs.existsSync(TEMPLATE)) {
|
|
34
|
+
throw new UserError(`apply template missing at ${TEMPLATE}`, "reinstall backpass");
|
|
35
|
+
}
|
|
36
|
+
const html = injectPayload(fs.readFileSync(TEMPLATE, "utf8"), proposal, toolVersion);
|
|
37
|
+
const target = path.join(state.applyDir, "apply.html");
|
|
38
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
39
|
+
fs.writeFileSync(target, html);
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runLavish(args, { inherit = false } = {}) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(LAVISH_BIN, args, { stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"] });
|
|
46
|
+
let stdout = "";
|
|
47
|
+
let stderr = "";
|
|
48
|
+
if (!inherit) {
|
|
49
|
+
child.stdout.on("data", (d) => {
|
|
50
|
+
stdout += d;
|
|
51
|
+
});
|
|
52
|
+
child.stderr.on("data", (d) => {
|
|
53
|
+
stderr += d;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
child.on("error", (err) => resolve({ code: null, stdout, stderr, spawnError: err }));
|
|
57
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the decision vector the surface queues back:
|
|
63
|
+
* `BACKPASS_DECISIONS e1=accepted e2=rejected e3=accepted`
|
|
64
|
+
* Parsing is tolerant because the text travels through a human-facing comment box.
|
|
65
|
+
*/
|
|
66
|
+
export function parseDecisions(text, editIds) {
|
|
67
|
+
const decisions = {};
|
|
68
|
+
const pattern = /\b(e\d+)\s*=\s*(accepted|rejected|accept|reject)\b/gi;
|
|
69
|
+
let match = pattern.exec(text || "");
|
|
70
|
+
while (match) {
|
|
71
|
+
const id = match[1].toLowerCase();
|
|
72
|
+
if (editIds.includes(id)) {
|
|
73
|
+
decisions[id] = match[2].toLowerCase().startsWith("accept") ? "accepted" : "rejected";
|
|
74
|
+
}
|
|
75
|
+
match = pattern.exec(text || "");
|
|
76
|
+
}
|
|
77
|
+
return Object.keys(decisions).length ? decisions : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function openApplySurface(file) {
|
|
81
|
+
const result = await runLavish([file]);
|
|
82
|
+
if (result.spawnError && result.spawnError.code === "ENOENT") {
|
|
83
|
+
throw new UserError(
|
|
84
|
+
`${LAVISH_BIN} not found on PATH`,
|
|
85
|
+
"install lavish-axi, or run `backpass apply --no-ui` for the terminal fallback",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (result.code !== 0) {
|
|
89
|
+
throw new UserError(`${LAVISH_BIN} failed to open the apply surface`, result.stderr.trim().slice(0, 400));
|
|
90
|
+
}
|
|
91
|
+
const url = /(https?:\/\/\S+)/.exec(`${result.stdout}\n${result.stderr}`);
|
|
92
|
+
return url ? url[1] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Long-poll for the human's decision vector. `lavish-axi poll` blocks until the reviewer
|
|
97
|
+
* sends feedback, so this is intentionally a foreground wait.
|
|
98
|
+
*/
|
|
99
|
+
export async function pollDecisions(file, editIds) {
|
|
100
|
+
info(
|
|
101
|
+
`${color.dim("waiting for your decisions in the browser (Ctrl-C to abort; nothing is written until you send)")}`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
for (;;) {
|
|
105
|
+
const result = await runLavish(["poll", file]);
|
|
106
|
+
if (result.spawnError) {
|
|
107
|
+
throw new UserError(`${LAVISH_BIN} poll failed: ${result.spawnError.message}`);
|
|
108
|
+
}
|
|
109
|
+
const text = `${result.stdout}\n${result.stderr}`;
|
|
110
|
+
if (result.code !== 0) {
|
|
111
|
+
throw new UserError("lavish-axi poll exited unexpectedly", text.trim().slice(0, 400));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const decisions = parseDecisions(text, editIds);
|
|
115
|
+
if (decisions) return decisions;
|
|
116
|
+
|
|
117
|
+
if (/session\s+(ended|closed)/i.test(text)) {
|
|
118
|
+
warn("review session ended without a decision vector - nothing applied");
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// Feedback that was not a decision vector (a comment, a layout report): keep waiting.
|
|
122
|
+
info(`${color.dim("received feedback without a decision vector; still waiting")}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function closeApplySurface(file) {
|
|
127
|
+
await runLavish(["end", file]);
|
|
128
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
|
|
4
|
+
import { UserError, color } from "../logger.js";
|
|
5
|
+
import { formatTokens } from "../tokens.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `--no-ui` fallback: the same ACCEPT/REJECT decision, one edit at a time, for
|
|
9
|
+
* machines with no browser (or reviewers who would rather stay in the terminal).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Measured edits carry display lines per hunk; a legacy edit shows its find/replace. */
|
|
13
|
+
export function diffLinesOf(edit) {
|
|
14
|
+
if (Array.isArray(edit.hunks)) {
|
|
15
|
+
return edit.hunks.flatMap((hunk, i) => [...(i > 0 ? [{ type: "gap", text: "" }] : []), ...(hunk.lines || [])]);
|
|
16
|
+
}
|
|
17
|
+
return [
|
|
18
|
+
...(edit.find || "")
|
|
19
|
+
.split("\n")
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((text) => ({ type: "del", text })),
|
|
22
|
+
...(edit.replace || "")
|
|
23
|
+
.split("\n")
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((text) => ({ type: "ins", text })),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderDiff(edit) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
for (const line of diffLinesOf(edit)) {
|
|
32
|
+
if (line.type === "del") lines.push(color.red(` - ${line.text}`));
|
|
33
|
+
else if (line.type === "ins") lines.push(color.green(` + ${line.text}`));
|
|
34
|
+
else if (line.type === "gap") lines.push(color.dim(" ..."));
|
|
35
|
+
else lines.push(color.dim(` ${line.text}`));
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderEdit(edit, index, total) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const kind = edit.kind === "extract" ? "EXTRACT -> SKILL" : edit.kind.toUpperCase();
|
|
43
|
+
const delta = edit.deltaTokens || 0;
|
|
44
|
+
const deltaText = `${delta > 0 ? "+" : ""}${formatTokens(delta)} tok${edit.targetsMemoryFile ? "" : " (not always-loaded)"}`;
|
|
45
|
+
|
|
46
|
+
out.push("");
|
|
47
|
+
out.push(`${color.bold(`[${index + 1}/${total}] ${kind}`)} ${color.dim(deltaText)}`);
|
|
48
|
+
out.push(` ${edit.title}`);
|
|
49
|
+
out.push(` ${color.dim(`file: ${edit.file}`)}`);
|
|
50
|
+
if (edit.rationale) out.push(` ${color.dim(edit.rationale)}`);
|
|
51
|
+
out.push("");
|
|
52
|
+
out.push(renderDiff(edit));
|
|
53
|
+
|
|
54
|
+
if (edit.kind === "extract" && edit.skill) {
|
|
55
|
+
out.push("");
|
|
56
|
+
out.push(color.dim(` new skill: ${edit.skill.path}`));
|
|
57
|
+
out.push(color.dim(` description: ${edit.skill.description}`));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (edit.evidence?.length) {
|
|
61
|
+
out.push("");
|
|
62
|
+
out.push(color.dim(` evidence (${edit.transcripts} transcript(s)):`));
|
|
63
|
+
for (const quote of edit.evidence.slice(0, 4)) {
|
|
64
|
+
const mark = quote.polarity === "positive" ? color.green("+") : color.red("-");
|
|
65
|
+
out.push(` ${mark} "${quote.text.replace(/\s+/g, " ").slice(0, 200)}"`);
|
|
66
|
+
out.push(` ${color.dim(quote.source)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return out.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function reviewInTerminal(proposal) {
|
|
74
|
+
if (!stdin.isTTY) {
|
|
75
|
+
throw new UserError(
|
|
76
|
+
"terminal review needs an interactive terminal (stdin is not a TTY)",
|
|
77
|
+
"run `backpass apply` without --no-ui to review in the browser, or use --dry-run to see the proposal",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
82
|
+
const decisions = {};
|
|
83
|
+
// Ctrl-D at a prompt closes the interface; treat it as "quit", never as a hang.
|
|
84
|
+
let closed = false;
|
|
85
|
+
rl.on("close", () => {
|
|
86
|
+
closed = true;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
console.error(
|
|
91
|
+
`\n${color.bold("backpass apply")} ${color.dim(
|
|
92
|
+
`· ${proposal.repo.name} · ${proposal.memoryFile.path} · ${proposal.edits.length} proposed edit(s)`,
|
|
93
|
+
)}`,
|
|
94
|
+
);
|
|
95
|
+
console.error(
|
|
96
|
+
color.dim(
|
|
97
|
+
`budget: ${formatTokens(proposal.budget.current)} -> ${formatTokens(proposal.budget.projected)} / ${formatTokens(
|
|
98
|
+
proposal.budget.capTokens,
|
|
99
|
+
)} tok if all accepted`,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
for (const [index, edit] of proposal.edits.entries()) {
|
|
104
|
+
console.error(renderEdit(edit, index, proposal.edits.length));
|
|
105
|
+
let answer = "";
|
|
106
|
+
while (!["a", "r", "q"].includes(answer)) {
|
|
107
|
+
const reply = await rl.question(`\n ${color.cyan("[a]ccept / [r]eject / [q]uit")} > `).catch(() => null);
|
|
108
|
+
if (reply === null || closed) return null;
|
|
109
|
+
answer = reply.trim().toLowerCase().slice(0, 1);
|
|
110
|
+
}
|
|
111
|
+
if (answer === "q") return null;
|
|
112
|
+
decisions[edit.id] = answer === "a" ? "accepted" : "rejected";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return decisions;
|
|
116
|
+
} finally {
|
|
117
|
+
rl.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { applyEdit } from "../proposal.js";
|
|
5
|
+
import { budgetStatus } from "../tokens.js";
|
|
6
|
+
import { recordRejection } from "../state.js";
|
|
7
|
+
import { writeSkill } from "../skills.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The only place in backpass that writes to the repo.
|
|
11
|
+
*
|
|
12
|
+
* Everything upstream is read-only analysis; a run only changes the weights here, after
|
|
13
|
+
* a human accepted specific edits. Writes are grouped per file so a memory file is
|
|
14
|
+
* rewritten once, atomically, rather than edit by edit.
|
|
15
|
+
*/
|
|
16
|
+
export function applyDecisions({ proposal, decisions, repo, state, config, dryRun = false }) {
|
|
17
|
+
const accepted = proposal.edits.filter((e) => decisions[e.id] === "accepted");
|
|
18
|
+
const rejected = proposal.edits.filter((e) => decisions[e.id] === "rejected");
|
|
19
|
+
|
|
20
|
+
const byFile = new Map();
|
|
21
|
+
const results = {
|
|
22
|
+
written: [],
|
|
23
|
+
skills: [],
|
|
24
|
+
failed: [],
|
|
25
|
+
warnings: [],
|
|
26
|
+
accepted: accepted.length,
|
|
27
|
+
rejected: rejected.length,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (const edit of accepted) {
|
|
31
|
+
if (!byFile.has(edit.file)) byFile.set(edit.file, []);
|
|
32
|
+
byFile.get(edit.file).push(edit);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [relative, edits] of byFile) {
|
|
36
|
+
const absolute = path.join(repo.root, relative);
|
|
37
|
+
if (!fs.existsSync(absolute)) {
|
|
38
|
+
results.failed.push({ file: relative, error: "file does not exist" });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const before = fs.readFileSync(absolute, "utf8");
|
|
43
|
+
let text = before;
|
|
44
|
+
const applied = [];
|
|
45
|
+
|
|
46
|
+
for (const edit of edits) {
|
|
47
|
+
try {
|
|
48
|
+
text = applyEdit(text, edit);
|
|
49
|
+
applied.push(edit.id);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
results.failed.push({ file: relative, edit: edit.id, error: err.message });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (text === before) continue;
|
|
56
|
+
|
|
57
|
+
const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
|
|
58
|
+
|
|
59
|
+
if (!dryRun) fs.writeFileSync(absolute, text);
|
|
60
|
+
results.written.push({ file: relative, edits: applied, budget, dryRun });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const edit of accepted) {
|
|
64
|
+
if (edit.kind !== "extract" || !edit.skill) continue;
|
|
65
|
+
try {
|
|
66
|
+
const layout = dryRun ? { created: [], warnings: [] } : writeSkill(repo.root, edit.skill);
|
|
67
|
+
results.skills.push({ path: edit.skill.path, dryRun, created: layout.created });
|
|
68
|
+
for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
results.failed.push({ file: edit.skill.path, edit: edit.id, error: err.message });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Rejections are remembered so the same edit is not re-proposed without new evidence.
|
|
75
|
+
if (!dryRun && rejected.length) {
|
|
76
|
+
const rejections = state.readRejections();
|
|
77
|
+
for (const edit of rejected) recordRejection(edit, rejections);
|
|
78
|
+
state.writeRejections(rejections);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Seed a repo that has no memory file: the canonical file plus the pointer. Each file
|
|
86
|
+
* is created only if absent - bootstrap never overwrites, so a pre-existing file (say a
|
|
87
|
+
* CLAUDE.md the user wrote while a run was in flight) is reported, not replaced.
|
|
88
|
+
*/
|
|
89
|
+
export function writeBootstrapFiles(repoRoot, files) {
|
|
90
|
+
const results = { written: [], skipped: [] };
|
|
91
|
+
for (const { path: relative, text } of files) {
|
|
92
|
+
const absolute = path.join(repoRoot, relative);
|
|
93
|
+
if (fs.existsSync(absolute)) {
|
|
94
|
+
results.skipped.push({ file: relative, reason: "already exists" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
fs.writeFileSync(absolute, text, { flag: "wx" });
|
|
98
|
+
results.written.push({ file: relative, bytes: Buffer.byteLength(text, "utf8") });
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
package/src/bootstrap.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { parseMemoryUnits } from "./memory.js";
|
|
4
|
+
import { sha256 } from "./state.js";
|
|
5
|
+
import { estimateTokens } from "./tokens.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The starter memory file backpass seeds when a repo has none.
|
|
9
|
+
*
|
|
10
|
+
* Bootstrap = a minimal skeleton + the normal backward pass. The skeleton is deliberately
|
|
11
|
+
* tiny (purpose, an empty `## Learnings` section, a self-governance section): every
|
|
12
|
+
* starter line is paid on every session, so nothing generic is pre-filled. What the
|
|
13
|
+
* repo's own transcripts teach is layered on top by the same analyze -> fold ->
|
|
14
|
+
* synthesize pipeline a normal run uses, with this text standing in as the "current
|
|
15
|
+
* weights"; evidence-backed entries land under `## Learnings`.
|
|
16
|
+
*
|
|
17
|
+
* The canonical file is AGENTS.md; CLAUDE.md is the `@AGENTS.md` pointer so both harness
|
|
18
|
+
* families read one source of truth. Writing happens in `src/apply/writer.js`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const CANONICAL_MEMORY_FILE = "AGENTS.md";
|
|
22
|
+
export const POINTER_MEMORY_FILE = "CLAUDE.md";
|
|
23
|
+
|
|
24
|
+
/** The convention's two-line pointer form; `isPointerTo` recognizes it round-trip. */
|
|
25
|
+
export function renderPointer(target = CANONICAL_MEMORY_FILE) {
|
|
26
|
+
return `<!-- Points Claude at ${target} via import; edit ${target}, not this file. -->\n@${target}\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a bootstrap creates for a config: the canonical file is the first configured
|
|
31
|
+
* memory file (a user override still wins), and CLAUDE.md becomes a pointer to it unless
|
|
32
|
+
* CLAUDE.md itself is the canonical file.
|
|
33
|
+
*/
|
|
34
|
+
export function bootstrapTargets(memoryFiles) {
|
|
35
|
+
const canonical = memoryFiles[0] || CANONICAL_MEMORY_FILE;
|
|
36
|
+
const pointer =
|
|
37
|
+
path.basename(canonical) === POINTER_MEMORY_FILE && path.dirname(canonical) === "." ? null : POINTER_MEMORY_FILE;
|
|
38
|
+
return { canonical, pointer };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MAINTAINING_SECTION = `## Maintaining this file
|
|
42
|
+
|
|
43
|
+
Keep this file for knowledge useful to almost every future agent session in this project.
|
|
44
|
+
Do not repeat what the codebase already shows; point to the authoritative file or command instead.
|
|
45
|
+
Prefer rewriting or pruning existing entries over appending new ones.
|
|
46
|
+
When updating this file, preserve this bar for all agents and keep entries concise.
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/** Render the default AGENTS.md for a repo. Deterministic for a given checkout. */
|
|
50
|
+
export function renderStarterMemory({ repo }) {
|
|
51
|
+
return `# Project agent memory
|
|
52
|
+
|
|
53
|
+
${repo.name}: this file is the always-loaded memory for agents working in this repo.
|
|
54
|
+
It is kept short on purpose - every line here is paid on every session.
|
|
55
|
+
|
|
56
|
+
## Learnings
|
|
57
|
+
|
|
58
|
+
- None recorded yet. backpass adds evidence-backed entries here from real sessions.
|
|
59
|
+
|
|
60
|
+
${MAINTAINING_SECTION}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An in-memory memory file object in the shape `readMemoryFile` returns, never on disk. */
|
|
64
|
+
export function starterMemoryFile(repo, relativePath = CANONICAL_MEMORY_FILE) {
|
|
65
|
+
const text = renderStarterMemory({ repo });
|
|
66
|
+
return {
|
|
67
|
+
path: relativePath,
|
|
68
|
+
absolute: path.join(repo.root, relativePath),
|
|
69
|
+
text,
|
|
70
|
+
hash: `sha256:${sha256(text).slice(0, 16)}`,
|
|
71
|
+
tokens: estimateTokens(text),
|
|
72
|
+
units: parseMemoryUnits(text),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { UserError, fail, setQuiet } from "./logger.js";
|
|
7
|
+
import { loadConfig, parseMaxTranscripts } from "./config.js";
|
|
8
|
+
import { resolveRepo } from "./repo.js";
|
|
9
|
+
import { State } from "./state.js";
|
|
10
|
+
import { AgentResolver } from "./agents.js";
|
|
11
|
+
|
|
12
|
+
import { cmdInit } from "./commands/init.js";
|
|
13
|
+
import { cmdScan } from "./commands/scan.js";
|
|
14
|
+
import { cmdAnalyze } from "./commands/analyze.js";
|
|
15
|
+
import { cmdPropose } from "./commands/propose.js";
|
|
16
|
+
import { cmdApply } from "./commands/apply.js";
|
|
17
|
+
import { cmdStatus } from "./commands/status.js";
|
|
18
|
+
import { cmdRun } from "./commands/run.js";
|
|
19
|
+
|
|
20
|
+
const PKG = JSON.parse(
|
|
21
|
+
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const VERSION = PKG.version;
|
|
25
|
+
|
|
26
|
+
/** @type {import("node:util").ParseArgsOptionsConfig} */
|
|
27
|
+
const OPTIONS = {
|
|
28
|
+
help: { type: "boolean", short: "h" },
|
|
29
|
+
version: { type: "boolean", short: "v" },
|
|
30
|
+
quiet: { type: "boolean", short: "q" },
|
|
31
|
+
json: { type: "boolean" },
|
|
32
|
+
|
|
33
|
+
since: { type: "string" },
|
|
34
|
+
harness: { type: "string" },
|
|
35
|
+
jobs: { type: "string" },
|
|
36
|
+
strict: { type: "boolean" },
|
|
37
|
+
"include-cursor-ide": { type: "boolean" },
|
|
38
|
+
|
|
39
|
+
budget: { type: "string" },
|
|
40
|
+
"max-edits": { type: "string" },
|
|
41
|
+
"max-transcripts": { type: "string" },
|
|
42
|
+
seed: { type: "string" },
|
|
43
|
+
"min-gap-evidence": { type: "string" },
|
|
44
|
+
"memory-file": { type: "string", multiple: true },
|
|
45
|
+
"skills-dir": { type: "string" },
|
|
46
|
+
|
|
47
|
+
"analysis-agent": { type: "string" },
|
|
48
|
+
"analysis-model": { type: "string" },
|
|
49
|
+
"analysis-effort": { type: "string" },
|
|
50
|
+
"synthesis-agent": { type: "string" },
|
|
51
|
+
"synthesis-model": { type: "string" },
|
|
52
|
+
"synthesis-effort": { type: "string" },
|
|
53
|
+
|
|
54
|
+
"dry-run": { type: "boolean" },
|
|
55
|
+
"no-ui": { type: "boolean" },
|
|
56
|
+
"no-auto-agent": { type: "boolean" },
|
|
57
|
+
force: { type: "boolean" },
|
|
58
|
+
limit: { type: "string" },
|
|
59
|
+
theme: { type: "string" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const HELP = `backpass v${VERSION} - gradient descent for your agent memory
|
|
63
|
+
|
|
64
|
+
A backward pass over AGENTS.md / CLAUDE.md: it finds the agent sessions that ran in this
|
|
65
|
+
repo, reads what actually happened in them, and proposes evidence-backed edits to the
|
|
66
|
+
memory file - under a token budget, gated by you.
|
|
67
|
+
|
|
68
|
+
USAGE
|
|
69
|
+
backpass [command] [options]
|
|
70
|
+
|
|
71
|
+
COMMANDS
|
|
72
|
+
(none) the full pass: collect samples → calculate loss → aggregate gradients →
|
|
73
|
+
gradient descent. Never writes.
|
|
74
|
+
scan collect samples only: which transcripts belong to this repo, and how we know
|
|
75
|
+
analyze calculate loss: one cheap model call per new transcript (tier 1)
|
|
76
|
+
propose aggregate gradients, then gradient descent: one high-reasoning call
|
|
77
|
+
turning the aggregated evidence into edits (tier 2)
|
|
78
|
+
apply review the proposal and write the accepted edits (the only writer)
|
|
79
|
+
status cache state, evidence counts, and the budget bar
|
|
80
|
+
init write .backpassrc.json and exclude .backpass/ via .git/info/exclude
|
|
81
|
+
|
|
82
|
+
COLLECT SAMPLES
|
|
83
|
+
--since <dur> only sessions newer than this (30d, 12h, 2w, all) [30d]
|
|
84
|
+
--harness <a,b> limit to these harnesses
|
|
85
|
+
(claude, codex, pi, opencode, grok, cursor)
|
|
86
|
+
--strict deterministic associations only (tiers 1 and 2)
|
|
87
|
+
--include-cursor-ide also scan the Cursor IDE store (best-effort, v1.1 preview)
|
|
88
|
+
--limit <n> analyze at most N transcripts this run (newest first)
|
|
89
|
+
--max-transcripts <n> cap per run; past it a recency-weighted random sample
|
|
90
|
+
is analyzed. 0 or "all" disables the cap [100]
|
|
91
|
+
--seed <n> make the transcript sample reproducible
|
|
92
|
+
|
|
93
|
+
MODELS (two-tier: cheap analysis, smart synthesis - all through acpx)
|
|
94
|
+
By default each pass auto-picks the first harness in its ladder that is installed,
|
|
95
|
+
logged in, and serves the model (a ~1.5s zero-token probe per candidate, cached):
|
|
96
|
+
analysis gpt-5.6-luna via pi, opencode, codex > claude-sonnet-5 via claude > grok-4.6 via pi, opencode, grok
|
|
97
|
+
synthesis gpt-5.6-sol via pi, opencode, codex > claude-opus-5 via claude > grok-4.6 via pi, opencode, grok
|
|
98
|
+
Setting an agent pins that pass and skips its ladder.
|
|
99
|
+
--analysis-agent <a> acpx agent for the per-transcript pass [auto]
|
|
100
|
+
--analysis-model <id> model id for the analysis pass (needs --analysis-agent)
|
|
101
|
+
--analysis-effort <e> reasoning effort, when the adapter advertises it [medium]
|
|
102
|
+
--synthesis-agent <a> acpx agent for the final proposal pass [auto]
|
|
103
|
+
--synthesis-model <id> model id for the synthesis pass (needs --synthesis-agent)
|
|
104
|
+
--synthesis-effort <e> reasoning effort for synthesis [high]
|
|
105
|
+
--no-auto-agent skip the ladders and pin codex / claude (the pre-0.2 defaults)
|
|
106
|
+
--jobs <n> parallel analysis calls [4]
|
|
107
|
+
|
|
108
|
+
BUDGET AND SHAPE
|
|
109
|
+
--budget <tokens> always-loaded budget per memory file [5000]
|
|
110
|
+
--max-edits <n> edits per run - the learning rate [adaptive]
|
|
111
|
+
--min-gap-evidence <n> sessions needed before a new instruction [2]
|
|
112
|
+
--memory-file <path> memory file to optimize (repeatable)
|
|
113
|
+
--skills-dir <path> where skill extractions are written [.agents/skills]
|
|
114
|
+
|
|
115
|
+
APPLY
|
|
116
|
+
--no-ui terminal accept/reject instead of the Lavish surface
|
|
117
|
+
--dry-run show what would be written, write nothing
|
|
118
|
+
--force re-analyze transcripts that already have fresh evidence,
|
|
119
|
+
and re-probe agents instead of trusting the probe cache
|
|
120
|
+
|
|
121
|
+
OTHER
|
|
122
|
+
--theme <mode> live progress ink set: auto, dark, or light [auto]
|
|
123
|
+
--json machine-readable output on stdout
|
|
124
|
+
-q, --quiet suppress progress output (also disables the live view)
|
|
125
|
+
-h, --help this help
|
|
126
|
+
-v, --version print version
|
|
127
|
+
|
|
128
|
+
The default run renders a live progress view on an interactive terminal. It draws
|
|
129
|
+
to stderr only and falls back to plain lines when piped, in CI, under NO_COLOR,
|
|
130
|
+
or below 60 columns - stdout and --json output are identical either way.
|
|
131
|
+
|
|
132
|
+
EXAMPLES
|
|
133
|
+
backpass a full run, ending with a proposal
|
|
134
|
+
backpass scan --since 7d --strict what would be collected, deterministic only
|
|
135
|
+
backpass --synthesis-agent claude --synthesis-model claude-opus-5
|
|
136
|
+
backpass apply --no-ui review and write from the terminal
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
/** Map CLI flags onto the config shape so one merge order covers every layer. */
|
|
140
|
+
function overridesFrom(values) {
|
|
141
|
+
const overrides = { discovery: {}, analysis: {}, synthesis: {} };
|
|
142
|
+
|
|
143
|
+
if (values.since) overrides.discovery.since = values.since;
|
|
144
|
+
if (values.harness) {
|
|
145
|
+
overrides.discovery.harnesses = values.harness
|
|
146
|
+
.split(",")
|
|
147
|
+
.map((h) => h.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
}
|
|
150
|
+
if (values["include-cursor-ide"]) overrides.discovery.includeCursorIde = true;
|
|
151
|
+
if (values.jobs) overrides.jobs = toInt(values.jobs, "--jobs");
|
|
152
|
+
if (values.budget) overrides.budgetTokens = toInt(values.budget, "--budget");
|
|
153
|
+
if (values["max-edits"]) overrides.maxEditsPerRun = toInt(values["max-edits"], "--max-edits");
|
|
154
|
+
if (values["max-transcripts"] !== undefined) {
|
|
155
|
+
overrides.maxTranscripts = parseMaxTranscripts(values["max-transcripts"], "--max-transcripts");
|
|
156
|
+
}
|
|
157
|
+
if (values.seed !== undefined) overrides.seed = toSeed(values.seed);
|
|
158
|
+
if (values["min-gap-evidence"]) {
|
|
159
|
+
overrides.minGapEvidence = toInt(values["min-gap-evidence"], "--min-gap-evidence");
|
|
160
|
+
}
|
|
161
|
+
if (values["memory-file"]?.length) overrides.memoryFiles = values["memory-file"];
|
|
162
|
+
if (values["skills-dir"]) overrides.skillsDir = values["skills-dir"];
|
|
163
|
+
if (values.theme) overrides.theme = values.theme;
|
|
164
|
+
|
|
165
|
+
if (values["analysis-agent"]) overrides.analysis.agent = values["analysis-agent"];
|
|
166
|
+
if (values["analysis-model"]) overrides.analysis.model = values["analysis-model"];
|
|
167
|
+
if (values["analysis-effort"]) overrides.analysis.effort = values["analysis-effort"];
|
|
168
|
+
if (values["synthesis-agent"]) overrides.synthesis.agent = values["synthesis-agent"];
|
|
169
|
+
if (values["synthesis-model"]) overrides.synthesis.model = values["synthesis-model"];
|
|
170
|
+
if (values["synthesis-effort"]) overrides.synthesis.effort = values["synthesis-effort"];
|
|
171
|
+
if (values["no-auto-agent"]) overrides.autoAgent = false;
|
|
172
|
+
|
|
173
|
+
for (const key of ["discovery", "analysis", "synthesis"]) {
|
|
174
|
+
if (!Object.keys(overrides[key]).length) delete overrides[key];
|
|
175
|
+
}
|
|
176
|
+
return overrides;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toInt(value, flag) {
|
|
180
|
+
const n = Number(value);
|
|
181
|
+
if (!Number.isInteger(n) || n <= 0) throw new UserError(`${flag} must be a positive integer (got "${value}")`);
|
|
182
|
+
return n;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function toSeed(value) {
|
|
186
|
+
const n = Number(value);
|
|
187
|
+
if (!Number.isInteger(n)) throw new UserError(`--seed must be an integer (got "${value}")`);
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const COMMANDS = {
|
|
192
|
+
init: cmdInit,
|
|
193
|
+
scan: cmdScan,
|
|
194
|
+
analyze: cmdAnalyze,
|
|
195
|
+
propose: cmdPropose,
|
|
196
|
+
apply: cmdApply,
|
|
197
|
+
status: cmdStatus,
|
|
198
|
+
run: cmdRun,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export async function main(argv) {
|
|
202
|
+
let parsed;
|
|
203
|
+
try {
|
|
204
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fail(err.message);
|
|
207
|
+
console.error("\nRun `backpass --help` for the full option list.");
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { values, positionals } = parsed;
|
|
212
|
+
|
|
213
|
+
if (values.help) {
|
|
214
|
+
console.log(HELP);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (values.version) {
|
|
218
|
+
console.log(VERSION);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
setQuiet(values.quiet);
|
|
222
|
+
|
|
223
|
+
const commandName = positionals[0] || "run";
|
|
224
|
+
const command = COMMANDS[commandName];
|
|
225
|
+
if (!command) {
|
|
226
|
+
fail(`unknown command "${commandName}"`);
|
|
227
|
+
console.error("\nRun `backpass --help` for the command list.");
|
|
228
|
+
return 2;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const repo = resolveRepo(process.cwd());
|
|
233
|
+
const config = loadConfig(repo.root, overridesFrom(values));
|
|
234
|
+
config.state = new State(repo.root).ensure();
|
|
235
|
+
config.agents = new AgentResolver(config, {
|
|
236
|
+
state: config.state,
|
|
237
|
+
cwd: repo.root,
|
|
238
|
+
bypassCache: Boolean(values.force),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const ctx = {
|
|
242
|
+
repo,
|
|
243
|
+
config,
|
|
244
|
+
flags: values,
|
|
245
|
+
positionals: positionals.slice(1),
|
|
246
|
+
version: VERSION,
|
|
247
|
+
strict: Boolean(values.strict),
|
|
248
|
+
limit: values.limit ? toInt(values.limit, "--limit") : null,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return (await command(ctx)) ?? 0;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof UserError) {
|
|
254
|
+
fail(err.message);
|
|
255
|
+
if (err.hint) console.error(` ${err.hint}`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
fail(err.stack || err.message);
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
}
|