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
package/src/redact.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Distilled traces are handed to a model the user configured, but they are still built
3
+ * from raw session logs. Obvious secret shapes are redacted before that happens
4
+ * (design section 9, privacy). This is a coarse net, not a guarantee: it catches the
5
+ * common token formats and `KEY=value` assignments that show up in shell transcripts.
6
+ */
7
+
8
+ const PATTERNS = [
9
+ [/\b(sk-ant-[A-Za-z0-9_-]{16,})/g, "ANTHROPIC_KEY"],
10
+ [/\b(sk-proj-[A-Za-z0-9_-]{16,})/g, "OPENAI_KEY"],
11
+ [/\b(sk-or-v1-[A-Za-z0-9_-]{16,})/g, "OPENROUTER_KEY"],
12
+ [/\b(sk-[A-Za-z0-9]{32,})/g, "API_KEY"],
13
+ [/\b(gh[pousr]_[A-Za-z0-9]{16,})/g, "GITHUB_TOKEN"],
14
+ [/\b(xox[abposr]-[A-Za-z0-9-]{10,})/g, "SLACK_TOKEN"],
15
+ [/\b(AKIA[0-9A-Z]{16})\b/g, "AWS_ACCESS_KEY_ID"],
16
+ [/\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, "JWT"],
17
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "PRIVATE_KEY"],
18
+ [
19
+ /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY)[A-Za-z0-9_]*)\s*[=:]\s*["']?([^\s"']{8,})["']?/gi,
20
+ "ASSIGNMENT",
21
+ ],
22
+ ];
23
+
24
+ export function redact(text) {
25
+ if (!text) return text;
26
+ let out = String(text);
27
+ for (const [pattern, label] of PATTERNS) {
28
+ out = out.replace(pattern, (match, first, second) => {
29
+ if (label !== "ASSIGNMENT") return `[redacted:${label}]`;
30
+ // A specific pattern above may already have replaced the value; keep its label.
31
+ if (typeof second === "string" && second.startsWith("[redacted")) return match;
32
+ return `${first}=[redacted]`;
33
+ });
34
+ }
35
+ return out;
36
+ }
package/src/repo.js ADDED
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ import { UserError } from "./logger.js";
6
+
7
+ function git(args, cwd) {
8
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
9
+ }
10
+
11
+ function realpathOrSelf(p) {
12
+ try {
13
+ return fs.realpathSync(p);
14
+ } catch {
15
+ return path.resolve(p);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Normalize a git remote to a comparable identity.
21
+ *
22
+ * git@github.com:kunchenguid/backpass.git -> github.com/kunchenguid/backpass
23
+ * https://github.com/kunchenguid/backpass -> github.com/kunchenguid/backpass
24
+ * /Users/kun/src/backpass -> /users/kun/src/backpass (local path remote)
25
+ */
26
+ export function normalizeRemote(remote) {
27
+ if (!remote) return null;
28
+ let s = String(remote).trim();
29
+ if (!s) return null;
30
+ s = s.replace(/^[a-z+]+:\/\//i, "");
31
+ s = s.replace(/^[^/@]+@/, "");
32
+ s = s.replace(/:(?=[^/])/, "/");
33
+ s = s.replace(/\.git\/?$/i, "");
34
+ s = s.replace(/\/+$/, "");
35
+ return s.toLowerCase() || null;
36
+ }
37
+
38
+ /** Worktree paths for the repo, realpath-normalized (design section 2.1, tier 1). */
39
+ function listWorktrees(root) {
40
+ let raw;
41
+ try {
42
+ raw = git(["worktree", "list", "--porcelain"], root);
43
+ } catch {
44
+ return [realpathOrSelf(root)];
45
+ }
46
+ const paths = [];
47
+ for (const line of raw.split("\n")) {
48
+ if (line.startsWith("worktree ")) paths.push(realpathOrSelf(line.slice("worktree ".length)));
49
+ }
50
+ if (!paths.length) paths.push(realpathOrSelf(root));
51
+ return [...new Set(paths)];
52
+ }
53
+
54
+ function listRemotes(root) {
55
+ let raw;
56
+ try {
57
+ raw = git(["remote", "-v"], root);
58
+ } catch {
59
+ return [];
60
+ }
61
+ const out = new Set();
62
+ for (const line of raw.split("\n")) {
63
+ const url = line.split(/\s+/)[1];
64
+ const norm = normalizeRemote(url);
65
+ if (norm) out.add(norm);
66
+ }
67
+ return [...out];
68
+ }
69
+
70
+ /**
71
+ * Ensure `line` is present in this repo's *local* git exclude file
72
+ * (`.git/info/exclude`) - never a tracked file the user owns. The path is
73
+ * resolved via `git rev-parse --git-path info/exclude` rather than assumed,
74
+ * since in a worktree or submodule `.git` is a file, not a directory, and
75
+ * `info/exclude` lives in the shared common git dir. Idempotent: a line
76
+ * already present is left alone.
77
+ *
78
+ * Returns `{ status: "added" | "present" | "no-git", path? }`.
79
+ */
80
+ export function ensureLocalExclude(root, line) {
81
+ let excludePath;
82
+ try {
83
+ excludePath = git(["rev-parse", "--git-path", "info/exclude"], root);
84
+ } catch {
85
+ return { status: "no-git" };
86
+ }
87
+ const resolved = path.isAbsolute(excludePath) ? excludePath : path.join(root, excludePath);
88
+
89
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
90
+ const current = fs.existsSync(resolved) ? fs.readFileSync(resolved, "utf8") : "";
91
+ if (current.split("\n").some((l) => l.trim() === line)) {
92
+ return { status: "present", path: resolved };
93
+ }
94
+ const separator = current && !current.endsWith("\n") ? "\n" : "";
95
+ fs.appendFileSync(resolved, `${separator}${line}\n`);
96
+ return { status: "added", path: resolved };
97
+ }
98
+
99
+ /**
100
+ * Repo identity used by every discovery adapter.
101
+ * `commonDir` distinguishes worktrees of the same repository.
102
+ */
103
+ export function resolveRepo(cwd = process.cwd()) {
104
+ let root;
105
+ try {
106
+ root = git(["rev-parse", "--show-toplevel"], cwd);
107
+ } catch {
108
+ throw new UserError("not inside a git repository", "backpass runs per-repo; cd into a repo and retry");
109
+ }
110
+ const realRoot = realpathOrSelf(root);
111
+ return {
112
+ root,
113
+ realRoot,
114
+ name: path.basename(realRoot),
115
+ worktrees: listWorktrees(root),
116
+ remotes: listRemotes(root),
117
+ };
118
+ }
package/src/sample.js ADDED
@@ -0,0 +1,99 @@
1
+ import { parseMaxTranscripts, parseSince } from "./config.js";
2
+ import { color, info } from "./logger.js";
3
+
4
+ /**
5
+ * Recency-weighted capping of the discovered transcript set.
6
+ *
7
+ * Analysis costs one model call per transcript, so a repo with a long history (or a
8
+ * `--since all` run) needs a bound. A plain "newest N" cut would silently erase the
9
+ * older history; instead, when discovery exceeds `maxTranscripts` we draw a weighted
10
+ * sample WITHOUT replacement where each transcript's weight decays exponentially with
11
+ * its age: weight = 2^(-age / halfLife). Recent sessions are almost always kept, old
12
+ * ones are still represented in proportion to their weight.
13
+ *
14
+ * Sampling uses the Efraimidis-Spirakis one-pass scheme: key = -ln(u) / weight with
15
+ * u ~ U(0, 1), keep the N smallest keys. That is exactly weighted sampling without
16
+ * replacement (an exponential race with rate = weight), needs no rejection loop, and is
17
+ * O(n log n). The RNG is a seedable splitmix32 so runs are reproducible with `--seed`.
18
+ *
19
+ * This module is pure: it never touches the cache, so evidence for a sampled transcript
20
+ * is reused by the analyzer exactly as before.
21
+ */
22
+
23
+ export const DEFAULT_SAMPLE_HALF_LIFE = "14d";
24
+
25
+ /** splitmix32: small, seedable, and uniform enough for sampling keys. */
26
+ export function seededRandom(seed) {
27
+ let state = Number(seed) >>> 0 || 0x9e3779b9;
28
+ return () => {
29
+ state = (state + 0x9e3779b9) | 0;
30
+ let t = state ^ (state >>> 16);
31
+ t = Math.imul(t, 0x21f0aaad);
32
+ t ^= t >>> 15;
33
+ t = Math.imul(t, 0x735a2d97);
34
+ t ^= t >>> 15;
35
+ return (t >>> 0) / 4294967296;
36
+ };
37
+ }
38
+
39
+ /** Epoch ms a transcript is dated to: the session start, else the file mtime. */
40
+ export function transcriptTime(transcript) {
41
+ const at = Number(transcript.startedAt);
42
+ if (Number.isFinite(at) && at > 0) return at;
43
+ const mtime = Number(transcript.mtimeMs);
44
+ return Number.isFinite(mtime) && mtime > 0 ? mtime : null;
45
+ }
46
+
47
+ /** 2^(-age / halfLife), clamped so an undated or ancient transcript still has a chance. */
48
+ export function recencyWeight(transcript, { now, halfLifeMs }) {
49
+ const at = transcriptTime(transcript);
50
+ if (at === null) return 1e-9;
51
+ const age = Math.max(0, now - at);
52
+ return Math.max(1e-9, Math.pow(2, -age / halfLifeMs));
53
+ }
54
+
55
+ /**
56
+ * Weighted sample without replacement of `count` transcripts. Returns the kept
57
+ * transcripts in their original (newest-first) order so downstream output is stable.
58
+ */
59
+ /**
60
+ * @param {object[]} transcripts
61
+ * @param {number | null} count
62
+ * @param {{ seed?: number, now?: number, halfLife?: string }} [options]
63
+ */
64
+ export function sampleTranscripts(
65
+ transcripts,
66
+ count,
67
+ { seed, now = Date.now(), halfLife = DEFAULT_SAMPLE_HALF_LIFE } = {},
68
+ ) {
69
+ if (count === null || transcripts.length <= count) return transcripts;
70
+ const random = seededRandom(seed ?? Math.floor(Math.random() * 0xffffffff));
71
+ const halfLifeMs = parseSince(halfLife) ?? Infinity;
72
+ const keyed = transcripts.map((transcript, index) => {
73
+ const u = random() || Number.EPSILON;
74
+ return { index, key: -Math.log(u) / recencyWeight(transcript, { now, halfLifeMs }) };
75
+ });
76
+ keyed.sort((a, b) => a.key - b.key);
77
+ const kept = new Set(keyed.slice(0, count).map((k) => k.index));
78
+ return transcripts.filter((_, index) => kept.has(index));
79
+ }
80
+
81
+ /**
82
+ * Apply the configured cap to a discovery result, reporting it on stderr when sampling
83
+ * actually happened. Under the cap the set passes through untouched and nothing is
84
+ * printed.
85
+ */
86
+ export function capTranscripts(result, config, { now = Date.now() } = {}) {
87
+ const cap = parseMaxTranscripts(config.maxTranscripts);
88
+ const discovered = result.transcripts.length;
89
+ if (cap === null || discovered <= cap) return result;
90
+ const sampled = sampleTranscripts(result.transcripts, cap, {
91
+ seed: config.seed ?? undefined,
92
+ now,
93
+ halfLife: config.sampleHalfLife,
94
+ });
95
+ info(
96
+ `${color.yellow("·")} discovered ${discovered} transcript(s), analyzing a recency-weighted sample of ${sampled.length} (--max-transcripts)`,
97
+ );
98
+ return { ...result, transcripts: sampled, sampledFrom: discovered };
99
+ }
package/src/skills.js ADDED
@@ -0,0 +1,207 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { estimateTokens } from "./tokens.js";
5
+
6
+ /**
7
+ * Skills as overflow (design section 7).
8
+ *
9
+ * A skill's description IS its when-useful condition: the description is always loaded
10
+ * and cheap, the body is free until the trigger fires. That makes extraction the release
11
+ * valve for the always-loaded budget - a 640-token procedure that matters in 4% of
12
+ * sessions becomes a 35-token description line.
13
+ *
14
+ * The placement rule the synthesis prompt encodes:
15
+ *
16
+ * | trigger detectable | trigger not detectable
17
+ * broad (>=20%) | memory file | memory file (must be ambient)
18
+ * narrow | SKILL | deletion candidate
19
+ */
20
+
21
+ export const BROAD_RELEVANCE_THRESHOLD = 0.2;
22
+
23
+ /** Read the existing skills so synthesis can tune a description instead of duplicating it. */
24
+ export function loadSkills(repoRoot, skillsDir) {
25
+ const root = path.join(repoRoot, skillsDir);
26
+ if (!fs.existsSync(root)) return [];
27
+
28
+ const skills = [];
29
+ let entries;
30
+ try {
31
+ entries = fs.readdirSync(root, { withFileTypes: true });
32
+ } catch {
33
+ return [];
34
+ }
35
+
36
+ for (const entry of entries) {
37
+ const file = entry.isDirectory()
38
+ ? path.join(root, entry.name, "SKILL.md")
39
+ : entry.name.endsWith(".md")
40
+ ? path.join(root, entry.name)
41
+ : null;
42
+ if (!file || !fs.existsSync(file)) continue;
43
+ let text;
44
+ try {
45
+ text = fs.readFileSync(file, "utf8");
46
+ } catch {
47
+ continue;
48
+ }
49
+ const frontmatter = parseFrontmatter(text);
50
+ skills.push({
51
+ name: frontmatter.name || entry.name.replace(/\.md$/, ""),
52
+ description: frontmatter.description || "",
53
+ path: path.relative(repoRoot, file),
54
+ bodyTokens: estimateTokens(text),
55
+ descriptionTokens: estimateTokens(frontmatter.description || ""),
56
+ });
57
+ }
58
+
59
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
60
+ }
61
+
62
+ /** Minimal YAML frontmatter reader: only `key: value` and folded multi-line values. */
63
+ export function parseFrontmatter(text) {
64
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
65
+ if (!match) return {};
66
+ const result = {};
67
+ let currentKey = null;
68
+ for (const line of match[1].split("\n")) {
69
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
70
+ if (kv) {
71
+ currentKey = kv[1];
72
+ result[currentKey] = kv[2].trim().replace(/^["']|["']$/g, "");
73
+ } else if (currentKey && /^\s+\S/.test(line)) {
74
+ result[currentKey] = `${result[currentKey]} ${line.trim()}`.trim();
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ export function renderSkillIndex(skills) {
81
+ if (!skills.length) return "(no skills directory found in this repo)";
82
+ return skills
83
+ .map(
84
+ (s) =>
85
+ `- ${s.name} (${s.bodyTokens} tok body, ${s.descriptionTokens} tok description) :: ${s.description || "(no description)"}`,
86
+ )
87
+ .join("\n");
88
+ }
89
+
90
+ /** Serialize a skill draft to the common SKILL.md shape. */
91
+ export function renderSkillFile(skill) {
92
+ const description = skill.description.replace(/\n+/g, " ").trim();
93
+ // Generated skills are reference material that fires on its description, never a
94
+ // slash-command: mark them non-invocable and internal so harnesses keep them out of
95
+ // the user-facing command list.
96
+ const frontmatter = [
97
+ `name: ${skill.name}`,
98
+ `description: ${description}`,
99
+ "user-invocable: false",
100
+ "metadata:",
101
+ " internal: true",
102
+ ].join("\n");
103
+ return `---\n${frontmatter}\n---\n\n${skill.body.trim()}\n`;
104
+ }
105
+
106
+ /**
107
+ * The budget arithmetic that makes extraction worth it, reported per edit:
108
+ * "-1,900 tok always-loaded, +140 tok description".
109
+ */
110
+ export function extractionBudgetEffect(edit) {
111
+ if (edit.kind !== "extract" || !edit.skill) return null;
112
+ const pairs = Array.isArray(edit.hunks) ? edit.hunks : [edit];
113
+ const removedFromMemory = pairs.reduce((sum, p) => sum + estimateTokens(p.find) - estimateTokens(p.replace), 0);
114
+ const descriptionCost = estimateTokens(edit.skill.description);
115
+ return {
116
+ alwaysLoadedDelta: -removedFromMemory,
117
+ descriptionCost,
118
+ net: descriptionCost - removedFromMemory,
119
+ skillBodyTokens: estimateTokens(edit.skill.body),
120
+ };
121
+ }
122
+
123
+ /** Where agents actually auto-load skills from: the AGENTS.md convention. */
124
+ export const CANONICAL_SKILLS_DIR = ".agents/skills";
125
+ /** Claude reads this path; it is kept as a symlink into the canonical dir, never a copy. */
126
+ export const CLAUDE_SKILLS_LINK = ".claude/skills";
127
+ export const CLAUDE_SKILLS_LINK_TARGET = path.posix.join("..", CANONICAL_SKILLS_DIR);
128
+
129
+ /**
130
+ * Pick the directory skill extractions target.
131
+ *
132
+ * Skills only pay off if the harness loads them, so the answer is the canonical
133
+ * `.agents/skills` (mirrored to `.claude/skills` by symlink) unless the user explicitly
134
+ * configured another directory that already exists. The bare `skills/` dir is an
135
+ * installer/public convention that no harness auto-loads, so it is never auto-detected -
136
+ * it is only honored when named in the config. Resolution is read-only; the layout is
137
+ * created at write time (`ensureSkillsLayout`), which keeps every pre-apply stage
138
+ * side-effect free.
139
+ */
140
+ export function resolveOverflowTarget(repoRoot, skillsDir = CANONICAL_SKILLS_DIR) {
141
+ const warnings = [];
142
+ const claude = inspectClaudeSkillsLink(repoRoot);
143
+ if (claude.state === "dir") warnings.push(claudeSkillsDirWarning());
144
+
145
+ const explicit = skillsDir && skillsDir !== CANONICAL_SKILLS_DIR && skillsDir !== CLAUDE_SKILLS_LINK;
146
+ if (explicit && fs.existsSync(path.join(repoRoot, skillsDir))) {
147
+ return { kind: "skills", dir: skillsDir, warnings };
148
+ }
149
+ return { kind: "skills", dir: CANONICAL_SKILLS_DIR, warnings };
150
+ }
151
+
152
+ function claudeSkillsDirWarning() {
153
+ return (
154
+ `${CLAUDE_SKILLS_LINK} is a real directory, not a symlink to ${CLAUDE_SKILLS_LINK_TARGET}; ` +
155
+ `left untouched. Claude will not see skills written to ${CANONICAL_SKILLS_DIR} until you ` +
156
+ `merge it in and replace it with the symlink (ln -s ${CLAUDE_SKILLS_LINK_TARGET} ${CLAUDE_SKILLS_LINK}).`
157
+ );
158
+ }
159
+
160
+ function inspectClaudeSkillsLink(repoRoot) {
161
+ let stat;
162
+ try {
163
+ stat = fs.lstatSync(path.join(repoRoot, CLAUDE_SKILLS_LINK));
164
+ } catch {
165
+ return { state: "missing" };
166
+ }
167
+ if (stat.isSymbolicLink()) return { state: "symlink" };
168
+ if (stat.isDirectory()) return { state: "dir" };
169
+ return { state: "other" };
170
+ }
171
+
172
+ /**
173
+ * Create the canonical skills dir and the `.claude/skills -> ../.agents/skills` symlink
174
+ * so both harness families load the same files with no duplication. An existing
175
+ * `.claude/skills` is never clobbered: a symlink (to anywhere) is left as is, and a real
176
+ * directory is reported so the user can merge it by hand.
177
+ */
178
+ export function ensureSkillsLayout(repoRoot) {
179
+ const created = [];
180
+ const warnings = [];
181
+ const canonical = path.join(repoRoot, CANONICAL_SKILLS_DIR);
182
+ if (!fs.existsSync(canonical)) {
183
+ fs.mkdirSync(canonical, { recursive: true });
184
+ created.push(CANONICAL_SKILLS_DIR);
185
+ }
186
+
187
+ const claude = inspectClaudeSkillsLink(repoRoot);
188
+ if (claude.state === "missing") {
189
+ const link = path.join(repoRoot, CLAUDE_SKILLS_LINK);
190
+ fs.mkdirSync(path.dirname(link), { recursive: true });
191
+ fs.symlinkSync(CLAUDE_SKILLS_LINK_TARGET, link, "dir");
192
+ created.push(`${CLAUDE_SKILLS_LINK} -> ${CLAUDE_SKILLS_LINK_TARGET}`);
193
+ } else if (claude.state === "dir") {
194
+ warnings.push(claudeSkillsDirWarning());
195
+ }
196
+ return { created, warnings };
197
+ }
198
+
199
+ /** Write an accepted skill extraction to disk, setting up the load layout on first use. */
200
+ export function writeSkill(repoRoot, skill) {
201
+ const inCanonical = skill.path === CANONICAL_SKILLS_DIR || skill.path.startsWith(`${CANONICAL_SKILLS_DIR}/`);
202
+ const layout = inCanonical ? ensureSkillsLayout(repoRoot) : { created: [], warnings: [] };
203
+ const target = path.join(repoRoot, skill.path);
204
+ fs.mkdirSync(path.dirname(target), { recursive: true });
205
+ fs.writeFileSync(target, renderSkillFile(skill));
206
+ return { target, ...layout };
207
+ }
package/src/state.js ADDED
@@ -0,0 +1,202 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ import { STATE_DIRNAME } from "./config.js";
6
+ import { warn } from "./logger.js";
7
+ import { ensureLocalExclude } from "./repo.js";
8
+
9
+ /** The line every command writes to the repo's local git exclude for the state dir. */
10
+ export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
11
+
12
+ /**
13
+ * All mutable run state lives in a `.backpass/` directory, kept out of git via the
14
+ * repo's local exclude (`.git/info/exclude`, written idempotently by `ensure()` on every
15
+ * command, so a plain `backpass` run with no prior `init` is excluded too) rather than
16
+ * the tracked `.gitignore`:
17
+ *
18
+ * scan-cache.json path+mtime+size -> association verdict (design section 2.2)
19
+ * evidence/<id>.json per-transcript tier-1 analysis output (design section 3)
20
+ * evidence-summary.json folded evidence (stage 2)
21
+ * proposal.json latest tier-2 synthesis (stage 3)
22
+ * rejections.json edits the human rejected, and the evidence weight behind them
23
+ * gap-ledger.json gap observations by gap and session, accumulated across runs (src/gap-ledger.js)
24
+ * agent-probe-cache.json TTL'd availability/auth verdicts per agent|model (src/agents.js)
25
+ * prompts/ the exact prompts of the last run, one file per model turn
26
+ * synthesis/ the staging copy the synthesis agent edits natively (src/workspace.js)
27
+ * apply/ the rendered Lavish apply surface
28
+ */
29
+ export class State {
30
+ constructor(repoRoot) {
31
+ this.root = path.join(repoRoot, STATE_DIRNAME);
32
+ this.evidenceDir = path.join(this.root, "evidence");
33
+ this.applyDir = path.join(this.root, "apply");
34
+ this.scanCachePath = path.join(this.root, "scan-cache.json");
35
+ this.summaryPath = path.join(this.root, "evidence-summary.json");
36
+ this.proposalPath = path.join(this.root, "proposal.json");
37
+ this.rejectionsPath = path.join(this.root, "rejections.json");
38
+ this.gapLedgerPath = path.join(this.root, "gap-ledger.json");
39
+ this.probeCachePath = path.join(this.root, "agent-probe-cache.json");
40
+ }
41
+
42
+ /**
43
+ * Creates the state dir and excludes it from git in the same step. The exclude is
44
+ * local-only and fail-soft: a non-git directory is silently left alone.
45
+ */
46
+ ensure() {
47
+ fs.mkdirSync(this.evidenceDir, { recursive: true });
48
+ fs.mkdirSync(this.applyDir, { recursive: true });
49
+ this.exclude = ensureLocalExclude(path.dirname(this.root), STATE_EXCLUDE_LINE);
50
+ return this;
51
+ }
52
+
53
+ readJsonFile(file, fallback) {
54
+ if (!fs.existsSync(file)) return fallback;
55
+ try {
56
+ return JSON.parse(fs.readFileSync(file, "utf8"));
57
+ } catch (err) {
58
+ warn(`discarding corrupt state file ${path.relative(process.cwd(), file)}: ${err.message}`);
59
+ return fallback;
60
+ }
61
+ }
62
+
63
+ writeJsonFile(file, value) {
64
+ fs.mkdirSync(path.dirname(file), { recursive: true });
65
+ const tmp = `${file}.tmp`;
66
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
67
+ fs.renameSync(tmp, file);
68
+ }
69
+
70
+ readScanCache() {
71
+ const cache = this.readJsonFile(this.scanCachePath, null);
72
+ return cache && cache.version === 1 ? cache : { version: 1, entries: {} };
73
+ }
74
+
75
+ writeScanCache(cache) {
76
+ this.writeJsonFile(this.scanCachePath, cache);
77
+ }
78
+
79
+ evidencePath(transcriptId) {
80
+ return path.join(this.evidenceDir, `${safeFileName(transcriptId)}.json`);
81
+ }
82
+
83
+ readEvidence(transcriptId) {
84
+ return this.readJsonFile(this.evidencePath(transcriptId), null);
85
+ }
86
+
87
+ writeEvidence(transcriptId, evidence) {
88
+ this.writeJsonFile(this.evidencePath(transcriptId), evidence);
89
+ }
90
+
91
+ listEvidence() {
92
+ if (!fs.existsSync(this.evidenceDir)) return [];
93
+ return fs
94
+ .readdirSync(this.evidenceDir)
95
+ .filter((f) => f.endsWith(".json"))
96
+ .map((f) => this.readJsonFile(path.join(this.evidenceDir, f), null))
97
+ .filter(Boolean);
98
+ }
99
+
100
+ readSummary() {
101
+ return this.readJsonFile(this.summaryPath, null);
102
+ }
103
+
104
+ writeSummary(summary) {
105
+ this.writeJsonFile(this.summaryPath, summary);
106
+ }
107
+
108
+ readProposal() {
109
+ return this.readJsonFile(this.proposalPath, null);
110
+ }
111
+
112
+ writeProposal(proposal) {
113
+ this.writeJsonFile(this.proposalPath, proposal);
114
+ }
115
+
116
+ readRejections() {
117
+ const value = this.readJsonFile(this.rejectionsPath, null);
118
+ return value && value.version === 1 ? value : { version: 1, entries: {} };
119
+ }
120
+
121
+ writeRejections(rejections) {
122
+ this.writeJsonFile(this.rejectionsPath, rejections);
123
+ }
124
+
125
+ /** Fail-soft: a missing or corrupt ledger starts empty and is rebuilt from this run's evidence. */
126
+ readGapLedger() {
127
+ const value = this.readJsonFile(this.gapLedgerPath, null);
128
+ return value && value.version === 1 && value.entries && typeof value.entries === "object"
129
+ ? value
130
+ : { version: 1, entries: {} };
131
+ }
132
+
133
+ writeGapLedger(ledger) {
134
+ this.writeJsonFile(this.gapLedgerPath, ledger);
135
+ }
136
+
137
+ readProbeCache() {
138
+ const value = this.readJsonFile(this.probeCachePath, null);
139
+ return value && value.version === 1 ? value : { version: 1, acpxVersion: null, entries: {} };
140
+ }
141
+
142
+ writeProbeCache(cache) {
143
+ this.writeJsonFile(this.probeCachePath, cache);
144
+ }
145
+ }
146
+
147
+ export function safeFileName(id) {
148
+ return String(id)
149
+ .replace(/[^A-Za-z0-9._-]/g, "_")
150
+ .slice(0, 120);
151
+ }
152
+
153
+ export function sha256(text) {
154
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex");
155
+ }
156
+
157
+ /**
158
+ * Cache key for a transcript's analysis: the transcript's own content signature plus
159
+ * the memory-file hash it was judged against. Either changing invalidates the evidence.
160
+ */
161
+ export function evidenceKey(transcript, memoryHash) {
162
+ return `${transcript.mtimeMs}:${transcript.bytes}:${memoryHash}`;
163
+ }
164
+
165
+ /**
166
+ * Only a successful analysis is worth caching. A `failed` entry is retried, and a
167
+ * `skipped` entry is re-derived because the skip decision depends on configuration
168
+ * (`minUserTurns`) rather than on the model - recomputing it costs one local file read.
169
+ */
170
+ export function isEvidenceFresh(evidence, transcript, memoryHash) {
171
+ if (!evidence || evidence.status !== "ok") return false;
172
+ return evidence.key === evidenceKey(transcript, memoryHash);
173
+ }
174
+
175
+ /**
176
+ * A rejected edit stays rejected until materially new evidence arrives - the design's
177
+ * replacement for a DEFER button (captain tweak 3). "Materially new" means the edit is
178
+ * backed by strictly more transcripts than when it was turned down.
179
+ */
180
+ export function rejectionKey(edit) {
181
+ const body = Array.isArray(edit.hunks)
182
+ ? edit.hunks.map((h) => `${h.find}\u0000${h.replace}`).join("\u0001")
183
+ : `${edit.find || ""}\u0000${edit.replace || ""}`;
184
+ return sha256([edit.kind, edit.file, body].join(" ")).slice(0, 16);
185
+ }
186
+
187
+ export function isSuppressedByRejection(edit, rejections) {
188
+ const prior = rejections.entries[rejectionKey(edit)];
189
+ if (!prior) return false;
190
+ return (edit.transcripts || 0) <= (prior.transcripts || 0);
191
+ }
192
+
193
+ export function recordRejection(edit, rejections, at = new Date().toISOString()) {
194
+ rejections.entries[rejectionKey(edit)] = {
195
+ kind: edit.kind,
196
+ file: edit.file,
197
+ title: edit.title,
198
+ transcripts: edit.transcripts || 0,
199
+ rejectedAt: at,
200
+ };
201
+ return rejections;
202
+ }