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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -0
  3. package/bin/backpass.js +4 -0
  4. package/package.json +62 -0
  5. package/src/acpx.js +576 -0
  6. package/src/agents.js +389 -0
  7. package/src/analyze.js +289 -0
  8. package/src/apply/lavish.js +128 -0
  9. package/src/apply/terminal.js +119 -0
  10. package/src/apply/writer.js +101 -0
  11. package/src/bootstrap.js +74 -0
  12. package/src/cli.js +261 -0
  13. package/src/commands/analyze.js +88 -0
  14. package/src/commands/apply.js +103 -0
  15. package/src/commands/bootstrap.js +172 -0
  16. package/src/commands/init.js +59 -0
  17. package/src/commands/propose.js +136 -0
  18. package/src/commands/run.js +95 -0
  19. package/src/commands/scan.js +90 -0
  20. package/src/commands/status.js +143 -0
  21. package/src/commands/usage.js +25 -0
  22. package/src/config.js +249 -0
  23. package/src/diff.js +305 -0
  24. package/src/discovery/adapters/claude.js +77 -0
  25. package/src/discovery/adapters/codex.js +162 -0
  26. package/src/discovery/adapters/cursor-cli.js +109 -0
  27. package/src/discovery/adapters/cursor-ide.js +130 -0
  28. package/src/discovery/adapters/grok.js +107 -0
  29. package/src/discovery/adapters/opencode.js +151 -0
  30. package/src/discovery/adapters/pi.js +87 -0
  31. package/src/discovery/adapters/shared.js +195 -0
  32. package/src/discovery/adapters/sqlite.js +50 -0
  33. package/src/discovery/association.js +100 -0
  34. package/src/discovery/index.js +226 -0
  35. package/src/discovery/self.js +62 -0
  36. package/src/distill.js +182 -0
  37. package/src/fold.js +214 -0
  38. package/src/gap-ledger.js +174 -0
  39. package/src/logger.js +74 -0
  40. package/src/memory.js +244 -0
  41. package/src/progress.js +29 -0
  42. package/src/prompts/analysis.md +48 -0
  43. package/src/prompts/annotate.md +48 -0
  44. package/src/prompts/synthesis.md +98 -0
  45. package/src/prompts.js +36 -0
  46. package/src/proposal.js +430 -0
  47. package/src/redact.js +36 -0
  48. package/src/repo.js +118 -0
  49. package/src/sample.js +99 -0
  50. package/src/skills.js +207 -0
  51. package/src/state.js +202 -0
  52. package/src/subprocess.js +47 -0
  53. package/src/synthesize.js +287 -0
  54. package/src/tokens.js +48 -0
  55. package/src/tui/index.js +336 -0
  56. package/src/tui/render.js +487 -0
  57. package/src/tui/term.js +130 -0
  58. package/src/tui/theme.js +111 -0
  59. package/src/workspace.js +162 -0
  60. 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
+ }
@@ -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
+ }