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/agents.js ADDED
@@ -0,0 +1,389 @@
1
+ import { UserError, color, info, warn } from "./logger.js";
2
+ import { DEFAULT_EFFORT, LEGACY_DEFAULT_AGENTS } from "./config.js";
3
+ import { AcpxError, acpxVersion, classifyAcpxFailure, probeSession } from "./acpx.js";
4
+ import { runCapture } from "./subprocess.js";
5
+
6
+ /**
7
+ * Ordered agent selection (the ordered-defaults design, section 8).
8
+ *
9
+ * Each role (analysis, synthesis) has a ladder of candidates - a model id served by a
10
+ * few harnesses, in preference order. This module walks the ladder and picks the first
11
+ * candidate that is installed, authenticated, and serves the model, using a ~1.5s
12
+ * zero-token probe per candidate. The probe is a *filter*, not a guarantee: the first
13
+ * real call is the decider, and a classifiable failure there (AUTH_REQUIRED, model
14
+ * rejected, adapter missing) demotes the candidate and falls through to the next one.
15
+ *
16
+ * All model invocation still goes through `src/acpx.js`. The one documented exception
17
+ * is `NATIVE_PROBES` below: the claude adapter creates sessions happily while logged
18
+ * out and only fails at prompt time, so its login state has to come from the harness's
19
+ * own `claude auth status`. opencode's ACP session has been seen to wedge for minutes
20
+ * on a large profile, so `opencode models` answers first and the ACP probe is capped.
21
+ *
22
+ * Verdicts are cached in `.backpass/agent-probe-cache.json` (12h for ok, 30min for
23
+ * negatives, invalidated on an acpx version change) and memoized for the run.
24
+ */
25
+
26
+ const OK_TTL_MS = 12 * 60 * 60 * 1000;
27
+ const NEGATIVE_TTL_MS = 30 * 60 * 1000;
28
+ const NATIVE_TIMEOUT_MS = 5_000;
29
+ const PROBE_TIMEOUT_MS = 20_000;
30
+ const PROBE_TIMEOUT_BY_AGENT = { opencode: 10_000 };
31
+
32
+ /** Adapters whose model list is open-ended: any id is forwarded, none can be verified. */
33
+ const TRUSTING_MODEL_AGENTS = new Set(["claude"]);
34
+
35
+ export const VERDICT_LABELS = {
36
+ ok: "ok",
37
+ unauthenticated: "not logged in",
38
+ "model-unavailable": "model not advertised",
39
+ unreachable: "not installed / not spawnable",
40
+ timeout: "probe timed out",
41
+ };
42
+
43
+ const LOGIN_HINTS = {
44
+ codex: "codex login",
45
+ claude: "claude auth login",
46
+ grok: "grok login",
47
+ opencode: "opencode auth login",
48
+ pi: "pi login",
49
+ };
50
+
51
+ /**
52
+ * Per-harness native status commands. Each returns a verdict or null (inconclusive, so
53
+ * the acpx probe decides). See the module header for why this table exists at all.
54
+ */
55
+ const NATIVE_PROBES = {
56
+ async claude({ model }) {
57
+ const result = await runCapture("claude", ["auth", "status"], { timeoutMs: NATIVE_TIMEOUT_MS });
58
+ if (result.spawnError?.code === "ENOENT") {
59
+ return { verdict: "unreachable", detail: "claude CLI not found on PATH", resolvedModel: model };
60
+ }
61
+ let parsed = null;
62
+ try {
63
+ parsed = JSON.parse(result.stdout.trim());
64
+ } catch {
65
+ // Not the JSON we expect; let the acpx probe have a look.
66
+ }
67
+ if (parsed && typeof parsed.loggedIn === "boolean") {
68
+ if (!parsed.loggedIn) return { verdict: "unauthenticated", detail: "claude auth status: loggedIn=false" };
69
+ return null;
70
+ }
71
+ if (result.code !== 0) {
72
+ return { verdict: "unreachable", detail: firstLine(result.stderr) || `claude auth status exit ${result.code}` };
73
+ }
74
+ return null;
75
+ },
76
+ async opencode({ model }) {
77
+ const result = await runCapture("opencode", ["models"], { timeoutMs: NATIVE_TIMEOUT_MS });
78
+ if (result.spawnError?.code === "ENOENT") {
79
+ return { verdict: "unreachable", detail: "opencode CLI not found on PATH" };
80
+ }
81
+ if (result.timedOut || result.code !== 0) return null;
82
+ const advertised = result.stdout
83
+ .split("\n")
84
+ .map((l) => l.trim())
85
+ .filter(Boolean);
86
+ const resolved = resolveModelId(model, advertised);
87
+ if (!resolved.id) {
88
+ return {
89
+ verdict: "model-unavailable",
90
+ detail: resolved.ambiguous
91
+ ? `ambiguous in \`opencode models\`: ${resolved.ambiguous.join(", ")}`
92
+ : "absent from `opencode models` (provider not logged in?)",
93
+ };
94
+ }
95
+ return null;
96
+ },
97
+ };
98
+
99
+ function firstLine(text) {
100
+ return (text || "").split("\n").find((l) => l.trim()) || "";
101
+ }
102
+
103
+ /**
104
+ * Match a bare model id against what an adapter advertises (design section 4.1):
105
+ * exact, then a unique last-`/`-segment match (`openai-codex/x`, `xai/x`), then a
106
+ * unique `x[...]` variant. Segment *equality* is deliberate: `gpt-5.6-luna-fast`
107
+ * must not satisfy `gpt-5.6-luna`. More than one survivor is a non-match, reported.
108
+ *
109
+ * @returns {{ id: string | null, ambiguous?: string[] }}
110
+ */
111
+ export function resolveModelId(bareId, advertised) {
112
+ if (advertised.includes(bareId)) return { id: bareId };
113
+ const bySegment = advertised.filter((id) => id.split("/").at(-1) === bareId);
114
+ if (bySegment.length === 1) return { id: bySegment[0] };
115
+ if (bySegment.length > 1) return { id: null, ambiguous: bySegment };
116
+ const byVariant = advertised.filter((id) => id.startsWith(`${bareId}[`));
117
+ if (byVariant.length === 1) return { id: byVariant[0] };
118
+ if (byVariant.length > 1) return { id: null, ambiguous: byVariant };
119
+ return { id: null };
120
+ }
121
+
122
+ /** Flatten a ladder model-outer / harness-inner into `{ model, agent }` candidates. */
123
+ export function flattenLadder(ladder) {
124
+ return ladder.flatMap((rung) => rung.agents.map((agent) => ({ model: rung.model, agent })));
125
+ }
126
+
127
+ export function candidateKey({ agent, model }) {
128
+ return `${agent}|${model}`;
129
+ }
130
+
131
+ /**
132
+ * Probe one candidate end to end: native status command first (when one exists),
133
+ * then the zero-token acpx session probe, then model-id resolution.
134
+ *
135
+ * @param {{ agent: string, model: string }} candidate
136
+ * @param {{ cwd?: string, sessionName?: string,
137
+ * probeSession?: (args: { agent: string, sessionName: string, cwd?: string, timeoutMs?: number }) =>
138
+ * Promise<{ verdict: string, detail: string, availableModels?: string[] }> }} [options]
139
+ * @returns {Promise<{ verdict: string, detail: string, resolvedModel: string | null }>}
140
+ */
141
+ export async function probeCandidate(candidate, options = {}) {
142
+ const { cwd, sessionName, probeSession: probe = probeSession } = options;
143
+ const { agent, model } = candidate;
144
+ const native = NATIVE_PROBES[agent];
145
+ if (native) {
146
+ const early = await native(candidate);
147
+ if (early) return { resolvedModel: null, ...early };
148
+ }
149
+
150
+ const result = await probe({
151
+ agent,
152
+ sessionName,
153
+ cwd,
154
+ timeoutMs: PROBE_TIMEOUT_BY_AGENT[agent] || PROBE_TIMEOUT_MS,
155
+ });
156
+ if (result.verdict !== "ok") return { verdict: result.verdict, detail: result.detail, resolvedModel: null };
157
+
158
+ if (TRUSTING_MODEL_AGENTS.has(agent)) {
159
+ return { verdict: "ok", detail: "model accepted on faith; the first real call verifies it", resolvedModel: model };
160
+ }
161
+ const resolved = resolveModelId(model, result.availableModels || []);
162
+ if (!resolved.id) {
163
+ const detail = resolved.ambiguous
164
+ ? `ambiguous among advertised ids: ${resolved.ambiguous.join(", ")}`
165
+ : result.availableModels?.length
166
+ ? `not among ${result.availableModels.length} advertised model(s)`
167
+ : "adapter advertised no models";
168
+ return { verdict: "model-unavailable", detail, resolvedModel: null };
169
+ }
170
+ return { verdict: "ok", detail: "", resolvedModel: resolved.id };
171
+ }
172
+
173
+ /**
174
+ * A cache entry is fresh when it is within its TTL and was recorded against the same
175
+ * acpx version. Negatives expire fast: "I just logged in" is the common repair.
176
+ */
177
+ export function isProbeEntryFresh(entry, { now = Date.now() } = {}) {
178
+ if (!entry || !entry.checkedAt) return false;
179
+ const age = now - Date.parse(entry.checkedAt);
180
+ if (!Number.isFinite(age) || age < 0) return false;
181
+ return age < (entry.verdict === "ok" ? OK_TTL_MS : NEGATIVE_TTL_MS);
182
+ }
183
+
184
+ function hintFor(agent, verdict) {
185
+ if (verdict === "unauthenticated" && LOGIN_HINTS[agent]) return `-> run: ${LOGIN_HINTS[agent]}`;
186
+ if (verdict === "unreachable") return `-> install the ${agent} CLI`;
187
+ return "";
188
+ }
189
+
190
+ /**
191
+ * Resolves the agent for each role once per run and owns the fall-through state.
192
+ *
193
+ * `resolve(role)` returns `{ agent, model, effort, pinned, reason }`. The analysis and
194
+ * synthesis stages call it lazily, so read-only commands (`scan`, `status`) never probe.
195
+ * `demote(role, pick, verdict)` records a mid-run classifiable failure; the next
196
+ * `resolve(role)` walks on from the following candidate.
197
+ */
198
+ export class AgentResolver {
199
+ /**
200
+ * @param {object} config
201
+ * @param {{ state?: { readProbeCache: Function, writeProbeCache: Function }, cwd?: string,
202
+ * bypassCache?: boolean, probeCandidate?: Function, acpxVersion?: Function, now?: () => number }} [deps]
203
+ */
204
+ constructor(config, deps = {}) {
205
+ this.config = config;
206
+ this.state = deps.state || null;
207
+ this.cwd = deps.cwd;
208
+ this.bypassCache = Boolean(deps.bypassCache);
209
+ this.probeCandidate = deps.probeCandidate || probeCandidate;
210
+ this.acpxVersion = deps.acpxVersion || acpxVersion;
211
+ this.now = deps.now || (() => Date.now());
212
+ /** In-process memo for the run: key -> { verdict, detail, resolvedModel, checkedAt }. */
213
+ this.memo = new Map();
214
+ /** Probes in flight, so parallel analysis workers never double-probe a candidate. */
215
+ this.inflight = new Map();
216
+ this.picks = {};
217
+ this.cache = null;
218
+ this.version = undefined;
219
+ this.probeCount = 0;
220
+ }
221
+
222
+ async loadCache() {
223
+ if (this.cache) return this.cache;
224
+ if (this.version === undefined) this.version = await this.acpxVersion();
225
+ const stored = this.state ? this.state.readProbeCache() : { version: 1, acpxVersion: null, entries: {} };
226
+ if (stored.acpxVersion !== this.version) stored.entries = {};
227
+ stored.acpxVersion = this.version;
228
+ this.cache = stored;
229
+ return stored;
230
+ }
231
+
232
+ saveCache() {
233
+ if (this.state && this.cache) this.state.writeProbeCache(this.cache);
234
+ }
235
+
236
+ async verdictFor(candidate) {
237
+ const key = candidateKey(candidate);
238
+ if (this.memo.has(key)) return this.memo.get(key);
239
+ if (this.inflight.has(key)) return this.inflight.get(key);
240
+ const pending = this.probeAndRecord(candidate, key).finally(() => this.inflight.delete(key));
241
+ this.inflight.set(key, pending);
242
+ return pending;
243
+ }
244
+
245
+ async probeAndRecord(candidate, key) {
246
+ const cache = await this.loadCache();
247
+ const cached = cache.entries[key];
248
+ if (!this.bypassCache && isProbeEntryFresh(cached, { now: this.now() })) {
249
+ this.memo.set(key, { ...cached, cached: true });
250
+ return this.memo.get(key);
251
+ }
252
+
253
+ this.probeCount += 1;
254
+ const sessionName = `backpass-probe-${process.pid}-${this.probeCount}`;
255
+ const result = await this.probeCandidate(candidate, { cwd: this.cwd, sessionName });
256
+ const entry = {
257
+ verdict: result.verdict,
258
+ detail: result.detail || "",
259
+ resolvedModel: result.resolvedModel || null,
260
+ checkedAt: new Date(this.now()).toISOString(),
261
+ };
262
+ cache.entries[key] = entry;
263
+ this.memo.set(key, entry);
264
+ this.saveCache();
265
+ return entry;
266
+ }
267
+
268
+ /** The candidates for a role, in order, as `{ agent, model }`. */
269
+ ladder(role) {
270
+ return flattenLadder(this.config.ladders[role]);
271
+ }
272
+
273
+ pinned(role) {
274
+ const explicit = this.config[role];
275
+ if (explicit.agent) {
276
+ return { agent: explicit.agent, model: explicit.model || null, pinned: true, reason: "configured" };
277
+ }
278
+ if (this.config.autoAgent === false) {
279
+ return { agent: LEGACY_DEFAULT_AGENTS[role], model: null, pinned: true, reason: "--no-auto-agent" };
280
+ }
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * @param {"analysis" | "synthesis"} role
286
+ * @returns {Promise<{ agent: string, model: string | null, ladderModel?: string, effort: string | null, pinned: boolean, reason: string }>}
287
+ */
288
+ async resolve(role) {
289
+ if (this.picks[role]) return this.picks[role];
290
+ const effort = this.config[role].effort || DEFAULT_EFFORT[role];
291
+
292
+ const pinned = this.pinned(role);
293
+ if (pinned) {
294
+ this.picks[role] = { ...pinned, effort };
295
+ return this.picks[role];
296
+ }
297
+
298
+ const trail = [];
299
+ for (const candidate of this.ladder(role)) {
300
+ let entry;
301
+ try {
302
+ entry = await this.verdictFor(candidate);
303
+ } catch (err) {
304
+ // acpx itself is missing: one clean error, not one per candidate.
305
+ if (err instanceof AcpxError) throw new UserError(err.message, "install acpx (npm i -g acpx) and retry");
306
+ throw err;
307
+ }
308
+ trail.push({ ...candidate, ...entry });
309
+ if (entry.verdict === "ok") {
310
+ this.picks[role] = {
311
+ agent: candidate.agent,
312
+ model: entry.resolvedModel || candidate.model,
313
+ ladderModel: candidate.model,
314
+ effort,
315
+ pinned: false,
316
+ reason: describeTrail(trail),
317
+ };
318
+ this.announce(role, this.picks[role], trail);
319
+ return this.picks[role];
320
+ }
321
+ }
322
+ throw exhaustedError(role, trail);
323
+ }
324
+
325
+ announce(role, pick, trail) {
326
+ const losers = trail.filter((t) => t.verdict !== "ok");
327
+ const cached = trail.at(-1)?.cached ? color.dim(" (cached)") : "";
328
+ info(`${color.cyan("·")} ${role}: ${pick.agent} (${pick.model}) effort=${pick.effort}${cached}`);
329
+ for (const t of losers) {
330
+ info(color.dim(` skipped ${t.agent}/${t.model}: ${VERDICT_LABELS[t.verdict] || t.verdict}`));
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Record a classifiable mid-run failure for the chosen candidate. Returns true when
336
+ * there is something to fall through to (the next `resolve(role)` walks on), false
337
+ * when the pick was pinned by the user - then the error is theirs to see.
338
+ */
339
+ async demote(role, pick, verdict, detail = "") {
340
+ if (pick.pinned) return false;
341
+ const key = candidateKey({ agent: pick.agent, model: pick.ladderModel });
342
+ if (this.memo.get(key)?.verdict === "ok") {
343
+ // First worker to see the failure records it; the rest just re-resolve.
344
+ const entry = { verdict, detail, resolvedModel: null, checkedAt: new Date(this.now()).toISOString() };
345
+ this.memo.set(key, entry);
346
+ const cache = await this.loadCache();
347
+ cache.entries[key] = entry;
348
+ this.saveCache();
349
+ warn(`${role}: ${pick.agent} (${pick.model}) ${VERDICT_LABELS[verdict] || verdict} mid-run; falling through`);
350
+ }
351
+ if (this.picks[role] === pick) delete this.picks[role];
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * Run `fn(pick)` for a role, falling through the ladder on classifiable failures.
357
+ * Unclassifiable errors (a timeout on real work, garbage output) propagate unchanged.
358
+ */
359
+ async withFallthrough(role, fn) {
360
+ for (;;) {
361
+ const pick = await this.resolve(role);
362
+ try {
363
+ return await fn(pick);
364
+ } catch (err) {
365
+ const verdict = err instanceof AcpxError ? classifyAcpxFailure(err) : null;
366
+ if (!verdict || !(await this.demote(role, pick, verdict, err.message))) throw err;
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ function describeTrail(trail) {
373
+ const losers = trail.filter((t) => t.verdict !== "ok");
374
+ if (!losers.length) return "first candidate in the ladder";
375
+ return `after skipping ${losers.map((t) => `${t.agent}/${t.model} (${VERDICT_LABELS[t.verdict] || t.verdict})`).join(", ")}`;
376
+ }
377
+
378
+ function exhaustedError(role, trail) {
379
+ const width = Math.max(...trail.map((t) => t.model.length));
380
+ const lines = trail.map((t) => {
381
+ const label = VERDICT_LABELS[t.verdict] || t.verdict;
382
+ const hint = hintFor(t.agent, t.verdict);
383
+ return ` ${t.model.padEnd(width)} ${t.agent.padEnd(9)} ${label}${t.detail ? ` (${t.detail})` : ""}${hint ? ` ${hint}` : ""}`;
384
+ });
385
+ return new UserError(
386
+ `no available agent for the ${role} pass\n\n${lines.join("\n")}`,
387
+ `log in to one of the harnesses above, or pin one explicitly: backpass --${role}-agent <agent> --${role}-model <id>`,
388
+ );
389
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,289 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { execOneShot, extractJson, sessionPrompt, usageRecord } from "./acpx.js";
5
+ import { distill } from "./distill.js";
6
+ import { readTranscript } from "./discovery/index.js";
7
+ import { renderInstructionIndex } from "./memory.js";
8
+ import { renderPrompt } from "./prompts.js";
9
+ import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js";
10
+ import { emitProgress } from "./progress.js";
11
+ import { color, info, warn } from "./logger.js";
12
+
13
+ /**
14
+ * Stage 1 of the pipeline (design section 3): one cheap model call per transcript,
15
+ * fanned out over a small worker pool.
16
+ *
17
+ * Everything expensive is cached. Evidence is keyed to the transcript's content
18
+ * signature AND the memory-file hash it was judged against, so re-running after an
19
+ * apply correctly re-analyzes against the new weights while an unchanged run is free.
20
+ */
21
+
22
+ const MIN_ASSISTANT_TURNS = 4;
23
+ const MIN_TOOL_CALLS = 3;
24
+
25
+ let callCounter = 0;
26
+ const seenNotes = new Set();
27
+
28
+ /** The same adapter limitation would repeat once per transcript; say it once per run. */
29
+ function noteOnce(note) {
30
+ if (seenNotes.has(note)) return;
31
+ seenNotes.add(note);
32
+ warn(note);
33
+ }
34
+
35
+ /** Evidence items without a verbatim quote are dropped - the rubric's central rule. */
36
+ export function sanitizeEvidence(parsed) {
37
+ const clean = { positive: [], negative: [], gaps: [], usedRawTranscript: Boolean(parsed?.usedRawTranscript) };
38
+ if (!parsed || typeof parsed !== "object") return clean;
39
+
40
+ const hasQuote = (item) => typeof item?.quote === "string" && item.quote.trim().length >= 8;
41
+
42
+ for (const key of ["positive", "negative"]) {
43
+ for (const item of Array.isArray(parsed[key]) ? parsed[key] : []) {
44
+ if (!hasQuote(item) || typeof item.instruction !== "string") continue;
45
+ clean[key].push({
46
+ instruction: item.instruction.trim(),
47
+ moment: String(item.moment ?? "").slice(0, 80),
48
+ effect: String(item.effect ?? "").slice(0, 400),
49
+ quote: item.quote.trim().slice(0, 600),
50
+ });
51
+ }
52
+ }
53
+
54
+ for (const item of Array.isArray(parsed.gaps) ? parsed.gaps : []) {
55
+ if (!hasQuote(item) || typeof item.proposedInstruction !== "string") continue;
56
+ clean.gaps.push({
57
+ mistake: String(item.mistake ?? "").slice(0, 400),
58
+ proposedInstruction: item.proposedInstruction.trim().slice(0, 400),
59
+ recurrenceRisk: ["high", "medium", "low"].includes(item.recurrenceRisk) ? item.recurrenceRisk : "medium",
60
+ quote: item.quote.trim().slice(0, 600),
61
+ });
62
+ }
63
+
64
+ return clean;
65
+ }
66
+
67
+ /**
68
+ * Human-facing label for a transcript in progress output. Never a raw session ID: a
69
+ * transcript with no title falls back to its session date/time, then to "(untitled)".
70
+ */
71
+ export function transcriptLabel(transcript) {
72
+ if (transcript.title) return transcript.title;
73
+ const at = Number(transcript.startedAt);
74
+ if (Number.isFinite(at) && at > 0) {
75
+ const d = new Date(at);
76
+ const pad = (n) => String(n).padStart(2, "0");
77
+ return `session ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
78
+ }
79
+ return "(untitled)";
80
+ }
81
+
82
+ function promptPathFor(state, transcript) {
83
+ return path.join(state.applyDir, "..", "prompts", `${safeFileName(transcript.id)}.md`);
84
+ }
85
+
86
+ async function analyzeOne({ transcript, memoryFile, config, repo, slot = 0 }) {
87
+ const raw = await readTranscript(transcript);
88
+ const distilled = distill(raw.events, {
89
+ ...transcript,
90
+ model: raw.model,
91
+ rawPath: raw.rawPath,
92
+ });
93
+
94
+ emitProgress("analyze:lane", {
95
+ slot,
96
+ harness: transcript.harness,
97
+ id: transcript.nativeId,
98
+ title: transcriptLabel(transcript),
99
+ phase: "model",
100
+ // Measure the input distill actually consumed, not `transcript.bytes`: that is a
101
+ // discovery stat() size, which is a directory or 0 for several harnesses.
102
+ rawBytes: Buffer.byteLength(JSON.stringify(raw.events), "utf8"),
103
+ distilledBytes: Buffer.byteLength(distilled.trace, "utf8"),
104
+ });
105
+
106
+ // Triviality filter. `minUserTurns` is the knob, but a session is only truly trivial
107
+ // when the agent barely did anything either: an autonomous run has exactly one user
108
+ // turn (the brief) followed by hundreds of agent turns, and it carries plenty of
109
+ // signal. Skipping those would discard most of a real corpus.
110
+ const { userTurns, assistantTurns, toolCalls } = distilled.stats;
111
+ if (userTurns < config.discovery.minUserTurns && assistantTurns < MIN_ASSISTANT_TURNS && toolCalls < MIN_TOOL_CALLS) {
112
+ return {
113
+ status: "skipped",
114
+ reason: `trivial session (${userTurns} user turn(s), ${assistantTurns} agent turn(s), ${toolCalls} tool call(s))`,
115
+ distilled,
116
+ };
117
+ }
118
+
119
+ const prompt = renderPrompt("analysis", {
120
+ MEMORY_PATH: memoryFile.path,
121
+ INSTRUCTION_INDEX: renderInstructionIndex(memoryFile),
122
+ TRACE: distilled.trace,
123
+ });
124
+
125
+ const promptFile = promptPathFor(config.state, transcript);
126
+ fs.mkdirSync(path.dirname(promptFile), { recursive: true });
127
+ fs.writeFileSync(promptFile, prompt);
128
+
129
+ let ranWith = null;
130
+ const result = await config.agents.withFallthrough("analysis", async (pick) => {
131
+ ranWith = pick.agent;
132
+ const call = {
133
+ agent: pick.agent,
134
+ model: pick.model,
135
+ promptFile,
136
+ cwd: repo.root,
137
+ timeoutSeconds: config.timeoutSeconds,
138
+ promptRetries: config.promptRetries,
139
+ };
140
+ // Effort is a session config option, so an effortful analysis call needs a
141
+ // (fresh, per-transcript) session; without effort the plain one-shot is cheaper.
142
+ if (!pick.effort) return execOneShot(call);
143
+ callCounter += 1;
144
+ return sessionPrompt({
145
+ ...call,
146
+ effort: pick.effort,
147
+ sessionName: `backpass-analysis-${process.pid}-${slot}-${callCounter}`,
148
+ });
149
+ });
150
+ for (const note of result.notes || []) noteOnce(note);
151
+
152
+ const parsed = extractJson(result.text);
153
+ if (!parsed) {
154
+ throw new Error("analysis returned no parseable JSON");
155
+ }
156
+
157
+ return {
158
+ status: "ok",
159
+ evidence: sanitizeEvidence(parsed),
160
+ usage: usageRecord(ranWith, result),
161
+ distilled,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Bounded-concurrency worker pool - the design's `--jobs N` fan-out.
167
+ * The worker also receives its runner slot so the progress view can show one
168
+ * lane per job.
169
+ */
170
+ async function pool(items, limit, worker) {
171
+ const results = new Array(items.length);
172
+ let cursor = 0;
173
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
174
+ for (;;) {
175
+ const index = cursor;
176
+ cursor += 1;
177
+ if (index >= items.length) return;
178
+ results[index] = await worker(items[index], index, slot);
179
+ }
180
+ });
181
+ await Promise.all(runners);
182
+ return results;
183
+ }
184
+
185
+ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo, memoryHash, force = false }) {
186
+ const state = config.state;
187
+ const pending = [];
188
+ const summary = { total: transcripts.length, cached: 0, analyzed: 0, skipped: 0, failed: 0, usage: [] };
189
+
190
+ for (const transcript of transcripts) {
191
+ const existing = state.readEvidence(transcript.id);
192
+ if (!force && isEvidenceFresh(existing, transcript, memoryHash)) {
193
+ summary.cached += 1;
194
+ continue;
195
+ }
196
+ pending.push(transcript);
197
+ }
198
+
199
+ if (!pending.length) {
200
+ emitProgress("analyze:start", { pending: 0, cached: summary.cached, total: transcripts.length, jobs: config.jobs });
201
+ emitProgress("analyze:done", summary);
202
+ return summary;
203
+ }
204
+
205
+ // Resolve (and, on the first run, probe) before the fan-out so the pick is announced once.
206
+ const pick = await config.agents.resolve("analysis");
207
+ emitProgress("analyze:start", {
208
+ pending: pending.length,
209
+ cached: summary.cached,
210
+ total: transcripts.length,
211
+ jobs: config.jobs,
212
+ agent: pick.agent,
213
+ model: pick.model,
214
+ });
215
+
216
+ info(
217
+ `${color.cyan("·")} analyzing ${pending.length} transcript(s) with ${pick.agent}` +
218
+ `${pick.model ? ` (${pick.model})` : ""}${pick.effort ? ` effort=${pick.effort}` : ""} at jobs=${config.jobs}`,
219
+ );
220
+
221
+ let done = 0;
222
+ const evidenceTotals = { positive: 0, negative: 0, gaps: 0 };
223
+ await pool(pending, config.jobs, async (transcript, _index, slot) => {
224
+ const base = {
225
+ transcript: {
226
+ harness: transcript.harness,
227
+ id: transcript.id,
228
+ path: transcript.path,
229
+ mtimeMs: transcript.mtimeMs,
230
+ bytes: transcript.bytes,
231
+ startedAt: transcript.startedAt,
232
+ association: transcript.association,
233
+ },
234
+ memoryHash,
235
+ memoryPath: memoryFile.path,
236
+ key: evidenceKey(transcript, memoryHash),
237
+ analyzedAt: new Date().toISOString(),
238
+ };
239
+
240
+ emitProgress("analyze:lane", {
241
+ slot,
242
+ harness: transcript.harness,
243
+ id: transcript.nativeId,
244
+ title: transcriptLabel(transcript),
245
+ phase: "distill",
246
+ });
247
+
248
+ try {
249
+ const result = await analyzeOne({ transcript, memoryFile, config, repo, slot });
250
+ if (result.status === "skipped") {
251
+ summary.skipped += 1;
252
+ state.writeEvidence(transcript.id, { ...base, status: "skipped", reason: result.reason });
253
+ } else {
254
+ summary.analyzed += 1;
255
+ summary.usage.push(result.usage);
256
+ state.writeEvidence(transcript.id, {
257
+ ...base,
258
+ status: "ok",
259
+ stats: result.distilled.stats,
260
+ ...result.evidence,
261
+ });
262
+ evidenceTotals.positive += result.evidence.positive.length;
263
+ evidenceTotals.negative += result.evidence.negative.length;
264
+ evidenceTotals.gaps += result.evidence.gaps.length;
265
+ emitProgress("analyze:evidence", { ...evidenceTotals });
266
+ }
267
+ } catch (err) {
268
+ // Per-transcript fail-soft: recorded, listed by `backpass status`, retried next run.
269
+ summary.failed += 1;
270
+ warn(`${transcript.harness} ${transcriptLabel(transcript)}: ${err.message}`);
271
+ state.writeEvidence(transcript.id, { ...base, status: "failed", error: err.message });
272
+ } finally {
273
+ done += 1;
274
+ emitProgress("analyze:tick", {
275
+ slot,
276
+ done,
277
+ ok: summary.analyzed,
278
+ skipped: summary.skipped,
279
+ failed: summary.failed,
280
+ });
281
+ if (done % 10 === 0 || done === pending.length) {
282
+ info(`${color.dim(` ${done}/${pending.length} analyzed`)}`);
283
+ }
284
+ }
285
+ });
286
+
287
+ emitProgress("analyze:done", summary);
288
+ return summary;
289
+ }