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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { discoverTranscripts } from "../discovery/index.js";
|
|
2
|
+
import { color, info, json, out } from "../logger.js";
|
|
3
|
+
|
|
4
|
+
/** Shared by every command that needs the transcript set. */
|
|
5
|
+
export async function discoverForRun(ctx) {
|
|
6
|
+
const { repo, config, strict } = ctx;
|
|
7
|
+
const result = await discoverTranscripts({ repo, config, strict });
|
|
8
|
+
if (ctx.limit && result.transcripts.length > ctx.limit) {
|
|
9
|
+
result.truncated = result.transcripts.length - ctx.limit;
|
|
10
|
+
result.transcripts = result.transcripts.slice(0, ctx.limit);
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ago(ms) {
|
|
16
|
+
if (!ms) return "-";
|
|
17
|
+
const days = Math.floor((Date.now() - ms) / 86_400_000);
|
|
18
|
+
if (days <= 0) return "today";
|
|
19
|
+
if (days === 1) return "1d ago";
|
|
20
|
+
return `${days}d ago`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function cmdScan(ctx) {
|
|
24
|
+
const { transcripts, perHarness, truncated } = await discoverForRun(ctx);
|
|
25
|
+
|
|
26
|
+
if (ctx.flags.json) {
|
|
27
|
+
json({ repo: ctx.repo.name, perHarness, transcripts });
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
out(`${ctx.repo.name} · ${ctx.repo.worktrees.length} worktree(s) · since ${ctx.config.discovery.since}`);
|
|
32
|
+
out("");
|
|
33
|
+
|
|
34
|
+
const rows = [["HARNESS", "SCANNED", "MATCHED", "SELF", "CACHED", "NOTE"]];
|
|
35
|
+
for (const [harness, stats] of Object.entries(perHarness)) {
|
|
36
|
+
rows.push([
|
|
37
|
+
harness,
|
|
38
|
+
String(stats.scanned),
|
|
39
|
+
String(stats.matched),
|
|
40
|
+
String(stats.self || 0),
|
|
41
|
+
String(stats.cached),
|
|
42
|
+
stats.error ? `unreadable: ${stats.error}` : "",
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
out(table(rows));
|
|
46
|
+
const selfTotal = Object.values(perHarness).reduce((n, s) => n + (s.self || 0), 0);
|
|
47
|
+
if (selfTotal) out(color.dim(` SELF = backpass's own loss / gradient-descent sessions, excluded from the corpus`));
|
|
48
|
+
out("");
|
|
49
|
+
|
|
50
|
+
const byTier = { 1: 0, 2: 0, 3: 0 };
|
|
51
|
+
for (const t of transcripts) byTier[t.association.tier] += 1;
|
|
52
|
+
out(
|
|
53
|
+
`${transcripts.length} transcript(s) associated with this repo · ` +
|
|
54
|
+
`tier1 ${byTier[1]} (exact) · tier2 ${byTier[2]} (remote) · tier3 ${byTier[3]} (best-effort)`,
|
|
55
|
+
);
|
|
56
|
+
if (byTier[3] && !ctx.strict) out(color.dim(" re-run with --strict to exclude the best-effort tier"));
|
|
57
|
+
if (truncated) out(color.dim(` --limit ${ctx.limit} is hiding ${truncated} more transcript(s)`));
|
|
58
|
+
out("");
|
|
59
|
+
|
|
60
|
+
const preview = transcripts.slice(0, 25);
|
|
61
|
+
const detail = [["HARNESS", "SESSION", "WHEN", "SIZE", "TIER", "HOW"]];
|
|
62
|
+
for (const t of preview) {
|
|
63
|
+
detail.push([
|
|
64
|
+
t.harness,
|
|
65
|
+
t.nativeId.slice(0, 12),
|
|
66
|
+
ago(t.mtimeMs),
|
|
67
|
+
t.bytes ? `${Math.round(t.bytes / 1024)}KB` : "-",
|
|
68
|
+
`t${t.association.tier}`,
|
|
69
|
+
t.association.reason,
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
out(table(detail));
|
|
73
|
+
if (transcripts.length > preview.length) {
|
|
74
|
+
info(color.dim(` ... and ${transcripts.length - preview.length} more`));
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function table(rows) {
|
|
80
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => String(r[i] ?? "").length)));
|
|
81
|
+
return rows
|
|
82
|
+
.map((row, rowIndex) => {
|
|
83
|
+
const line = row
|
|
84
|
+
.map((cell, i) => String(cell ?? "").padEnd(widths[i]))
|
|
85
|
+
.join(" ")
|
|
86
|
+
.trimEnd();
|
|
87
|
+
return rowIndex === 0 ? color.dim(line) : line;
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { color, json, out } from "../logger.js";
|
|
5
|
+
import { resolveMemoryFiles } from "../memory.js";
|
|
6
|
+
import { loadSkills, resolveOverflowTarget } from "../skills.js";
|
|
7
|
+
import { budgetBar, budgetStatus, formatTokens } from "../tokens.js";
|
|
8
|
+
import { table } from "./scan.js";
|
|
9
|
+
import { DEFAULT_EFFORT } from "../config.js";
|
|
10
|
+
import { candidateKey, isProbeEntryFresh } from "../agents.js";
|
|
11
|
+
|
|
12
|
+
export async function cmdStatus(ctx) {
|
|
13
|
+
const { repo, config } = ctx;
|
|
14
|
+
const state = config.state;
|
|
15
|
+
|
|
16
|
+
const resolved = resolveMemoryFiles(repo.root, config.memoryFiles);
|
|
17
|
+
const files = resolved.all;
|
|
18
|
+
const evidence = state.listEvidence();
|
|
19
|
+
const counts = { ok: 0, failed: 0, skipped: 0 };
|
|
20
|
+
for (const e of evidence) counts[e.status] = (counts[e.status] || 0) + 1;
|
|
21
|
+
|
|
22
|
+
const cache = state.readScanCache();
|
|
23
|
+
const summary = state.readSummary();
|
|
24
|
+
const proposal = state.readProposal();
|
|
25
|
+
const rejections = state.readRejections();
|
|
26
|
+
const overflow = resolveOverflowTarget(repo.root, config.skillsDir);
|
|
27
|
+
const skills = loadSkills(repo.root, overflow.dir);
|
|
28
|
+
|
|
29
|
+
const budgets = files.map((file) => ({
|
|
30
|
+
path: file.path,
|
|
31
|
+
...budgetStatus(file.text, null, config.budgetTokens),
|
|
32
|
+
instructions: file.units.length,
|
|
33
|
+
pointerTo: resolved.pointers.includes(file) ? resolved.primary.path : null,
|
|
34
|
+
separate: resolved.separate.includes(file),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
if (ctx.flags.json) {
|
|
38
|
+
json({
|
|
39
|
+
repo: repo.name,
|
|
40
|
+
budgets,
|
|
41
|
+
evidence: counts,
|
|
42
|
+
scanCacheEntries: Object.keys(cache.entries).length,
|
|
43
|
+
summary: summary ? { analyzedSessions: summary.analyzedSessions, totals: summary.totals } : null,
|
|
44
|
+
proposal: proposal ? { generatedAt: proposal.generatedAt, edits: proposal.edits.length } : null,
|
|
45
|
+
rejections: Object.keys(rejections.entries).length,
|
|
46
|
+
skills: skills.length,
|
|
47
|
+
});
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
out(`${color.bold(repo.name)} ${color.dim(repo.root)}`);
|
|
52
|
+
out("");
|
|
53
|
+
|
|
54
|
+
out(color.dim("BUDGET (always-loaded)"));
|
|
55
|
+
if (!budgets.length) out(" no memory file found");
|
|
56
|
+
for (const b of budgets) {
|
|
57
|
+
if (b.pointerTo) {
|
|
58
|
+
out(` ${b.path.padEnd(14)} ${color.dim(`pointer to ${b.pointerTo}`)}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const state_ =
|
|
62
|
+
(b.withinBudget ? "" : color.red(` ${b.over} OVER`)) +
|
|
63
|
+
(b.separate ? color.yellow(" separate - not optimized") : "");
|
|
64
|
+
out(
|
|
65
|
+
` ${b.path.padEnd(14)} ${budgetBar(b)} ${formatTokens(b.current)} / ${formatTokens(b.capTokens)} tok` +
|
|
66
|
+
` · ${b.instructions} instructions${state_}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (skills.length) {
|
|
70
|
+
const skillTokens = skills.reduce((n, s) => n + s.bodyTokens, 0);
|
|
71
|
+
const descTokens = skills.reduce((n, s) => n + s.descriptionTokens, 0);
|
|
72
|
+
out(
|
|
73
|
+
color.dim(
|
|
74
|
+
` overflow: ${skills.length} skill(s) in ${overflow.dir} · ${formatTokens(skillTokens)} tok on trigger, ` +
|
|
75
|
+
`${formatTokens(descTokens)} tok always loaded`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
out("");
|
|
80
|
+
|
|
81
|
+
out(color.dim("CACHE"));
|
|
82
|
+
out(` scan cache ${Object.keys(cache.entries).length} file(s) fingerprinted`);
|
|
83
|
+
out(
|
|
84
|
+
` evidence ${counts.ok || 0} ok · ${counts.skipped || 0} skipped · ${color.red(String(counts.failed || 0))} failed`,
|
|
85
|
+
);
|
|
86
|
+
if (summary) {
|
|
87
|
+
out(
|
|
88
|
+
` gradients ${summary.analyzedSessions} session(s) · ${summary.totals.positive}+ ` +
|
|
89
|
+
`${summary.totals.negative}- · ${summary.totals.gapClusters} gap cluster(s)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
out(` rejections ${Object.keys(rejections.entries).length} remembered`);
|
|
93
|
+
out("");
|
|
94
|
+
|
|
95
|
+
if (counts.failed) {
|
|
96
|
+
out(color.dim("FAILED TRANSCRIPTS (retried on the next run)"));
|
|
97
|
+
const rows = [["HARNESS", "SESSION", "ERROR"]];
|
|
98
|
+
for (const e of evidence.filter((x) => x.status === "failed").slice(0, 10)) {
|
|
99
|
+
rows.push([e.transcript.harness, String(e.transcript.id).slice(-12), String(e.error).slice(0, 60)]);
|
|
100
|
+
}
|
|
101
|
+
out(table(rows));
|
|
102
|
+
out("");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
out(color.dim("PROPOSAL"));
|
|
106
|
+
if (!proposal) {
|
|
107
|
+
out(" none yet - run `backpass`");
|
|
108
|
+
} else {
|
|
109
|
+
out(` generated ${proposal.generatedAt}`);
|
|
110
|
+
out(
|
|
111
|
+
` edits ${proposal.edits.length}${proposal.violations?.length ? color.red(" (failed its gates)") : ""}`,
|
|
112
|
+
);
|
|
113
|
+
const surface = path.join(state.applyDir, "apply.html");
|
|
114
|
+
if (fs.existsSync(surface)) out(color.dim(` surface ${surface}`));
|
|
115
|
+
if (!proposal.violations?.length && proposal.edits.length) out(" review with `backpass apply`");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
out("");
|
|
119
|
+
out(color.dim("MODELS"));
|
|
120
|
+
for (const role of ["analysis", "synthesis"]) out(` ${role.padEnd(10)} ${describeRole(config, role)}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The pick for a role without probing: a pinned agent as configured, otherwise the
|
|
126
|
+
* ladder with whatever the probe cache already knows. `status` must stay instant.
|
|
127
|
+
*/
|
|
128
|
+
function describeRole(config, role) {
|
|
129
|
+
const pinned = config.agents.pinned(role);
|
|
130
|
+
const effort = config[role].effort || DEFAULT_EFFORT[role];
|
|
131
|
+
if (pinned) {
|
|
132
|
+
return `${pinned.agent}${pinned.model ? `/${pinned.model}` : ""} (effort ${effort}, ${pinned.reason})`;
|
|
133
|
+
}
|
|
134
|
+
const cache = config.state.readProbeCache();
|
|
135
|
+
for (const candidate of config.agents.ladder(role)) {
|
|
136
|
+
const entry = cache.entries[candidateKey(candidate)];
|
|
137
|
+
if (!isProbeEntryFresh(entry)) continue;
|
|
138
|
+
if (entry.verdict === "ok") {
|
|
139
|
+
return `${candidate.agent}/${entry.resolvedModel || candidate.model} (effort ${effort}, auto - probed ${entry.checkedAt.slice(0, 16).replace("T", " ")})`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return color.dim(`auto - ${config.agents.ladder(role).length} candidates, none probed yet (effort ${effort})`);
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describeUsage } from "../acpx.js";
|
|
2
|
+
import { color, out } from "../logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Print the model-usage accounting for a run, one line per pass that actually made
|
|
6
|
+
* calls: tier-1 (the per-transcript analysis pass) then tier-2 (synthesis). This is the
|
|
7
|
+
* only place that prints these lines - `run` and `propose` both end in `printProposal`,
|
|
8
|
+
* and `analyze` prints just its own tier through the same helper - so the accounting
|
|
9
|
+
* appears exactly once, in order, before the apply hint.
|
|
10
|
+
*
|
|
11
|
+
* A pass that made no calls this run (all evidence cached, or analysis ran in an earlier
|
|
12
|
+
* command) prints nothing rather than a meaningless "n/a"; a pass whose harness returned
|
|
13
|
+
* no usage says so by name (see `describeUsage`).
|
|
14
|
+
*
|
|
15
|
+
* @param {{ tier1?: import("../acpx.js").UsageRecord[], tier2?: import("../acpx.js").UsageRecord[] }} usage
|
|
16
|
+
*/
|
|
17
|
+
export function printUsage({ tier1 = [], tier2 = [] } = {}) {
|
|
18
|
+
const lines = [
|
|
19
|
+
["tier-1", describeUsage(tier1)],
|
|
20
|
+
["tier-2", describeUsage(tier2)],
|
|
21
|
+
].filter(([, text]) => text);
|
|
22
|
+
if (!lines.length) return;
|
|
23
|
+
out("");
|
|
24
|
+
for (const [tier, text] of lines) out(color.dim(` ${tier} tokens: ${text}`));
|
|
25
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
import { UserError, warn } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILENAME = ".backpassrc.json";
|
|
8
|
+
export const STATE_DIRNAME = ".backpass";
|
|
9
|
+
|
|
10
|
+
export const ALL_HARNESSES = ["claude", "codex", "pi", "opencode", "grok", "cursor"];
|
|
11
|
+
/** Cursor IDE is deferred to v1.1 and only ever runs behind --include-cursor-ide. */
|
|
12
|
+
export const OPT_IN_HARNESSES = ["cursor-ide"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The ordered candidate ladders behind the auto-pick (ordered-defaults design, section 8.2).
|
|
16
|
+
* Each rung is one model id served by several harnesses in preference order; rungs are
|
|
17
|
+
* flattened model-outer / harness-inner, and the first candidate that is installed,
|
|
18
|
+
* authenticated, and serves the model wins. Model ids are the bare ids the vendors use;
|
|
19
|
+
* per-harness spellings (`openai-codex/...`, `openai/...`, `xai/...`) are resolved at
|
|
20
|
+
* probe time against what the adapter advertises - never hard-coded. Ladders live in
|
|
21
|
+
* config so a user can reorder or shorten them in `.backpassrc.json` without a release.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_LADDERS = {
|
|
24
|
+
analysis: [
|
|
25
|
+
{ model: "gpt-5.6-luna", agents: ["pi", "opencode", "codex"] },
|
|
26
|
+
{ model: "claude-sonnet-5", agents: ["claude"] },
|
|
27
|
+
{ model: "grok-4.6", agents: ["pi", "opencode", "grok"] },
|
|
28
|
+
],
|
|
29
|
+
synthesis: [
|
|
30
|
+
{ model: "gpt-5.6-sol", agents: ["pi", "opencode", "codex"] },
|
|
31
|
+
{ model: "claude-opus-5", agents: ["claude"] },
|
|
32
|
+
{ model: "grok-4.6", agents: ["pi", "opencode", "grok"] },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Applied to a role whenever no layer set an effort, auto-picked or pinned. */
|
|
37
|
+
export const DEFAULT_EFFORT = { analysis: "medium", synthesis: "high" };
|
|
38
|
+
|
|
39
|
+
/** What `--no-auto-agent` pins: the pre-ladder fixed defaults. */
|
|
40
|
+
export const LEGACY_DEFAULT_AGENTS = { analysis: "codex", synthesis: "claude" };
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_CONFIG = {
|
|
43
|
+
memoryFiles: ["AGENTS.md", "CLAUDE.md"],
|
|
44
|
+
budgetTokens: 5000,
|
|
45
|
+
skillsDir: ".agents/skills",
|
|
46
|
+
/** `null` means adaptive: see `effectiveMaxEdits` in proposal.js. An integer pins it. */
|
|
47
|
+
maxEditsPerRun: null,
|
|
48
|
+
minGapEvidence: 2,
|
|
49
|
+
/**
|
|
50
|
+
* Gap observations accumulate across runs in `.backpass/gap-ledger.json` so the
|
|
51
|
+
* `minGapEvidence` bar counts distinct sessions over time, not per run. A session's
|
|
52
|
+
* observations retire past this age (a duration like 90d, `all` to never expire).
|
|
53
|
+
*/
|
|
54
|
+
gapLedgerMaxAge: "90d",
|
|
55
|
+
/**
|
|
56
|
+
* Cap on transcripts analyzed per run; past it a recency-weighted sample is drawn
|
|
57
|
+
* (`src/sample.js`). `0` or "all" disables the cap. `sampleHalfLife` is the age at
|
|
58
|
+
* which a transcript's sampling weight halves; `seed` makes the sample reproducible.
|
|
59
|
+
*/
|
|
60
|
+
maxTranscripts: 100,
|
|
61
|
+
sampleHalfLife: "14d",
|
|
62
|
+
seed: null,
|
|
63
|
+
/**
|
|
64
|
+
* `agent: null` means auto-pick from `ladders[role]`; `effort: null` means
|
|
65
|
+
* `DEFAULT_EFFORT[role]`. Setting `agent` pins the role and skips the ladder.
|
|
66
|
+
*/
|
|
67
|
+
analysis: { agent: null, model: null, effort: null },
|
|
68
|
+
synthesis: { agent: null, model: null, effort: null },
|
|
69
|
+
autoAgent: true,
|
|
70
|
+
ladders: DEFAULT_LADDERS,
|
|
71
|
+
discovery: {
|
|
72
|
+
harnesses: ALL_HARNESSES,
|
|
73
|
+
since: "30d",
|
|
74
|
+
worktreeGlobs: [],
|
|
75
|
+
minUserTurns: 2,
|
|
76
|
+
includeCursorIde: false,
|
|
77
|
+
},
|
|
78
|
+
jobs: 4,
|
|
79
|
+
timeoutSeconds: 300,
|
|
80
|
+
promptRetries: 1,
|
|
81
|
+
/** Live progress ink set: "auto" queries the terminal background, or force "dark" / "light". */
|
|
82
|
+
theme: "auto",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function userConfigPath() {
|
|
86
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
87
|
+
return path.join(base, "backpass", "config.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readJsonIfPresent(file) {
|
|
91
|
+
if (!fs.existsSync(file)) return null;
|
|
92
|
+
let value;
|
|
93
|
+
try {
|
|
94
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new UserError(`${file} is not valid JSON: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
if (!isPlainObject(value)) {
|
|
99
|
+
throw new UserError(`${file} must contain a JSON object`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isPlainObject(v) {
|
|
105
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function deepMerge(base, override) {
|
|
109
|
+
if (!isPlainObject(override)) return override === undefined ? base : override;
|
|
110
|
+
const result = { ...base };
|
|
111
|
+
for (const [key, value] of Object.entries(override)) {
|
|
112
|
+
if (value === undefined) continue;
|
|
113
|
+
result[key] = isPlainObject(base[key]) ? deepMerge(base[key], value) : value;
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse a duration like `30d`, `12h`, `90m` into milliseconds.
|
|
120
|
+
* `all` / `0` disables the cutoff.
|
|
121
|
+
*/
|
|
122
|
+
export function parseSince(since) {
|
|
123
|
+
if (since === null || since === undefined || since === "all" || since === "0") return null;
|
|
124
|
+
const match = String(since)
|
|
125
|
+
.trim()
|
|
126
|
+
.match(/^(\d+)\s*([dhwm])$/i);
|
|
127
|
+
if (!match) {
|
|
128
|
+
throw new UserError(`invalid --since value "${since}"`, "use forms like 30d, 12h, 2w, 90m, or all");
|
|
129
|
+
}
|
|
130
|
+
const n = Number(match[1]);
|
|
131
|
+
const unit = match[2].toLowerCase();
|
|
132
|
+
const ms = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[unit];
|
|
133
|
+
return n * ms;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Normalize a user-facing cap: `0`, `all`, null, or undefined disables it. */
|
|
137
|
+
export function parseMaxTranscripts(value, flag = "maxTranscripts") {
|
|
138
|
+
if (value === null || value === undefined || value === "all" || value === "0" || value === 0) return null;
|
|
139
|
+
const n = Number(value);
|
|
140
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
141
|
+
throw new UserError(
|
|
142
|
+
`${flag} must be a non-negative integer or "all" (got "${value}")`,
|
|
143
|
+
'use a positive integer, or 0 / "all" to analyze every transcript',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return n;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function sinceCutoff(since, now = Date.now()) {
|
|
150
|
+
const window = parseSince(since);
|
|
151
|
+
return window === null ? null : now - window;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validate(config) {
|
|
155
|
+
if (!Array.isArray(config.memoryFiles) || config.memoryFiles.length === 0) {
|
|
156
|
+
throw new UserError("config.memoryFiles must be a non-empty array");
|
|
157
|
+
}
|
|
158
|
+
if (!Number.isFinite(config.budgetTokens) || config.budgetTokens <= 0) {
|
|
159
|
+
throw new UserError("config.budgetTokens must be a positive number");
|
|
160
|
+
}
|
|
161
|
+
if (config.maxEditsPerRun !== null && (!Number.isInteger(config.maxEditsPerRun) || config.maxEditsPerRun <= 0)) {
|
|
162
|
+
throw new UserError("config.maxEditsPerRun must be a positive integer, or null for the adaptive cap");
|
|
163
|
+
}
|
|
164
|
+
if (!Number.isInteger(config.minGapEvidence) || config.minGapEvidence < 1) {
|
|
165
|
+
throw new UserError("config.minGapEvidence must be an integer >= 1");
|
|
166
|
+
}
|
|
167
|
+
config.maxTranscripts = parseMaxTranscripts(config.maxTranscripts, "config.maxTranscripts");
|
|
168
|
+
try {
|
|
169
|
+
parseSince(config.gapLedgerMaxAge);
|
|
170
|
+
} catch {
|
|
171
|
+
throw new UserError(`config.gapLedgerMaxAge must be a duration like 90d or all (got "${config.gapLedgerMaxAge}")`);
|
|
172
|
+
}
|
|
173
|
+
if (parseSince(config.sampleHalfLife) === null) {
|
|
174
|
+
throw new UserError(`config.sampleHalfLife must be a duration like 14d (got "${config.sampleHalfLife}")`);
|
|
175
|
+
}
|
|
176
|
+
if (config.seed !== null && !Number.isInteger(config.seed)) {
|
|
177
|
+
throw new UserError("config.seed must be an integer or null");
|
|
178
|
+
}
|
|
179
|
+
if (!Number.isInteger(config.jobs) || config.jobs < 1) {
|
|
180
|
+
throw new UserError("config.jobs must be an integer >= 1");
|
|
181
|
+
}
|
|
182
|
+
for (const role of ["analysis", "synthesis"]) {
|
|
183
|
+
if (config[role].model && !config[role].agent) {
|
|
184
|
+
throw new UserError(
|
|
185
|
+
`config.${role}.model is set but config.${role}.agent is not`,
|
|
186
|
+
`set --${role}-agent too, or leave both unset to auto-pick from the ladder`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const ladder = config.ladders?.[role];
|
|
190
|
+
if (!Array.isArray(ladder) || ladder.length === 0) {
|
|
191
|
+
throw new UserError(`config.ladders.${role} must be a non-empty array of { model, agents } rungs`);
|
|
192
|
+
}
|
|
193
|
+
for (const rung of ladder) {
|
|
194
|
+
if (!rung || typeof rung.model !== "string" || !Array.isArray(rung.agents) || !rung.agents.length) {
|
|
195
|
+
throw new UserError(`config.ladders.${role} rungs must look like { "model": "<id>", "agents": ["<harness>"] }`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!["auto", "dark", "light"].includes(config.theme)) {
|
|
200
|
+
throw new UserError(`config.theme must be "auto", "dark", or "light" (got "${config.theme}")`);
|
|
201
|
+
}
|
|
202
|
+
const known = new Set([...ALL_HARNESSES, ...OPT_IN_HARNESSES]);
|
|
203
|
+
for (const h of config.discovery.harnesses) {
|
|
204
|
+
if (!known.has(h)) {
|
|
205
|
+
warn(`unknown harness "${h}" in config.discovery.harnesses - ignoring`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
config.discovery.harnesses = config.discovery.harnesses.filter((h) => known.has(h));
|
|
209
|
+
parseSince(config.discovery.since);
|
|
210
|
+
return config;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Layered config: defaults < ~/.config/backpass/config.json < <repo>/.backpassrc.json < CLI flags.
|
|
215
|
+
*/
|
|
216
|
+
export function loadConfig(repoRoot, overrides = {}) {
|
|
217
|
+
const merged = [
|
|
218
|
+
readJsonIfPresent(userConfigPath()),
|
|
219
|
+
readJsonIfPresent(path.join(repoRoot, CONFIG_FILENAME)),
|
|
220
|
+
overrides,
|
|
221
|
+
].reduce((acc, layer) => (layer ? deepMerge(acc, layer) : acc), DEFAULT_CONFIG);
|
|
222
|
+
|
|
223
|
+
const config = structuredClone(merged);
|
|
224
|
+
if (config.discovery.includeCursorIde && !config.discovery.harnesses.includes("cursor-ide")) {
|
|
225
|
+
config.discovery.harnesses = [...config.discovery.harnesses, "cursor-ide"];
|
|
226
|
+
}
|
|
227
|
+
return validate(config);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function repoConfigPath(repoRoot) {
|
|
231
|
+
return path.join(repoRoot, CONFIG_FILENAME);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The subset written by `backpass init` - defaults stay implicit so upgrades reach users. */
|
|
235
|
+
export function initialConfig() {
|
|
236
|
+
return {
|
|
237
|
+
memoryFiles: ["AGENTS.md"],
|
|
238
|
+
budgetTokens: DEFAULT_CONFIG.budgetTokens,
|
|
239
|
+
skillsDir: DEFAULT_CONFIG.skillsDir,
|
|
240
|
+
// maxEditsPerRun stays unset so the adaptive cap applies; set it to pin a number.
|
|
241
|
+
minGapEvidence: DEFAULT_CONFIG.minGapEvidence,
|
|
242
|
+
maxTranscripts: DEFAULT_CONFIG.maxTranscripts,
|
|
243
|
+
// Agents stay unset so the ladder auto-pick keeps applying to initialized repos.
|
|
244
|
+
analysis: { agent: null, model: null, effort: null },
|
|
245
|
+
synthesis: { agent: null, model: null, effort: null },
|
|
246
|
+
discovery: { harnesses: ALL_HARNESSES, since: "30d", worktreeGlobs: [], minUserTurns: 2 },
|
|
247
|
+
jobs: DEFAULT_CONFIG.jobs,
|
|
248
|
+
};
|
|
249
|
+
}
|