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,47 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Spawn a command, capture both streams, and resolve (never reject) with a
5
+ * uniform result. Shared by the acpx boundary (`src/acpx.js`) and the native
6
+ * harness probes (`src/agents.js`), so every subprocess in backpass is killed the
7
+ * same way on timeout and reports a spawn failure the same way.
8
+ *
9
+ * @param {string} bin
10
+ * @param {string[]} args
11
+ * @param {{ timeoutMs?: number, cwd?: string, input?: string }} [options]
12
+ * @returns {Promise<{ code: number | null, stdout: string, stderr: string, timedOut?: boolean, spawnError?: NodeJS.ErrnoException }>}
13
+ */
14
+ export function runCapture(bin, args, { timeoutMs, cwd, input } = {}) {
15
+ return new Promise((resolve) => {
16
+ const child = spawn(bin, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
17
+ let stdout = "";
18
+ let stderr = "";
19
+ let timedOut = false;
20
+
21
+ const timer = timeoutMs
22
+ ? setTimeout(() => {
23
+ timedOut = true;
24
+ child.kill("SIGTERM");
25
+ setTimeout(() => child.kill("SIGKILL"), 5000).unref();
26
+ }, timeoutMs)
27
+ : null;
28
+
29
+ child.stdout.on("data", (d) => {
30
+ stdout += d;
31
+ });
32
+ child.stderr.on("data", (d) => {
33
+ stderr += d;
34
+ });
35
+ child.on("error", (err) => {
36
+ if (timer) clearTimeout(timer);
37
+ resolve({ code: null, stdout, stderr: `${stderr}${err.message}`, spawnError: err });
38
+ });
39
+ child.on("close", (code) => {
40
+ if (timer) clearTimeout(timer);
41
+ resolve({ code, stdout, stderr, timedOut });
42
+ });
43
+
44
+ if (input !== undefined) child.stdin.end(input);
45
+ else child.stdin.end();
46
+ });
47
+ }
@@ -0,0 +1,287 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { extractJson, openSession, usageRecord } from "./acpx.js";
5
+ import { renderEvidenceForPrompt } from "./fold.js";
6
+ import { renderInstructionIndex } from "./memory.js";
7
+ import { renderPrompt } from "./prompts.js";
8
+ import { buildProposal, effectiveMaxEdits, ProposalViolation, renderChangesForPrompt } from "./proposal.js";
9
+ import { loadSkills, renderSkillIndex, resolveOverflowTarget } from "./skills.js";
10
+ import { isSuppressedByRejection } from "./state.js";
11
+ import { emitProgress } from "./progress.js";
12
+ import { measureWorkspace, prepareWorkspace, repoFingerprint } from "./workspace.js";
13
+ import { UserError, color, info, warn } from "./logger.js";
14
+
15
+ /**
16
+ * Stage 3 of the pipeline (design section 3): one high-reasoning session that turns
17
+ * folded evidence into concrete edits.
18
+ *
19
+ * The agent never describes an edit for backpass to locate - it makes the edit, with its
20
+ * harness's own file tools, in a staging copy of the memory file (`src/workspace.js`).
21
+ * One session, two kinds of turn:
22
+ *
23
+ * edit the synthesis prompt; the agent edits `./AGENTS.md` in the staging copy
24
+ * annotate backpass measures the copy against the original (`src/diff.js`) and shows
25
+ * the changes by id; the agent attaches kind, title, rationale, and evidence
26
+ *
27
+ * The annotation is what the mechanical gates validate (`buildProposal`); on a violation
28
+ * the agent is re-prompted with the exact breaches, at most ANNOTATE_TURNS times in all,
29
+ * then backpass fails loudly and saves the rejected proposal rather than quietly trimming
30
+ * it (design section 6). The repo is fingerprinted before and checked after: a harness
31
+ * that wrote past the staging copy is an error, never a silent apply.
32
+ */
33
+
34
+ /** Annotation turns per run: the first answer plus re-prompts with the exact violations. */
35
+ export const ANNOTATE_TURNS = 3;
36
+
37
+ function budgetRule(memoryFile, config, maxEdits) {
38
+ const remaining = config.budgetTokens - memoryFile.tokens;
39
+ if (remaining <= 0) {
40
+ return (
41
+ `This file is ALREADY ${Math.abs(remaining)} tokens OVER budget, so this run is a SHRINK ` +
42
+ `PLAN. You are NOT expected to reach ${config.budgetTokens} tokens in one run - the ` +
43
+ `${maxEdits}-edit cap for this run makes that impossible and later runs continue the work. ` +
44
+ `What is required is real progress: the edit set MUST be net-negative, so lead with the ` +
45
+ `highest-cost instructions that have no positive evidence, and with skill extractions of ` +
46
+ `long narrow sections. Any addition must name the removal that pays for it. Make the ` +
47
+ `largest honest reduction you can justify from the evidence.`
48
+ );
49
+ }
50
+ if (remaining < config.budgetTokens * 0.15) {
51
+ return (
52
+ `Only ${remaining} tokens of headroom remain. Treat this as zero-sum: every addition must ` +
53
+ `name its offsetting removal or skill extraction. The post-edit file must stay at or below ` +
54
+ `${config.budgetTokens} tokens.`
55
+ );
56
+ }
57
+ return `The post-edit file must stay at or below ${config.budgetTokens} tokens (${remaining} tokens of headroom today).`;
58
+ }
59
+
60
+ function budgetState(memoryFile, config) {
61
+ const ratio = memoryFile.tokens / config.budgetTokens;
62
+ if (ratio > 1) return "OVER BUDGET";
63
+ if (ratio > 0.85) return "near budget";
64
+ return "within budget";
65
+ }
66
+
67
+ function renderRejections(rejections) {
68
+ const entries = Object.values(rejections.entries || {});
69
+ if (!entries.length) return "(none)";
70
+ return entries
71
+ .map(
72
+ (e) =>
73
+ `- [${e.kind}] ${e.title} (rejected ${e.rejectedAt.slice(0, 10)} with ${e.transcripts} session(s) of evidence)`,
74
+ )
75
+ .join("\n");
76
+ }
77
+
78
+ function harnessCountsOf(transcripts) {
79
+ const counts = {};
80
+ for (const t of transcripts) counts[t.harness] = (counts[t.harness] || 0) + 1;
81
+ return counts;
82
+ }
83
+
84
+ /** The repo must be exactly as fingerprinted; the staging copy is the only place to write. */
85
+ function assertRepoUntouched(repo, before, workspaceRoot) {
86
+ const after = repoFingerprint(repo, Object.keys(before));
87
+ const moved = Object.keys(before).filter((file) => before[file] !== after[file]);
88
+ if (!moved.length) return;
89
+ throw new UserError(
90
+ `synthesis changed ${moved.join(", ")} in the repository directly instead of the staging copy ` +
91
+ `(${workspaceRoot}); nothing was proposed`,
92
+ `inspect the change with \`git diff\`, restore the file, and re-run - a harness that edits outside its cwd cannot be trusted with the synthesis role`,
93
+ );
94
+ }
95
+
96
+ export async function synthesizeProposal({ memoryFile, summary, config, repo, transcripts, runNote = "" }) {
97
+ const state = config.state;
98
+ const rejections = state.readRejections();
99
+ const overflow = resolveOverflowTarget(repo.root, config.skillsDir);
100
+ for (const w of overflow.warnings) warn(w);
101
+ const skillFiles = loadSkills(repo.root, overflow.dir);
102
+ const harnessCounts = harnessCountsOf(transcripts);
103
+ const maxEdits = effectiveMaxEdits(memoryFile, config);
104
+
105
+ const common = {
106
+ MEMORY_PATH: memoryFile.path,
107
+ BUDGET_RULE: budgetRule(memoryFile, config, maxEdits),
108
+ MAX_EDITS: String(maxEdits),
109
+ MIN_GAP_EVIDENCE: String(config.minGapEvidence),
110
+ };
111
+ const editValues = {
112
+ ...common,
113
+ REPO_NAME: repo.name,
114
+ REPO_ROOT: repo.root,
115
+ TRANSCRIPT_COUNT: String(summary.analyzedSessions),
116
+ RUN_NOTE: runNote,
117
+ HARNESS_SUMMARY:
118
+ Object.entries(harnessCounts)
119
+ .map(([h, n]) => `${h} ${n}`)
120
+ .join(" · ") || "none",
121
+ CURRENT_TOKENS: String(memoryFile.tokens),
122
+ BUDGET_TOKENS: String(config.budgetTokens),
123
+ BUDGET_STATE: budgetState(memoryFile, config),
124
+ INSTRUCTION_INDEX: renderInstructionIndex(memoryFile),
125
+ SKILLS_DIR: overflow.dir,
126
+ SKILL_INDEX: renderSkillIndex(skillFiles),
127
+ EVIDENCE: renderEvidenceForPrompt(summary),
128
+ REJECTIONS: renderRejections(rejections),
129
+ };
130
+
131
+ const context = {
132
+ memoryFile,
133
+ config: { ...config, skillsDir: overflow.dir },
134
+ repo,
135
+ summary,
136
+ harnessCounts,
137
+ rejections,
138
+ isSuppressed: isSuppressedByRejection,
139
+ skillFiles,
140
+ };
141
+
142
+ const promptDir = path.join(state.root, "prompts");
143
+ fs.mkdirSync(promptDir, { recursive: true });
144
+ const editPromptFile = path.join(promptDir, "synthesis-edit.md");
145
+ fs.writeFileSync(editPromptFile, renderPrompt("synthesis", editValues));
146
+
147
+ const fingerprint = repoFingerprint(repo, [memoryFile.path, ...skillFiles.map((s) => s.path)]);
148
+ const sessionName = `backpass-synth-${process.pid}`;
149
+ const timeoutSeconds = Math.max(config.timeoutSeconds, 900);
150
+ const usage = [];
151
+ const notes = [];
152
+ const noteOnce = (note) => {
153
+ // The same adapter limitation is reported on every turn; say it once.
154
+ if (notes.includes(note)) return;
155
+ notes.push(note);
156
+ warn(note);
157
+ };
158
+
159
+ const pick = await config.agents.resolve("synthesis");
160
+ info(
161
+ `${color.cyan("·")} synthesizing with ${pick.agent}` +
162
+ `${pick.model ? ` (${pick.model})` : ""}` +
163
+ `${pick.effort ? ` effort=${pick.effort}` : ""}`,
164
+ );
165
+ let ranWith = pick.agent;
166
+ const progress = (phase, extra = {}) =>
167
+ emitProgress("synth:start", {
168
+ agent: ranWith,
169
+ model: pick.model,
170
+ effort: pick.effort,
171
+ phase,
172
+ maxEdits,
173
+ sessionName,
174
+ gapClusters: summary.totals.gapClusters,
175
+ instructions: summary.instructions.length,
176
+ suppressed: Object.keys(rejections.entries || {}).length,
177
+ ...extra,
178
+ });
179
+
180
+ // A classifiable failure (not logged in, model rejected, adapter missing) falls
181
+ // through to the next ladder candidate; the switch is recorded in the notes so the
182
+ // proposal's provenance is visible. Once the editing turn has run, later turns stay
183
+ // on the same candidate - a run never silently switches models after real work.
184
+ /** @type {Awaited<ReturnType<typeof openSession>> | null} */
185
+ let session = null;
186
+ /** @type {ReturnType<typeof prepareWorkspace>} */
187
+ let workspace = null;
188
+ const editResult = await config.agents.withFallthrough("synthesis", async (current) => {
189
+ ranWith = current.agent;
190
+ if (current !== pick) notes.push(`synthesis fell through to ${current.agent} (${current.model})`);
191
+ workspace = prepareWorkspace({ state, repo, memoryFile, skillsDir: overflow.dir });
192
+ progress("edit", { attempt: 1 });
193
+ session = await openSession({
194
+ agent: current.agent,
195
+ model: current.model,
196
+ effort: current.effort,
197
+ sessionName,
198
+ cwd: workspace.root,
199
+ });
200
+ try {
201
+ return await session.prompt({
202
+ promptFile: editPromptFile,
203
+ approveAll: true,
204
+ timeoutSeconds,
205
+ promptRetries: config.promptRetries,
206
+ });
207
+ } catch (err) {
208
+ await session.close();
209
+ session = null;
210
+ throw err;
211
+ }
212
+ });
213
+ usage.push(usageRecord(ranWith, editResult));
214
+ for (const note of editResult.notes || []) noteOnce(note);
215
+
216
+ const turn = session;
217
+ try {
218
+ let lastViolations = [];
219
+ for (let attempt = 1; attempt <= ANNOTATE_TURNS; attempt += 1) {
220
+ assertRepoUntouched(repo, fingerprint, workspace.root);
221
+ const measured = measureWorkspace(workspace);
222
+
223
+ let prompt = renderPrompt("annotate", {
224
+ ...common,
225
+ CHANGES: renderChangesForPrompt(measured, memoryFile),
226
+ });
227
+ if (lastViolations.length) {
228
+ prompt +=
229
+ `\n\n## Your previous answer was rejected\n\nIt violated these hard rules. Fix every one of them ` +
230
+ `(edit the files first if a change must go or move) and return the corrected JSON object only.\n\n` +
231
+ `${lastViolations.map((v) => `- ${v}`).join("\n")}\n`;
232
+ }
233
+ const promptFile = path.join(promptDir, `synthesis-annotate-${attempt}.md`);
234
+ fs.writeFileSync(promptFile, prompt);
235
+ progress("annotate", { attempt, changes: measured.changes.length });
236
+
237
+ const result = await turn.prompt({
238
+ promptFile,
239
+ approveAll: true,
240
+ timeoutSeconds,
241
+ promptRetries: config.promptRetries,
242
+ });
243
+ usage.push(usageRecord(ranWith, result));
244
+ for (const note of result.notes || []) noteOnce(note);
245
+
246
+ // The agent may keep editing during an annotate turn; ids are then stale, so the
247
+ // answer is discarded and the fresh measurement is shown instead.
248
+ assertRepoUntouched(repo, fingerprint, workspace.root);
249
+ const remeasured = measureWorkspace(workspace);
250
+ if (remeasured.signature !== measured.signature) {
251
+ lastViolations = [
252
+ "the files changed after the changes were measured; annotate the re-measured changes shown above",
253
+ ];
254
+ } else {
255
+ const parsed = extractJson(result.text);
256
+ if (!parsed) {
257
+ lastViolations = ["synthesis returned no parseable JSON object"];
258
+ } else {
259
+ const { proposal, violations } = buildProposal(parsed, { ...context, measured });
260
+ proposal.notes = [...proposal.notes, ...notes];
261
+ proposal.usage = usage;
262
+ proposal.overflowTarget = overflow;
263
+ if (!violations.length) {
264
+ emitProgress("synth:done", { edits: proposal.edits.length, attempt });
265
+ return { proposal, violations: [] };
266
+ }
267
+ lastViolations = violations;
268
+ proposal.violations = violations;
269
+ // Keep the rejected proposal so a loud failure is still inspectable.
270
+ state.writeProposal(proposal);
271
+ }
272
+ }
273
+
274
+ if (attempt < ANNOTATE_TURNS) {
275
+ warn(`synthesis violated ${lastViolations.length} gate(s); re-prompting with the exact violations`);
276
+ emitProgress("synth:violations", { attempt, violations: lastViolations });
277
+ }
278
+ }
279
+
280
+ throw new ProposalViolation(
281
+ `synthesis could not produce a valid proposal after ${ANNOTATE_TURNS - 1} re-prompt(s) (${lastViolations.length} violation(s))`,
282
+ lastViolations,
283
+ );
284
+ } finally {
285
+ await turn.close();
286
+ }
287
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Token accounting for the length budget (design section 6).
3
+ *
4
+ * The estimator is deliberately harness-neutral: UTF-8 bytes / 4, the same
5
+ * currency firstmate prices its own startup memory budget in. It is accurate
6
+ * to roughly +/-15% across real memory files, which is the precision the
7
+ * budget gate needs - the gate is a guardrail, not a billing system.
8
+ */
9
+
10
+ const BYTES_PER_TOKEN = 4;
11
+
12
+ export function estimateTokens(text) {
13
+ if (!text) return 0;
14
+ return Math.ceil(Buffer.byteLength(text, "utf8") / BYTES_PER_TOKEN);
15
+ }
16
+
17
+ export function estimateTokensFromBytes(bytes) {
18
+ return Math.ceil(bytes / BYTES_PER_TOKEN);
19
+ }
20
+
21
+ export function formatTokens(n) {
22
+ return n.toLocaleString("en-US");
23
+ }
24
+
25
+ /**
26
+ * Budget verdict for one always-loaded memory file.
27
+ * `over` is the amount by which projected exceeds the cap (0 when within).
28
+ */
29
+ export function budgetStatus(currentText, projectedText, capTokens) {
30
+ const current = estimateTokens(currentText);
31
+ const projected = estimateTokens(projectedText ?? currentText);
32
+ return {
33
+ capTokens,
34
+ current,
35
+ projected,
36
+ delta: projected - current,
37
+ withinBudget: projected <= capTokens,
38
+ over: Math.max(0, projected - capTokens),
39
+ utilization: capTokens > 0 ? projected / capTokens : 0,
40
+ };
41
+ }
42
+
43
+ /** Fixed-width ASCII gauge for `backpass status`. */
44
+ export function budgetBar(status, width = 32) {
45
+ const filled = Math.min(width, Math.round(status.utilization * width));
46
+ const overflow = status.withinBudget ? 0 : Math.min(width - filled, 2);
47
+ return `[${"#".repeat(filled)}${"!".repeat(overflow)}${".".repeat(Math.max(0, width - filled - overflow))}]`;
48
+ }