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,111 @@
1
+ /**
2
+ * The backpass house theme for the live progress view.
3
+ *
4
+ * One theme, two ink sets (captain-approved design): the palette from the apply
5
+ * surface for dark backgrounds, and a darkened twin tuned for paper-white. On
6
+ * truecolor terminals the exact hex inks are emitted; otherwise each ink maps to
7
+ * its nearest ANSI-16 color, which inherits the user's own palette.
8
+ *
9
+ * The mint->blue "descent gradient" is the brand mark and is reserved for
10
+ * exactly two things: the nabla wordmark and active progress fills.
11
+ */
12
+
13
+ export const INKS = {
14
+ dark: {
15
+ text: "#d9dfea",
16
+ dim: "#8a93a5",
17
+ faint: "#5a6375",
18
+ mint: "#4fe3c1",
19
+ blue: "#6ea8ff",
20
+ yellow: "#ffc857",
21
+ red: "#ff5d73",
22
+ purple: "#b18aff",
23
+ peach: "#ff9e64",
24
+ green: "#9ece6a",
25
+ magenta: "#e05fbc",
26
+ },
27
+ light: {
28
+ text: "#2a3140",
29
+ dim: "#5b6478",
30
+ faint: "#939cae",
31
+ mint: "#0a8a72",
32
+ blue: "#2b62d9",
33
+ yellow: "#8f6606",
34
+ red: "#c22b47",
35
+ purple: "#6f42c8",
36
+ peach: "#b25a10",
37
+ green: "#4e7a17",
38
+ magenta: "#ab3184",
39
+ },
40
+ };
41
+
42
+ /** Nearest ANSI-16 foreground SGR code per ink. `null` means the default fg. */
43
+ export const ANSI16 = {
44
+ text: null,
45
+ dim: "90",
46
+ faint: "90",
47
+ mint: "96",
48
+ blue: "94",
49
+ yellow: "93",
50
+ red: "91",
51
+ purple: "95",
52
+ peach: "33",
53
+ green: "92",
54
+ magenta: "95",
55
+ };
56
+
57
+ export function hexToRgb(hex) {
58
+ const value = hex.replace("#", "");
59
+ return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)];
60
+ }
61
+
62
+ const RESET = "\x1b[0m";
63
+
64
+ /**
65
+ * Build a theme for one terminal. `depth` 24 emits hex inks, 4 emits ANSI-16,
66
+ * and 0 emits no escapes at all - which is what the render tests use, so the
67
+ * rendered layout is asserted as plain text.
68
+ */
69
+ export function makeTheme({ depth = 24, background = "dark" } = {}) {
70
+ const inks = INKS[background] || INKS.dark;
71
+
72
+ const open = (ink, bold) => {
73
+ if (depth === 0) return "";
74
+ const parts = [];
75
+ if (bold) parts.push("1");
76
+ if (depth === 24) {
77
+ const [r, g, b] = hexToRgb(inks[ink] || inks.text);
78
+ parts.push(`38;2;${r};${g};${b}`);
79
+ } else if (ANSI16[ink]) {
80
+ parts.push(ANSI16[ink]);
81
+ }
82
+ return parts.length ? `\x1b[${parts.join(";")}m` : "";
83
+ };
84
+
85
+ const paint = (text, ink = "text", { bold = false } = {}) => {
86
+ const prefix = open(ink, bold);
87
+ return prefix ? `${prefix}${text}${RESET}` : String(text);
88
+ };
89
+
90
+ /** Per-character mint->blue interpolation; solid mint below truecolor. */
91
+ const gradient = (text, { bold = false } = {}) => {
92
+ const value = String(text);
93
+ if (depth === 0) return value;
94
+ if (depth !== 24) return paint(value, "mint", { bold });
95
+ const from = hexToRgb(inks.mint);
96
+ const to = hexToRgb(inks.blue);
97
+ const chars = [...value];
98
+ const steps = Math.max(chars.length - 1, 1);
99
+ return (
100
+ chars
101
+ .map((ch, i) => {
102
+ const t = i / steps;
103
+ const rgb = from.map((f, c) => Math.round(f + (to[c] - f) * t));
104
+ return `\x1b[${bold ? "1;" : ""}38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${ch}`;
105
+ })
106
+ .join("") + RESET
107
+ );
108
+ };
109
+
110
+ return { depth, background, inks, paint, gradient };
111
+ }
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { anchoredHunks } from "./diff.js";
5
+ import { parseFrontmatter } from "./skills.js";
6
+ import { sha256 } from "./state.js";
7
+
8
+ /**
9
+ * The synthesis staging workspace (design section 3, native-edit revision).
10
+ *
11
+ * The synthesis agent edits the memory file with its harness's own file tools - the
12
+ * same `edit`/`write` it uses on any repo - instead of handing backpass text to splice.
13
+ * So that a run stays safe to interrupt and `src/apply/writer.js` stays the only module
14
+ * that writes to the repo, those tools never see the repo: they run in a staging copy
15
+ * under `.backpass/synthesis/` holding only the memory file and the skills directory.
16
+ * The agent reads the real repository by absolute path for grounding; what it changes in
17
+ * the copy is measured here (`measureWorkspace`) and becomes the proposal the human
18
+ * reviews. The copy is wiped and rebuilt on every synthesis.
19
+ */
20
+
21
+ export const WORKSPACE_DIRNAME = "synthesis";
22
+
23
+ export function workspaceRoot(state) {
24
+ return path.join(state.root, WORKSPACE_DIRNAME);
25
+ }
26
+
27
+ /**
28
+ * Build a fresh staging copy. `originals` records every file placed there, so the
29
+ * measurement can tell a modified file from a created or deleted one.
30
+ */
31
+ export function prepareWorkspace({ state, repo, memoryFile, skillsDir }) {
32
+ const root = workspaceRoot(state);
33
+ fs.rmSync(root, { recursive: true, force: true });
34
+ fs.mkdirSync(root, { recursive: true });
35
+
36
+ const originals = new Map();
37
+ const memoryTarget = path.join(root, memoryFile.path);
38
+ fs.mkdirSync(path.dirname(memoryTarget), { recursive: true });
39
+ fs.writeFileSync(memoryTarget, memoryFile.text);
40
+ originals.set(memoryFile.path, memoryFile.text);
41
+
42
+ const skillsSource = path.join(repo.root, skillsDir);
43
+ if (fs.existsSync(skillsSource) && fs.statSync(skillsSource).isDirectory()) {
44
+ for (const relative of walkFiles(skillsSource)) {
45
+ const from = path.join(skillsSource, relative);
46
+ const to = path.join(root, skillsDir, relative);
47
+ fs.mkdirSync(path.dirname(to), { recursive: true });
48
+ fs.copyFileSync(from, to);
49
+ originals.set(path.posix.join(skillsDir, relative), fs.readFileSync(from, "utf8"));
50
+ }
51
+ }
52
+ fs.mkdirSync(path.join(root, skillsDir), { recursive: true });
53
+
54
+ return { root, memoryPath: memoryFile.path, skillsDir, originals };
55
+ }
56
+
57
+ function walkFiles(dir, prefix = "") {
58
+ const out = [];
59
+ let entries;
60
+ try {
61
+ entries = fs.readdirSync(dir, { withFileTypes: true });
62
+ } catch {
63
+ return out;
64
+ }
65
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
66
+ const relative = prefix ? path.posix.join(prefix, entry.name) : entry.name;
67
+ if (entry.isDirectory()) out.push(...walkFiles(path.join(dir, entry.name), relative));
68
+ else if (entry.isFile()) out.push(relative);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** A created file counts as a skill only in the layouts `loadSkills` reads. */
74
+ export function isSkillFilePath(relative, skillsDir) {
75
+ const prefix = `${skillsDir}/`;
76
+ if (!relative.startsWith(prefix)) return false;
77
+ const inside = relative.slice(prefix.length);
78
+ const parts = inside.split("/");
79
+ if (parts.length === 1) return parts[0].endsWith(".md");
80
+ return parts.length === 2 && parts[1] === "SKILL.md";
81
+ }
82
+
83
+ /** Split a SKILL.md into the fields `writeSkill` needs; null when the frontmatter is unusable. */
84
+ export function parseSkillFile(relative, text) {
85
+ const frontmatter = parseFrontmatter(text);
86
+ if (!frontmatter.name || !frontmatter.description) return null;
87
+ const end = /^---\n[\s\S]*?\n---\n?/.exec(text);
88
+ const body = end ? text.slice(end[0].length) : text;
89
+ return {
90
+ name: String(frontmatter.name).trim(),
91
+ description: String(frontmatter.description).trim(),
92
+ path: relative,
93
+ body: body.trim(),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Everything that differs between the originals and the workspace now, as changes with
99
+ * stable ids the annotate turn refers to:
100
+ *
101
+ * { id: "H1", kind: "hunk", file, find, replace, oldStart, oldEnd, removed, added, lines }
102
+ * { id: "H4", kind: "created", file, text, skill | null } a new SKILL.md
103
+ * { id: "H5", kind: "deleted", file } a staged file removed
104
+ *
105
+ * Files outside the memory file and the skill layouts are `stray` - reported, never
106
+ * carried. Ids are assigned in file order so a re-measurement after an unchanged
107
+ * workspace yields identical ids.
108
+ */
109
+ export function measureWorkspace(workspace) {
110
+ const { root, memoryPath, skillsDir, originals } = workspace;
111
+ /** @type {any[]} */
112
+ const changes = [];
113
+ const stray = [];
114
+ const texts = new Map();
115
+
116
+ const present = new Set(walkFiles(root).map((p) => p.split(path.sep).join("/")));
117
+
118
+ const ordered = [memoryPath, ...[...originals.keys()].filter((f) => f !== memoryPath).sort()];
119
+ for (const relative of ordered) {
120
+ const original = originals.get(relative);
121
+ if (!present.has(relative)) {
122
+ changes.push({ kind: "deleted", file: relative });
123
+ continue;
124
+ }
125
+ const text = fs.readFileSync(path.join(root, relative), "utf8");
126
+ texts.set(relative, text);
127
+ for (const hunk of anchoredHunks(original, text)) changes.push({ kind: "hunk", file: relative, ...hunk });
128
+ }
129
+
130
+ for (const relative of [...present].sort()) {
131
+ if (originals.has(relative)) continue;
132
+ if (!isSkillFilePath(relative, skillsDir)) {
133
+ stray.push(relative);
134
+ continue;
135
+ }
136
+ const text = fs.readFileSync(path.join(root, relative), "utf8");
137
+ texts.set(relative, text);
138
+ changes.push({ kind: "created", file: relative, text, skill: parseSkillFile(relative, text) });
139
+ }
140
+
141
+ changes.forEach((change, index) => {
142
+ change.id = `H${index + 1}`;
143
+ });
144
+
145
+ return { changes, stray, texts, originals, signature: signatureOf(changes) };
146
+ }
147
+
148
+ function signatureOf(changes) {
149
+ return sha256(
150
+ JSON.stringify(changes.map((c) => [c.kind, c.file, c.find ?? "", c.replace ?? "", c.text ?? ""])),
151
+ ).slice(0, 16);
152
+ }
153
+
154
+ /** The files backpass must find untouched in the repo after synthesis: a moved write is a bug, loudly. */
155
+ export function repoFingerprint(repo, files) {
156
+ const out = {};
157
+ for (const relative of files) {
158
+ const absolute = path.join(repo.root, relative);
159
+ out[relative] = fs.existsSync(absolute) ? sha256(fs.readFileSync(absolute, "utf8")) : null;
160
+ }
161
+ return out;
162
+ }