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,336 @@
1
+ /**
2
+ * The live progress view controller: owns the stderr repaint region during the
3
+ * default run, reduces pipeline progress events into render state, and tears
4
+ * itself down into today's plain line output.
5
+ *
6
+ * Contract (approved design, section "behavior"):
7
+ * - stderr only; stdout and --json are untouched
8
+ * - eligibility-gated: no TTY, NO_COLOR, CI, --quiet, --json, or a terminal
9
+ * narrower than 60 columns means the TUI never starts and nothing changes
10
+ * - while active, logger progress lines are buffered and replayed verbatim on
11
+ * teardown, so scrollback ends up exactly as it does without the TUI
12
+ * - all motion stops and the region is erased the moment the run ends
13
+ */
14
+
15
+ import { DEFAULT_MAX_EDITS } from "../proposal.js";
16
+ import { clearProgressSink, setProgressSink } from "../progress.js";
17
+ import { setLoggerSink } from "../logger.js";
18
+ import { colorDepth, detectBackground, tuiEligible } from "./term.js";
19
+ import { makeTheme } from "./theme.js";
20
+ import { SPINNER_INTERVAL_MS, renderFrame, spinnerFrame } from "./render.js";
21
+
22
+ /**
23
+ * Start the live view for one run. Returns null when the terminal is not
24
+ * eligible - callers treat that as "no TUI" and change nothing.
25
+ */
26
+ export async function startTui(ctx, { stderr = process.stderr } = {}) {
27
+ if (!tuiEligible({ stderr, quiet: Boolean(ctx.flags.quiet), json: Boolean(ctx.flags.json) })) return null;
28
+
29
+ const depth = colorDepth({ stderr });
30
+ const preference = ctx.config.theme || "auto";
31
+ const background = preference === "auto" ? await detectBackground({ stderr }) : preference;
32
+ const theme = makeTheme({ depth, background });
33
+
34
+ const tui = new Tui({
35
+ theme,
36
+ stderr,
37
+ meta: {
38
+ version: ctx.version,
39
+ repoName: ctx.repo.name,
40
+ worktrees: ctx.repo.worktrees?.length || 1,
41
+ since: ctx.config.discovery.since,
42
+ maxEdits: ctx.config.maxEditsPerRun ?? DEFAULT_MAX_EDITS,
43
+ },
44
+ });
45
+ tui.start();
46
+ return tui;
47
+ }
48
+
49
+ export function initialState(meta) {
50
+ return {
51
+ meta,
52
+ memory: null,
53
+ discover: {
54
+ status: "pending",
55
+ startedAt: null,
56
+ endedAt: null,
57
+ order: [],
58
+ harnesses: {},
59
+ totalMatched: 0,
60
+ storesOk: 0,
61
+ storesTotal: 0,
62
+ files: 0,
63
+ newFiles: 0,
64
+ },
65
+ analyze: {
66
+ status: "pending",
67
+ startedAt: null,
68
+ endedAt: null,
69
+ total: 0,
70
+ pending: 0,
71
+ done: 0,
72
+ ok: 0,
73
+ skipped: 0,
74
+ failed: 0,
75
+ cached: 0,
76
+ jobs: 0,
77
+ agent: null,
78
+ model: null,
79
+ lanes: [],
80
+ evidence: { positive: 0, negative: 0, gaps: 0 },
81
+ },
82
+ fold: {
83
+ status: "pending",
84
+ startedAt: null,
85
+ endedAt: null,
86
+ instructions: 0,
87
+ clustersFound: 0,
88
+ clustersKept: 0,
89
+ minGapEvidence: 2,
90
+ },
91
+ synthesize: {
92
+ status: "pending",
93
+ startedAt: null,
94
+ endedAt: null,
95
+ agent: null,
96
+ model: null,
97
+ effort: null,
98
+ attempt: 0,
99
+ phase: "edit",
100
+ changes: null,
101
+ sessionName: null,
102
+ gapClusters: 0,
103
+ instructions: 0,
104
+ suppressed: 0,
105
+ violations: [],
106
+ edits: 0,
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Reduce one progress event into the render state. Exported for tests. */
112
+ export function reduceEvent(state, event, data, now = Date.now()) {
113
+ const { discover: d, analyze: a, fold: f, synthesize: s } = state;
114
+
115
+ switch (event) {
116
+ case "memory":
117
+ state.memory = { path: data.path, tokens: data.tokens, budget: data.budget, units: data.units };
118
+ break;
119
+
120
+ case "discover:start":
121
+ d.status = "active";
122
+ d.startedAt = now;
123
+ d.order = data.harnesses;
124
+ d.storesTotal = data.harnesses.length;
125
+ for (const harness of data.harnesses) {
126
+ d.harnesses[harness] = {
127
+ status: "pending",
128
+ scanned: 0,
129
+ total: 0,
130
+ matched: 0,
131
+ newCount: 0,
132
+ tiers: {},
133
+ error: null,
134
+ };
135
+ }
136
+ break;
137
+ case "discover:harness:start":
138
+ d.harnesses[data.harness].status = "scanning";
139
+ break;
140
+ case "discover:harness:tick": {
141
+ const h = d.harnesses[data.harness];
142
+ h.scanned = data.scanned;
143
+ h.total = data.total;
144
+ h.matched = data.matched;
145
+ d.totalMatched = Object.values(d.harnesses).reduce((sum, row) => sum + row.matched, 0);
146
+ break;
147
+ }
148
+ case "discover:harness:done": {
149
+ const h = d.harnesses[data.harness];
150
+ if (data.error) {
151
+ h.status = "error";
152
+ h.error = data.error;
153
+ } else {
154
+ h.status = "done";
155
+ h.scanned = data.scanned;
156
+ h.matched = data.matched;
157
+ h.newCount = Math.max(data.scanned - (data.cached || 0), 0);
158
+ h.self = data.self || 0;
159
+ h.tiers = data.tiers || {};
160
+ d.storesOk += 1;
161
+ d.files += data.scanned;
162
+ d.newFiles += h.newCount;
163
+ }
164
+ d.totalMatched = Object.values(d.harnesses).reduce((sum, row) => sum + row.matched, 0);
165
+ break;
166
+ }
167
+ case "discover:done":
168
+ d.status = "done";
169
+ d.endedAt = now;
170
+ d.totalMatched = data.total;
171
+ break;
172
+
173
+ case "analyze:start":
174
+ a.status = "active";
175
+ a.startedAt = now;
176
+ a.total = data.total;
177
+ a.pending = data.pending;
178
+ a.cached = data.cached;
179
+ a.jobs = data.jobs;
180
+ a.agent = data.agent;
181
+ a.model = data.model;
182
+ a.lanes = new Array(Math.max(Math.min(data.jobs, data.pending), 0)).fill(null);
183
+ break;
184
+ case "analyze:lane": {
185
+ const existing = a.lanes[data.slot] || { startedAt: now };
186
+ a.lanes[data.slot] = { ...existing, ...data, startedAt: data.phase === "distill" ? now : existing.startedAt };
187
+ break;
188
+ }
189
+ case "analyze:tick":
190
+ a.done = data.done;
191
+ a.ok = data.ok;
192
+ a.skipped = data.skipped;
193
+ a.failed = data.failed;
194
+ a.lanes[data.slot] = null;
195
+ break;
196
+ case "analyze:evidence":
197
+ a.evidence = { positive: data.positive, negative: data.negative, gaps: data.gaps };
198
+ break;
199
+ case "analyze:done":
200
+ a.status = "done";
201
+ a.endedAt = now;
202
+ a.ok = data.analyzed;
203
+ a.cached = data.cached;
204
+ a.skipped = data.skipped;
205
+ a.failed = data.failed;
206
+ a.lanes = [];
207
+ break;
208
+
209
+ case "fold:done":
210
+ f.status = "done";
211
+ f.startedAt = now - (data.ms || 0);
212
+ f.endedAt = now;
213
+ f.instructions = data.instructions;
214
+ f.clustersFound = data.clustersFound;
215
+ f.clustersKept = data.clustersKept;
216
+ f.minGapEvidence = data.minGapEvidence;
217
+ break;
218
+
219
+ case "synth:start":
220
+ s.status = "active";
221
+ s.startedAt = now;
222
+ s.agent = data.agent;
223
+ s.model = data.model;
224
+ s.effort = data.effort;
225
+ s.attempt = data.attempt;
226
+ s.phase = data.phase || "edit";
227
+ s.changes = data.changes ?? null;
228
+ if (data.maxEdits) state.meta.maxEdits = data.maxEdits;
229
+ s.sessionName = data.sessionName;
230
+ s.gapClusters = data.gapClusters;
231
+ s.instructions = data.instructions;
232
+ s.suppressed = data.suppressed;
233
+ break;
234
+ case "synth:violations":
235
+ s.violations = data.violations || [];
236
+ break;
237
+ case "synth:done":
238
+ s.status = "done";
239
+ s.endedAt = now;
240
+ s.edits = data.edits;
241
+ break;
242
+ }
243
+ return state;
244
+ }
245
+
246
+ const PAINT_THROTTLE_MS = 40;
247
+
248
+ class Tui {
249
+ constructor({ theme, stderr, meta }) {
250
+ this.theme = theme;
251
+ this.stderr = stderr;
252
+ this.state = initialState(meta);
253
+ this.buffered = [];
254
+ this.painted = 0;
255
+ this.lastPaint = 0;
256
+ this.stopped = false;
257
+ this.timer = null;
258
+ this.onEvent = this.onEvent.bind(this);
259
+ this.onResize = () => this.paint(true);
260
+ this.onSigint = () => {
261
+ this.stop();
262
+ process.exit(130);
263
+ };
264
+ this.onSigterm = () => {
265
+ this.stop();
266
+ process.exit(143);
267
+ };
268
+ this.onExit = () => {
269
+ if (!this.stopped) this.stderr.write("\x1b[?25h");
270
+ };
271
+ }
272
+
273
+ start() {
274
+ setProgressSink(this.onEvent);
275
+ // Progress lines keep being produced for scrollback; they are buffered here
276
+ // and replayed verbatim on teardown so the collapsed output is identical to
277
+ // a run without the TUI.
278
+ setLoggerSink((line) => this.buffered.push(line));
279
+ this.stderr.write("\x1b[?25l");
280
+ this.timer = setInterval(() => this.paint(), SPINNER_INTERVAL_MS);
281
+ this.stderr.on("resize", this.onResize);
282
+ process.on("SIGINT", this.onSigint);
283
+ process.on("SIGTERM", this.onSigterm);
284
+ process.once("exit", this.onExit);
285
+ this.paint(true);
286
+ }
287
+
288
+ onEvent(event, data) {
289
+ reduceEvent(this.state, event, data);
290
+ this.paint();
291
+ }
292
+
293
+ paint(force = false) {
294
+ if (this.stopped) return;
295
+ const now = Date.now();
296
+ if (!force && now - this.lastPaint < PAINT_THROTTLE_MS) return;
297
+ this.lastPaint = now;
298
+
299
+ const lines = renderFrame(this.state, {
300
+ width: this.stderr.columns || 80,
301
+ theme: this.theme,
302
+ now,
303
+ spin: spinnerFrame(now),
304
+ });
305
+
306
+ let out = "\x1b[?2026h";
307
+ if (this.painted > 0) out += `\x1b[${this.painted}A`;
308
+ out += "\r";
309
+ for (const line of lines) out += `\x1b[2K${line}\n`;
310
+ if (this.painted > lines.length) out += "\x1b[0J";
311
+ out += "\x1b[?2026l";
312
+ this.stderr.write(out);
313
+ this.painted = lines.length;
314
+ }
315
+
316
+ /** Idempotent teardown: erase the region, restore the cursor, replay buffered lines. */
317
+ stop() {
318
+ if (this.stopped) return;
319
+ this.stopped = true;
320
+ clearInterval(this.timer);
321
+ clearProgressSink();
322
+ setLoggerSink(null);
323
+ this.stderr.off("resize", this.onResize);
324
+ process.off("SIGINT", this.onSigint);
325
+ process.off("SIGTERM", this.onSigterm);
326
+
327
+ let out = "";
328
+ if (this.painted > 0) out += `\x1b[${this.painted}A\r\x1b[0J`;
329
+ out += "\x1b[?25h";
330
+ this.stderr.write(out);
331
+ this.painted = 0;
332
+
333
+ for (const line of this.buffered) this.stderr.write(`${line}\n`);
334
+ this.buffered = [];
335
+ }
336
+ }