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,88 @@
1
+ import { analyzeTranscripts } from "../analyze.js";
2
+ import { UserError, color, info, json, out, warn } from "../logger.js";
3
+ import { resolveMemoryFiles } from "../memory.js";
4
+ import { emitProgress } from "../progress.js";
5
+ import { discoverForRun } from "./scan.js";
6
+ import { printUsage } from "./usage.js";
7
+ import { capTranscripts } from "../sample.js";
8
+
9
+ /**
10
+ * The memory file a run optimizes: the first configured file that exists (AGENTS.md by
11
+ * default - canonical). Resolution is pointer-aware: a CLAUDE.md that is just
12
+ * `@AGENTS.md` is covered by optimizing AGENTS.md and needs no mention. A second file
13
+ * with its own content is NOT updated - that would either be ignored silently or
14
+ * double-written into divergence - so the run says so and recommends consolidating.
15
+ *
16
+ * When no configured file exists, `backpass` (the default run) bootstraps one; every
17
+ * other command fails with a pointer to that.
18
+ */
19
+ export function primaryMemoryFile(repo, config) {
20
+ const resolved = resolveMemoryFiles(repo.root, config.memoryFiles);
21
+ if (!resolved.primary) {
22
+ throw new UserError(
23
+ `no memory file found (looked for ${config.memoryFiles.join(", ")})`,
24
+ "run `backpass` to bootstrap an AGENTS.md, or set memoryFiles in .backpassrc.json",
25
+ );
26
+ }
27
+ for (const other of resolved.separate) {
28
+ warn(
29
+ `${other.path} is a separate memory file and will NOT be updated - only ${resolved.primary.path} is optimized. ` +
30
+ `To cover both, consolidate: move its content into ${resolved.primary.path} and make ${other.path} a pointer ` +
31
+ `(a single line: @${resolved.primary.path}).`,
32
+ );
33
+ }
34
+ return { file: resolved.primary, all: resolved.all, hash: resolved.hash, resolved };
35
+ }
36
+
37
+ export async function runAnalysis(ctx) {
38
+ const { repo, config } = ctx;
39
+ const { file, hash } = primaryMemoryFile(repo, config);
40
+ // Deterministic by design: tokens and units come from parsing the file, no model.
41
+ emitProgress("memory", {
42
+ path: file.path,
43
+ tokens: file.tokens,
44
+ budget: config.budgetTokens,
45
+ units: file.units.length,
46
+ });
47
+ // The cap bounds the expensive per-transcript calls; cached evidence is reused as usual.
48
+ const { transcripts, perHarness } = capTranscripts(await discoverForRun(ctx), config);
49
+
50
+ if (!transcripts.length) {
51
+ info(`${color.yellow("·")} no transcripts associated with this repo`);
52
+ return { file, hash, transcripts, perHarness, summary: null };
53
+ }
54
+
55
+ const summary = await analyzeTranscripts({
56
+ transcripts,
57
+ memoryFile: file,
58
+ config,
59
+ repo,
60
+ memoryHash: hash,
61
+ force: Boolean(ctx.flags.force),
62
+ });
63
+
64
+ return { file, hash, transcripts, perHarness, summary };
65
+ }
66
+
67
+ export async function cmdAnalyze(ctx) {
68
+ const { file, transcripts, summary } = await runAnalysis(ctx);
69
+
70
+ if (ctx.flags.json) {
71
+ json({ memoryFile: file.path, transcripts: transcripts.length, summary });
72
+ return 0;
73
+ }
74
+
75
+ if (!summary) return 0;
76
+
77
+ out("");
78
+ out(`analyzed against ${file.path} (${file.units.length} instructions, ${file.tokens} tok)`);
79
+ out(
80
+ ` ${summary.analyzed} newly analyzed · ${summary.cached} cached · ` +
81
+ `${summary.skipped} skipped (too short) · ${summary.failed} failed`,
82
+ );
83
+ if (summary.failed) {
84
+ out(color.dim(" failed transcripts are listed by `backpass status` and retried next run"));
85
+ }
86
+ printUsage({ tier1: summary.usage });
87
+ return 0;
88
+ }
@@ -0,0 +1,103 @@
1
+ import { UserError, color, info, json, out, warn } from "../logger.js";
2
+ import { applyDecisions } from "../apply/writer.js";
3
+ import { closeApplySurface, openApplySurface, pollDecisions, renderApplySurface } from "../apply/lavish.js";
4
+ import { reviewInTerminal } from "../apply/terminal.js";
5
+ import { budgetBar, formatTokens } from "../tokens.js";
6
+
7
+ /**
8
+ * The human gate. `backpass apply` is the only command that writes to the repo.
9
+ *
10
+ * By default it serves the shipped static template through lavish-axi and waits for one
11
+ * structured decision vector; `--no-ui` keeps the same ACCEPT/REJECT decision in the
12
+ * terminal.
13
+ */
14
+ export async function cmdApply(ctx) {
15
+ const { config, repo } = ctx;
16
+ const proposal = config.state.readProposal();
17
+
18
+ if (!proposal) {
19
+ throw new UserError("no proposal to apply", "run `backpass` first to produce one");
20
+ }
21
+ if (proposal.violations?.length) {
22
+ throw new UserError(
23
+ "the saved proposal failed its mechanical gates and was never approved for apply",
24
+ "run `backpass propose` again",
25
+ );
26
+ }
27
+ if (proposal.appliedAt) {
28
+ throw new UserError(
29
+ `the last proposal was already applied by ${proposal.appliedBy || "a previous apply"} (${proposal.appliedAt})`,
30
+ "run `backpass` again to produce a fresh one",
31
+ );
32
+ }
33
+ if (!proposal.edits.length) {
34
+ out("The last run proposed no edits. Nothing to apply.");
35
+ return 0;
36
+ }
37
+
38
+ const editIds = proposal.edits.map((e) => e.id);
39
+ let decisions;
40
+ let surfaceFile = null;
41
+
42
+ if (ctx.flags["no-ui"]) {
43
+ decisions = await reviewInTerminal(proposal);
44
+ } else {
45
+ surfaceFile = renderApplySurface(proposal, config.state, ctx.version);
46
+ const url = await openApplySurface(surfaceFile);
47
+ info(`${color.cyan("·")} review surface: ${url || surfaceFile}`);
48
+ decisions = await pollDecisions(surfaceFile, editIds);
49
+ }
50
+
51
+ if (!decisions) {
52
+ out("No decisions received - nothing was written.");
53
+ return 0;
54
+ }
55
+
56
+ // Anything the reviewer never touched stays untouched.
57
+ for (const id of editIds) if (!decisions[id]) decisions[id] = "skipped";
58
+
59
+ const results = applyDecisions({
60
+ proposal,
61
+ decisions,
62
+ repo,
63
+ state: config.state,
64
+ config,
65
+ dryRun: Boolean(ctx.flags["dry-run"]),
66
+ });
67
+
68
+ if (surfaceFile) await closeApplySurface(surfaceFile);
69
+
70
+ if (ctx.flags.json) {
71
+ json({ decisions, results });
72
+ return results.failed.length ? 1 : 0;
73
+ }
74
+
75
+ out("");
76
+ const prefix = ctx.flags["dry-run"] ? color.yellow("[dry-run] ") : "";
77
+ out(`${prefix}${results.accepted} accepted · ${results.rejected} rejected`);
78
+
79
+ for (const written of results.written) {
80
+ out(` ${color.green("wrote")} ${written.file} (${written.edits.join(", ")})`);
81
+ if (written.budget) {
82
+ out(
83
+ ` budget ${budgetBar(written.budget)} ${formatTokens(written.budget.current)} -> ` +
84
+ `${formatTokens(written.budget.projected)} / ${formatTokens(written.budget.capTokens)} tok`,
85
+ );
86
+ }
87
+ }
88
+ for (const skill of results.skills) {
89
+ out(` ${color.green("wrote")} ${skill.path} (new skill)`);
90
+ for (const created of skill.created || []) out(color.dim(` created ${created}`));
91
+ }
92
+ for (const warning of results.warnings || []) warn(warning);
93
+ for (const failure of results.failed) {
94
+ out(` ${color.red("failed")} ${failure.file}${failure.edit ? ` (${failure.edit})` : ""}: ${failure.error}`);
95
+ }
96
+
97
+ if (results.rejected) {
98
+ out(color.dim(" rejections recorded - they will not be re-proposed without new evidence"));
99
+ }
100
+ if (!results.written.length && !results.skills.length) out(" nothing written");
101
+
102
+ return results.failed.length ? 1 : 0;
103
+ }
@@ -0,0 +1,172 @@
1
+ import { analyzeTranscripts } from "../analyze.js";
2
+ import { applyDecisions, writeBootstrapFiles } from "../apply/writer.js";
3
+ import { bootstrapTargets, renderPointer, starterMemoryFile } from "../bootstrap.js";
4
+ import { color, info, json, out, warn } from "../logger.js";
5
+ import { emitProgress } from "../progress.js";
6
+ import { ProposalViolation } from "../proposal.js";
7
+ import { synthesizeProposal } from "../synthesize.js";
8
+ import { budgetBar, formatTokens } from "../tokens.js";
9
+ import { discoverForRun } from "./scan.js";
10
+ import { foldForRun, printProposal } from "./propose.js";
11
+
12
+ /**
13
+ * Bootstrap: the default run when the repo has no memory file at all.
14
+ *
15
+ * Instead of failing, backpass seeds a starter AGENTS.md (a minimal skeleton, see
16
+ * `src/bootstrap.js`) plus a CLAUDE.md pointer, then runs the ordinary backward pass with
17
+ * the starter as the current weights: the transcripts' gaps and recurring mistakes
18
+ * become the first evidence-backed instructions. Every edit of that first proposal is
19
+ * applied directly - the file is brand new, so there is nothing to protect with the
20
+ * human gate and `git diff` is the review. Later runs go through the normal
21
+ * propose -> apply flow.
22
+ *
23
+ * Safety: files are only ever created, never overwritten, and a failure anywhere after
24
+ * the seed still leaves a valid defaults-only memory file behind.
25
+ */
26
+
27
+ const BOOTSTRAP_RUN_NOTE =
28
+ "This memory file was just seeded from generic defaults and has never steered a session, " +
29
+ "so the evidence is gaps and mistakes only. Turn recurring gaps into concrete instructions " +
30
+ "(`add` under `## Learnings`, replacing the placeholder bullet there; never create a new " +
31
+ "section for them) and `rewrite` any default the evidence contradicts. " +
32
+ "Skip anything a single session suggests.";
33
+
34
+ /**
35
+ * `deps` exists so the flow is testable offline: the three model/disk-facing stages
36
+ * default to the real pipeline and can be swapped for fakes.
37
+ */
38
+ export async function bootstrapRun(ctx, deps = {}) {
39
+ const { repo, config } = ctx;
40
+ const discover = deps.discover || discoverForRun;
41
+ const analyze = deps.analyze || analyzeTranscripts;
42
+ const synthesize = deps.synthesize || synthesizeProposal;
43
+ const { canonical, pointer } = bootstrapTargets(config.memoryFiles);
44
+
45
+ const { transcripts, perHarness } = await discover(ctx);
46
+ info(
47
+ `${color.yellow("·")} no memory file found (looked for ${config.memoryFiles.join(", ")}) - ` +
48
+ `bootstrapping ${canonical} from ${transcripts.length} transcript(s) + defaults`,
49
+ );
50
+
51
+ const starter = starterMemoryFile(repo, canonical);
52
+ const seed = [{ path: canonical, text: starter.text }];
53
+ if (pointer) seed.push({ path: pointer, text: renderPointer(canonical) });
54
+ const seeded = writeBootstrapFiles(repo.root, seed);
55
+ for (const w of seeded.written) info(`${color.green("·")} wrote ${w.file}`);
56
+ for (const s of seeded.skipped) warn(`${s.file} ${s.reason} - left untouched`);
57
+
58
+ emitProgress("memory", {
59
+ path: starter.path,
60
+ tokens: starter.tokens,
61
+ budget: config.budgetTokens,
62
+ units: starter.units.length,
63
+ });
64
+
65
+ const result = {
66
+ bootstrap: true,
67
+ seededFrom: "defaults",
68
+ files: seeded,
69
+ memoryFile: canonical,
70
+ transcripts: transcripts.length,
71
+ perHarness,
72
+ summary: null,
73
+ proposal: null,
74
+ applied: null,
75
+ };
76
+
77
+ if (!transcripts.length) return result;
78
+
79
+ result.summary = await analyze({
80
+ transcripts,
81
+ memoryFile: starter,
82
+ config,
83
+ repo,
84
+ memoryHash: starter.hash,
85
+ force: Boolean(ctx.flags.force),
86
+ });
87
+ info(
88
+ `${color.cyan("·")} evidence: ${result.summary.analyzed} new · ${result.summary.cached} cached · ` +
89
+ `${result.summary.skipped} too short · ${result.summary.failed} failed`,
90
+ );
91
+
92
+ const folded = await foldForRun(ctx, starter);
93
+ config.state.writeSummary(folded);
94
+ emitProgress("fold:done", {
95
+ instructions: folded.instructions.length,
96
+ clustersFound: folded.totals.gapClusters + folded.totals.droppedGapSingletons,
97
+ clustersKept: folded.totals.gapClusters,
98
+ minGapEvidence: config.minGapEvidence,
99
+ ms: 0,
100
+ });
101
+ if (!folded.analyzedSessions) return result;
102
+
103
+ try {
104
+ const { proposal } = await synthesize({
105
+ memoryFile: starter,
106
+ summary: folded,
107
+ config,
108
+ repo,
109
+ transcripts,
110
+ runNote: BOOTSTRAP_RUN_NOTE,
111
+ });
112
+ const decisions = Object.fromEntries(proposal.edits.map((e) => [e.id, "accepted"]));
113
+ const applied = applyDecisions({ proposal, decisions, repo, state: config.state, config });
114
+ proposal.appliedAt = new Date().toISOString();
115
+ proposal.appliedBy = "bootstrap";
116
+ config.state.writeProposal(proposal);
117
+ result.proposal = proposal;
118
+ result.applied = applied;
119
+ if (proposal.edits.length) result.seededFrom = "transcripts + defaults";
120
+ } catch (err) {
121
+ if (!(err instanceof ProposalViolation)) throw err;
122
+ // The seed is already on disk, so a bad synthesis degrades to defaults-only.
123
+ for (const violation of err.violations) warn(violation);
124
+ warn("gradient descent failed its gates; the memory file stays defaults-only this run");
125
+ }
126
+ return result;
127
+ }
128
+
129
+ export function printBootstrap(result, config) {
130
+ out("");
131
+ const files = result.files.written.map((w) => w.file).join(" + ");
132
+ out(`${color.bold("bootstrapped")} ${files || "nothing"} ${color.dim(`(seeded from ${result.seededFrom})`)}`);
133
+ for (const s of result.files.skipped) out(` ${color.yellow("kept")} ${s.file} (${s.reason})`);
134
+
135
+ if (!result.transcripts) {
136
+ out(" no transcripts associated with this repo yet - the defaults stand until later runs find some");
137
+ out(color.dim(" `backpass scan --since all` widens the time window"));
138
+ } else if (!result.proposal) {
139
+ out(` ${result.transcripts} transcript(s) found, but none produced usable evidence; defaults stand`);
140
+ } else {
141
+ printProposal(result.proposal, { applied: true });
142
+ for (const w of result.applied.written) {
143
+ out(` ${color.green("wrote")} ${w.file} (${w.edits.join(", ")})`);
144
+ if (w.budget) {
145
+ out(
146
+ ` budget ${budgetBar(w.budget)} ${formatTokens(w.budget.current)} -> ` +
147
+ `${formatTokens(w.budget.projected)} / ${formatTokens(w.budget.capTokens)} tok`,
148
+ );
149
+ }
150
+ }
151
+ for (const s of result.applied.skills) out(` ${color.green("wrote")} ${s.path} (new skill)`);
152
+ for (const f of result.applied.failed) {
153
+ out(` ${color.red("failed")} ${f.file}${f.edit ? ` (${f.edit})` : ""}: ${f.error}`);
154
+ }
155
+ }
156
+ out("");
157
+ out(
158
+ `Review with \`git diff\`; later runs keep refining under the ${formatTokens(config.budgetTokens)}-token budget.`,
159
+ );
160
+ }
161
+
162
+ export function bootstrapJson(result) {
163
+ json({
164
+ bootstrap: true,
165
+ seededFrom: result.seededFrom,
166
+ memoryFile: result.memoryFile,
167
+ files: result.files,
168
+ transcripts: result.transcripts,
169
+ proposal: result.proposal,
170
+ applied: result.applied,
171
+ });
172
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { CONFIG_FILENAME, initialConfig, repoConfigPath } from "../config.js";
5
+ import { color, info, out, warn } from "../logger.js";
6
+ import { loadMemoryFiles } from "../memory.js";
7
+ import { ensureLocalExclude } from "../repo.js";
8
+ import { STATE_EXCLUDE_LINE as EXCLUDE_LINE } from "../state.js";
9
+ import { budgetBar, budgetStatus, formatTokens } from "../tokens.js";
10
+
11
+ export async function cmdInit({ repo, config, flags }) {
12
+ const target = repoConfigPath(repo.root);
13
+ const existing = fs.existsSync(target);
14
+
15
+ const seed = initialConfig();
16
+ // Seed memoryFiles with what this repo actually has, so the first run is correct.
17
+ const present = config.memoryFiles.filter((f) => fs.existsSync(path.join(repo.root, f)));
18
+ if (present.length) seed.memoryFiles = present;
19
+
20
+ if (existing && !flags.force) {
21
+ info(`${color.yellow("·")} ${CONFIG_FILENAME} already exists - leaving it alone (use --force to overwrite)`);
22
+ } else {
23
+ fs.writeFileSync(target, `${JSON.stringify(seed, null, 2)}\n`);
24
+ info(`${color.green("·")} wrote ${CONFIG_FILENAME}`);
25
+ }
26
+
27
+ const exclude = ensureLocalExclude(repo.root, EXCLUDE_LINE);
28
+ if (exclude.status === "added") {
29
+ info(`${color.green("·")} added ${EXCLUDE_LINE} to .git/info/exclude (local, never committed)`);
30
+ } else if (exclude.status === "no-git") {
31
+ info(`${color.yellow("·")} no git dir found - skipped excluding ${EXCLUDE_LINE}`);
32
+ }
33
+
34
+ const gitignore = path.join(repo.root, ".gitignore");
35
+ const gitignoreLines = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf8").split("\n") : [];
36
+ if (gitignoreLines.some((l) => l.trim() === EXCLUDE_LINE)) {
37
+ warn(`.gitignore already lists ${EXCLUDE_LINE} from an older backpass - remove it any time, it's redundant now`);
38
+ }
39
+
40
+ const files = loadMemoryFiles(repo.root, seed.memoryFiles);
41
+ out("");
42
+ if (!files.length) {
43
+ out(
44
+ `No memory file found yet. \`backpass\` will bootstrap ${seed.memoryFiles[0]} (+ a CLAUDE.md pointer) from your transcripts and defaults.`,
45
+ );
46
+ return 0;
47
+ }
48
+ for (const file of files) {
49
+ const status = budgetStatus(file.text, null, seed.budgetTokens);
50
+ out(
51
+ `${file.path.padEnd(14)} ${budgetBar(status)} ${formatTokens(status.current)} / ${formatTokens(
52
+ status.capTokens,
53
+ )} tok · ${file.units.length} instructions`,
54
+ );
55
+ }
56
+ out("");
57
+ out("Next: `backpass` to run a backward pass, or `backpass scan` to see what it would read.");
58
+ return 0;
59
+ }
@@ -0,0 +1,136 @@
1
+ import { foldEvidence } from "../fold.js";
2
+ import { ledgerGapObservations, pruneGapLedger, recordGapObservations } from "../gap-ledger.js";
3
+ import { synthesizeProposal } from "../synthesize.js";
4
+ import { ProposalViolation } from "../proposal.js";
5
+ import { UserError, color, info, json, out } from "../logger.js";
6
+ import { budgetBar, formatTokens } from "../tokens.js";
7
+ import { emitProgress } from "../progress.js";
8
+ import { primaryMemoryFile } from "./analyze.js";
9
+ import { printUsage } from "./usage.js";
10
+ import { discoverForRun } from "./scan.js";
11
+
12
+ /**
13
+ * Fold on-disk evidence for the memory file. Gap corroboration is counted through the
14
+ * persisted ledger so sessions accumulate across runs: record this run's observations,
15
+ * prune what the current file now covers or what aged out (after recording, because the
16
+ * evidence files that fed an expired sighting are still on disk and would re-add it),
17
+ * then cluster from the ledger.
18
+ */
19
+ export async function foldForRun(ctx, memoryFile) {
20
+ const { state, minGapEvidence, gapLedgerMaxAge } = ctx.config;
21
+ const evidence = state.listEvidence();
22
+ const relevant = evidence.filter((e) => e.memoryPath === memoryFile.path);
23
+
24
+ const ledger = state.readGapLedger();
25
+ recordGapObservations(ledger, relevant);
26
+ pruneGapLedger(ledger, { memoryFile, memoryPath: memoryFile.path, maxAge: gapLedgerMaxAge });
27
+ state.writeGapLedger(ledger);
28
+
29
+ return foldEvidence(relevant, {
30
+ minGapEvidence,
31
+ memoryFile,
32
+ gapObservations: ledgerGapObservations(ledger, memoryFile.path),
33
+ });
34
+ }
35
+
36
+ export async function runProposal(ctx, precomputed = null) {
37
+ const { repo, config } = ctx;
38
+ const { file } = precomputed || primaryMemoryFile(repo, config);
39
+ const transcripts = precomputed?.transcripts || (await discoverForRun(ctx)).transcripts;
40
+
41
+ const foldStarted = Date.now();
42
+ const summary = await foldForRun(ctx, file);
43
+ config.state.writeSummary(summary);
44
+ emitProgress("fold:done", {
45
+ instructions: summary.instructions.length,
46
+ clustersFound: summary.totals.gapClusters + summary.totals.droppedGapSingletons,
47
+ clustersKept: summary.totals.gapClusters,
48
+ minGapEvidence: config.minGapEvidence,
49
+ ms: Date.now() - foldStarted,
50
+ });
51
+
52
+ if (!summary.analyzedSessions) {
53
+ throw new UserError(
54
+ "no loss calculated yet: nothing to run gradient descent on",
55
+ "run `backpass analyze` first, or `backpass` for the full pass",
56
+ );
57
+ }
58
+
59
+ const { proposal } = await synthesizeProposal({
60
+ memoryFile: file,
61
+ summary,
62
+ config,
63
+ repo,
64
+ transcripts,
65
+ });
66
+
67
+ config.state.writeProposal(proposal);
68
+ return { proposal, summary, memoryFile: file };
69
+ }
70
+
71
+ /**
72
+ * @param {object} proposal
73
+ * @param {{ applied?: boolean, analysisUsage?: import("../acpx.js").UsageRecord[] }} [options]
74
+ * `analysisUsage` is the tier-1 accounting of the same run, when the caller ran it.
75
+ */
76
+ export function printProposal(proposal, { applied = false, analysisUsage = [] } = {}) {
77
+ out("");
78
+ out(
79
+ `${color.bold("proposal")} · ${proposal.repo.name} · ${proposal.memoryFile.path} · ` +
80
+ `${proposal.edits.length} edit(s) from ${proposal.stats.transcripts} session(s)`,
81
+ );
82
+ out(
83
+ ` budget ${budgetBar(proposal.budget)} ${formatTokens(proposal.budget.current)} -> ` +
84
+ `${formatTokens(proposal.budget.projected)} / ${formatTokens(proposal.budget.capTokens)} tok` +
85
+ (proposal.budget.mode === "shrink"
86
+ ? color.dim(` [shrink plan: ${formatTokens(proposal.budget.over)} still over]`)
87
+ : ""),
88
+ );
89
+ out(
90
+ ` evidence: ${proposal.stats.positive} positive · ${proposal.stats.negative} negative · ` +
91
+ `${proposal.stats.gapClusters} gap clusters`,
92
+ );
93
+ out("");
94
+
95
+ if (!proposal.edits.length) {
96
+ out(" no edits proposed - the evidence did not clear the thresholds this run");
97
+ }
98
+
99
+ for (const edit of proposal.edits) {
100
+ const kind = edit.kind === "extract" ? "EXTRACT" : edit.kind.toUpperCase();
101
+ const delta = edit.deltaTokens || 0;
102
+ out(
103
+ ` ${color.cyan(edit.id)} ${kind.padEnd(8)} ${edit.title} ` +
104
+ color.dim(`(${delta > 0 ? "+" : ""}${delta} tok, ${edit.transcripts} transcript(s))`),
105
+ );
106
+ }
107
+
108
+ for (const note of proposal.notes || []) out(color.dim(` note: ${note}`));
109
+
110
+ printUsage({ tier1: analysisUsage, tier2: proposal.usage || [] });
111
+ if (applied) return;
112
+ out("");
113
+ out("Review and apply with `backpass apply` (nothing has been written).");
114
+ }
115
+
116
+ export async function cmdPropose(ctx) {
117
+ try {
118
+ const { proposal } = await runProposal(ctx);
119
+ if (ctx.flags.json) {
120
+ json(proposal);
121
+ return 0;
122
+ }
123
+ printProposal(proposal);
124
+ return 0;
125
+ } catch (err) {
126
+ if (err instanceof ProposalViolation) {
127
+ // Loud failure, never silent truncation (design section 6).
128
+ info("");
129
+ for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
130
+ info("");
131
+ info(color.dim(` the rejected proposal was saved to ${ctx.config.state.proposalPath}`));
132
+ throw new UserError(err.message, "try a stronger synthesis model, or raise --budget / --max-edits");
133
+ }
134
+ throw err;
135
+ }
136
+ }
@@ -0,0 +1,95 @@
1
+ import { UserError, color, info, json, out } from "../logger.js";
2
+ import { ProposalViolation } from "../proposal.js";
3
+ import { runAnalysis } from "./analyze.js";
4
+ import { bootstrapJson, bootstrapRun, printBootstrap } from "./bootstrap.js";
5
+ import { printProposal, runProposal } from "./propose.js";
6
+ import { budgetBar, formatTokens } from "../tokens.js";
7
+ import { startTui } from "../tui/index.js";
8
+ import { resolveMemoryFiles } from "../memory.js";
9
+
10
+ /**
11
+ * The default command: one full backward pass.
12
+ *
13
+ * discover -> distill -> analyze (cheap, fanned out) -> fold -> synthesize (one big call)
14
+ *
15
+ * It never writes - with one exception: a repo with no memory file at all is
16
+ * bootstrapped (`./bootstrap.js`), which only ever creates files. Otherwise applying
17
+ * is a separate, human-gated step.
18
+ *
19
+ * On an eligible terminal a live progress view renders the run on stderr; it
20
+ * collapses back into the plain lines below before anything is printed to
21
+ * stdout, so piped and logged output is unchanged.
22
+ */
23
+ export async function cmdRun(ctx) {
24
+ const { repo, config } = ctx;
25
+ const tui = await startTui(ctx);
26
+
27
+ try {
28
+ info(
29
+ `${color.bold("backpass")} ${color.dim(
30
+ `v${ctx.version} · ${repo.name} · budget ${formatTokens(config.budgetTokens)} tok · since ${config.discovery.since}`,
31
+ )}`,
32
+ );
33
+
34
+ if (!resolveMemoryFiles(repo.root, config.memoryFiles).primary) {
35
+ const result = await bootstrapRun(ctx);
36
+ tui?.stop();
37
+ if (ctx.flags.json) bootstrapJson(result);
38
+ else printBootstrap(result, config);
39
+ return 0;
40
+ }
41
+
42
+ const analysis = await runAnalysis(ctx);
43
+
44
+ if (!analysis.transcripts.length) {
45
+ tui?.stop();
46
+ out("");
47
+ out("No agent transcripts are associated with this repo yet.");
48
+ out(
49
+ color.dim(
50
+ " `backpass scan --since all` widens the time window; --include-cursor-ide adds the Cursor IDE store.",
51
+ ),
52
+ );
53
+ return 0;
54
+ }
55
+
56
+ const budget = budgetBar({
57
+ utilization: analysis.file.tokens / config.budgetTokens,
58
+ withinBudget: analysis.file.tokens <= config.budgetTokens,
59
+ });
60
+ info(
61
+ `${color.cyan("·")} ${analysis.file.path}: ${budget} ${formatTokens(analysis.file.tokens)} / ` +
62
+ `${formatTokens(config.budgetTokens)} tok · ${analysis.file.units.length} instructions`,
63
+ );
64
+
65
+ if (analysis.summary) {
66
+ info(
67
+ `${color.cyan("·")} evidence: ${analysis.summary.analyzed} new · ${analysis.summary.cached} cached · ` +
68
+ `${analysis.summary.skipped} too short · ${analysis.summary.failed} failed`,
69
+ );
70
+ }
71
+
72
+ const { proposal } = await runProposal(ctx, analysis);
73
+ tui?.stop();
74
+
75
+ if (ctx.flags.json) {
76
+ json(proposal);
77
+ return 0;
78
+ }
79
+
80
+ printProposal(proposal, { analysisUsage: analysis.summary?.usage || [] });
81
+ return 0;
82
+ } catch (err) {
83
+ tui?.stop();
84
+ if (err instanceof ProposalViolation) {
85
+ info("");
86
+ for (const violation of err.violations) info(` ${color.red("x")} ${violation}`);
87
+ info("");
88
+ info(color.dim(` the rejected proposal was saved to ${config.state.proposalPath}`));
89
+ throw new UserError(err.message, "try a stronger synthesis model, or raise --budget / --max-edits");
90
+ }
91
+ throw err;
92
+ } finally {
93
+ tui?.stop();
94
+ }
95
+ }