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.
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/bin/backpass.js +4 -0
- package/package.json +62 -0
- package/src/acpx.js +576 -0
- package/src/agents.js +389 -0
- package/src/analyze.js +289 -0
- package/src/apply/lavish.js +128 -0
- package/src/apply/terminal.js +119 -0
- package/src/apply/writer.js +101 -0
- package/src/bootstrap.js +74 -0
- package/src/cli.js +261 -0
- package/src/commands/analyze.js +88 -0
- package/src/commands/apply.js +103 -0
- package/src/commands/bootstrap.js +172 -0
- package/src/commands/init.js +59 -0
- package/src/commands/propose.js +136 -0
- package/src/commands/run.js +95 -0
- package/src/commands/scan.js +90 -0
- package/src/commands/status.js +143 -0
- package/src/commands/usage.js +25 -0
- package/src/config.js +249 -0
- package/src/diff.js +305 -0
- package/src/discovery/adapters/claude.js +77 -0
- package/src/discovery/adapters/codex.js +162 -0
- package/src/discovery/adapters/cursor-cli.js +109 -0
- package/src/discovery/adapters/cursor-ide.js +130 -0
- package/src/discovery/adapters/grok.js +107 -0
- package/src/discovery/adapters/opencode.js +151 -0
- package/src/discovery/adapters/pi.js +87 -0
- package/src/discovery/adapters/shared.js +195 -0
- package/src/discovery/adapters/sqlite.js +50 -0
- package/src/discovery/association.js +100 -0
- package/src/discovery/index.js +226 -0
- package/src/discovery/self.js +62 -0
- package/src/distill.js +182 -0
- package/src/fold.js +214 -0
- package/src/gap-ledger.js +174 -0
- package/src/logger.js +74 -0
- package/src/memory.js +244 -0
- package/src/progress.js +29 -0
- package/src/prompts/analysis.md +48 -0
- package/src/prompts/annotate.md +48 -0
- package/src/prompts/synthesis.md +98 -0
- package/src/prompts.js +36 -0
- package/src/proposal.js +430 -0
- package/src/redact.js +36 -0
- package/src/repo.js +118 -0
- package/src/sample.js +99 -0
- package/src/skills.js +207 -0
- package/src/state.js +202 -0
- package/src/subprocess.js +47 -0
- package/src/synthesize.js +287 -0
- package/src/tokens.js +48 -0
- package/src/tui/index.js +336 -0
- package/src/tui/render.js +487 -0
- package/src/tui/term.js +130 -0
- package/src/tui/theme.js +111 -0
- package/src/workspace.js +162 -0
- 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
|
+
}
|