backpass 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -148,9 +148,14 @@ instruction covers.
148
148
  most important defence against a model confabulating influence. Negative evidence (a
149
149
  visible violation) is weighted highest.
150
150
 
151
- Results are cached per transcript, keyed to both the transcript's content _and_ the memory
152
- file's hash: edit the weights and the evidence correctly re-computes; change nothing and
153
- the next run is free.
151
+ Results are cached per transcript, keyed to both the transcript's content _and_ the effective
152
+ memory-file set hash: edit the weights and the evidence correctly re-computes; change nothing
153
+ and the next run is free. A memory-file edit therefore reanalyzes without `--force` - that is
154
+ not a cache miss, it is the cache doing its job - and the run says so on stderr, naming the
155
+ old and new hash, so a "0 reused" line reads as "the file changed" rather than "reuse is
156
+ broken." Evidence files that are not refreshed remain on disk but are excluded while their
157
+ hash is stale. They become eligible again if the memory-file set returns to that hash;
158
+ evidence for transcripts included in the new analysis is replaced with fresh judgments.
154
159
 
155
160
  ### 4. Aggregate gradients - deterministic, no model
156
161
 
@@ -159,6 +164,13 @@ Evidence is grouped by instruction, giving each one a positive/negative count an
159
164
  gaps across sessions are clustered, and clusters seen in fewer than `minGapEvidence`
160
165
  sessions (default 2) are dropped. One bad session never rewrites the weights.
161
166
 
167
+ Only evidence judged against the _current_ memory-file set hash is folded into a proposal. A
168
+ transcript that fell out of this run's sample - the time window, `maxTranscripts`, or the
169
+ transcript itself being gone - can leave an older evidence file on disk under a hash the
170
+ memory-file set no longer has; that file is left untouched, but it does not count toward this
171
+ run's session total or instruction scores, or add a gap observation, until it is current
172
+ again.
173
+
162
174
  Those sessions are counted across runs, not per run: every gap sighting is kept in
163
175
  `.backpass/gap-ledger.json` by gap and session, so a gap seen in one session today and in
164
176
  another session next week graduates on the later run. The same session never counts twice,
@@ -166,9 +178,9 @@ a sighting retires once the memory file gains an instruction that covers it, and
166
178
  sightings expire after `gapLedgerMaxAge` (default 90d). Until a gap corroborates it stays
167
179
  out of the proposal entirely.
168
180
 
169
- ### 5. Gradient descent - one session, native edits
181
+ ### 5. Gradient descent - native edits
170
182
 
171
- A single high-reasoning session turns the aggregated gradients into concrete edits: ADD,
183
+ A high-reasoning synthesis run turns the aggregated gradients into concrete edits: ADD,
172
184
  REMOVE, REWRITE, or EXTRACT→SKILL. The agent does not describe edits for backpass to
173
185
  splice in - it makes them, with its harness's own file tools, in a **staging copy** of the
174
186
  memory file under `.backpass/synthesis/` (the repo itself is read-only to it, for
@@ -189,10 +201,31 @@ Then mechanical gates run, and they are not negotiable:
189
201
  - every edit carries a verbatim quote
190
202
  - the post-edit file must fit the budget, measured on the staged file
191
203
 
192
- A violation triggers a re-prompt naming the exact breach (at most two). If those also
193
- fail, backpass **fails loudly** and saves the rejected proposal. It never silently
194
- truncates. A harness that writes past the staging copy into the repo is an error, never
195
- an apply.
204
+ An extraction is the created `SKILL.md` plus the memory-file change that pays for it.
205
+ Neighbouring removals are merged into one measured change, and a merged change cannot be
206
+ accepted in halves - so when several sections leave together, their skills arrive as one
207
+ extract with several skills, which is one honest accept/reject decision. Skills whose
208
+ removals were measured separately stay separate decisions, and bundling them is refused.
209
+
210
+ A malformed answer or gate violation triggers a re-prompt naming the exact breach (at
211
+ most two). If those also fail, backpass **fails loudly** and preserves the latest parseable
212
+ rejected proposal, if one was produced. It never silently truncates. A harness that writes
213
+ past the staging copy into the repo is an error, never an apply.
214
+
215
+ Not every annotate turn is an answer, and the three cases are kept apart because they call
216
+ for different things:
217
+
218
+ - **the agent edited the copy again** - the ids it was given no longer describe the files.
219
+ It is shown the fresh measurement and answers again; this costs no re-prompt.
220
+ - **the turn came back empty** - the harness returned success with no text at all. Nothing
221
+ was said, so there is nothing to correct: the annotation is retried once in a **new**
222
+ session, since the accumulated context of the old one is the likeliest cause.
223
+ - **the turn returned text** - malformed JSON or a gate violation uses an annotation
224
+ attempt and can trigger a re-prompt. Only a parseable, gate-rejected answer writes a
225
+ rejected proposal, stamped with the attempt that produced it.
226
+
227
+ When a run does fail, the advice it prints comes from the condition it ended on. Run
228
+ `backpass propose` again to start a fresh synthesis.
196
229
 
197
230
  Token deltas shown to you are measured by backpass from the actual text - never taken from
198
231
  the model's own arithmetic.
@@ -256,13 +289,17 @@ Apply preflights every accepted edit before writing. The proposal was measured a
256
289
  exact version of your memory file, so apply first checks the file still exists and is still
257
290
  that version. If it was removed or changed since - you pulled, edited it by hand, or another
258
291
  agent did - the edits no longer describe what is on disk, so nothing is written and you are
259
- told to run `backpass` again to re-propose against the current file. Within a run every file
260
- is composed from one version: it takes every accepted edit or none of them.
261
-
262
- Skills are written only after every edit has composed, and before the memory file, so a
263
- write failure cannot leave the memory file pointing at a missing skill. If one skill write
264
- fails after another succeeded, apply names the unreferenced skill paths to remove before
265
- retrying.
292
+ told to run `backpass` again to re-propose against the current file. That rerun reanalyzes
293
+ transcripts against the file that exists now; it does not reuse the stale judgments behind
294
+ the refused proposal. Within a run every file is composed from one version: it takes every
295
+ accepted edit or none of them. Apply also
296
+ refuses the whole write if any created skill target already exists or two accepted paths
297
+ resolve to the same file.
298
+
299
+ Skills and non-memory files are written only after every edit has composed, with the memory
300
+ file committed last. A later write failure rolls back files, skills, and loading-layout
301
+ entries created earlier in that round. If another process changes a committed file before
302
+ rollback reaches it, apply leaves that change untouched and reports the rollback conflict.
266
303
 
267
304
  For compatibility, proposals created by older backpass versions that do not contain a
268
305
  memory-file hash skip the freshness check. Regenerate such a proposal before applying it if
@@ -352,9 +389,10 @@ backpass \
352
389
  --synthesis-agent claude --synthesis-model claude-opus-5 --synthesis-effort high
353
390
  ```
354
391
 
355
- `--no-auto-agent` pins the pre-ladder defaults (codex / claude). If an adapter does not
356
- advertise reasoning effort, backpass says so in the run report rather than pretending it
357
- applied.
392
+ `--no-auto-agent` pins the pre-ladder defaults (codex / claude). Model and effort
393
+ overrides are scoped to that Backpass invocation, so they never rewrite your harness
394
+ defaults. If an adapter has no proven overlay, backpass stops rather than pretending it
395
+ applied, except OpenCode effort which is skipped with a report note.
358
396
 
359
397
  ### Configuration
360
398
 
@@ -405,7 +443,7 @@ Everything mutable lives in `.backpass/`, kept out of git via the repo's local e
405
443
  scan-cache.json collect-samples verdicts by path + mtime + size
406
444
  evidence/<id>.json per-transcript loss
407
445
  evidence-summary.json aggregated gradients
408
- proposal.json the latest gradient-descent step
446
+ proposal.json the latest parseable gradient-descent step (absent if none was produced)
409
447
  synthesis/ the staging copy the gradient-descent agent edited (memory file + skills)
410
448
  prompts/ the exact prompts of the last run
411
449
  agent-probe-cache.json which harnesses were available and logged in, and when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpass",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "description": "Gradient descent for your agent memory - analyzes past agent session transcripts and proposes evidence-backed edits to AGENTS.md / CLAUDE.md",
6
6
  "type": "module",
package/src/acpx.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
 
3
- import { warn } from "./logger.js";
3
+ import { UserError, warn } from "./logger.js";
4
4
  import { runCapture } from "./subprocess.js";
5
+ import { prepareHarnessInvocation } from "./harness-invoke.js";
5
6
  import * as piStore from "./discovery/adapters/pi.js";
6
7
  import { readJsonl } from "./discovery/adapters/shared.js";
7
8
 
@@ -32,13 +33,13 @@ export function acpxAgentName(agent) {
32
33
  }
33
34
 
34
35
  /**
35
- * The session config-option id each adapter uses for reasoning effort. There is no
36
- * shared ACP name for it and `acpx status` does not expose config options, so this
37
- * small table is measured (see the ordered-defaults design report) rather than
38
- * derived. Adapters absent here (grok, opencode) advertise no effort option at all;
39
- * effort is then skipped with a report note, never silently.
36
+ * The session config-option id each adapter uses for reasoning effort when that
37
+ * `set` is session-local. Pi is absent: ACP `set thought_level` rewrites
38
+ * `~/.pi/agent/settings.json`, so Pi effort is process `--thinking` instead
39
+ * (`src/harness-invoke.js`). Grok uses process `--reasoning-effort`. OpenCode
40
+ * has no overlay; effort is skipped with a report note, never silently.
40
41
  */
41
- export const EFFORT_OPTION_KEYS = { codex: "reasoning_effort", claude: "effort", pi: "thought_level" };
42
+ export const EFFORT_OPTION_KEYS = { codex: "reasoning_effort", claude: "effort" };
42
43
 
43
44
  export function effortOptionKey(agent) {
44
45
  return EFFORT_OPTION_KEYS[agent] || null;
@@ -60,7 +61,7 @@ export class AcpxError extends Error {
60
61
 
61
62
  /**
62
63
  * @param {string[]} args
63
- * @param {{ timeoutMs?: number, cwd?: string, input?: string }} [options]
64
+ * @param {{ timeoutMs?: number, cwd?: string, input?: string, env?: NodeJS.ProcessEnv }} [options]
64
65
  */
65
66
  function run(args, options = {}) {
66
67
  return runCapture(ACPX_BIN, args, options);
@@ -170,6 +171,52 @@ function baseArgs({ cwd, model, timeoutSeconds, approveReads, approveAll = false
170
171
  return args;
171
172
  }
172
173
 
174
+ function invocationAgentArgs(invocation, agent) {
175
+ return invocation.acpxAgentCommand ? ["--agent", invocation.acpxAgentCommand] : [acpxAgentName(agent)];
176
+ }
177
+
178
+ async function verifyHarnessInvocation(invocation, cwd) {
179
+ if (!invocation.requiredBuiltinAgent) return;
180
+ const args = [...(cwd ? ["--cwd", cwd] : []), "config", "show", "--format", "json"];
181
+ const result = await run(args, { timeoutMs: 10_000, cwd, env: invocation.env });
182
+ if (result.code !== 0) {
183
+ throw new UserError(
184
+ `cannot verify acpx adapter configuration for ${invocation.requiredBuiltinAgent}: ${firstLine(result.stderr) || `exit ${result.code}`}`,
185
+ "upgrade acpx or omit the model and effort override",
186
+ );
187
+ }
188
+
189
+ let config;
190
+ try {
191
+ config = JSON.parse(result.stdout);
192
+ } catch {
193
+ throw new UserError(
194
+ `cannot verify acpx adapter configuration for ${invocation.requiredBuiltinAgent}: config show returned invalid JSON`,
195
+ "upgrade acpx or omit the model and effort override",
196
+ );
197
+ }
198
+
199
+ if (
200
+ !config ||
201
+ typeof config !== "object" ||
202
+ Array.isArray(config) ||
203
+ !config.agents ||
204
+ typeof config.agents !== "object"
205
+ ) {
206
+ throw new UserError(
207
+ `cannot verify acpx adapter configuration for ${invocation.requiredBuiltinAgent}: config show omitted the resolved agents map`,
208
+ "upgrade acpx or omit the model and effort override",
209
+ );
210
+ }
211
+
212
+ if (Object.prototype.hasOwnProperty.call(config.agents, invocation.requiredBuiltinAgent)) {
213
+ throw new UserError(
214
+ `cannot safely apply model or effort overrides because acpx agents.${invocation.requiredBuiltinAgent} replaces the proven built-in adapter`,
215
+ `remove the agents.${invocation.requiredBuiltinAgent} replacement or omit the model and effort override`,
216
+ );
217
+ }
218
+ }
219
+
173
220
  /** `acpx --version`, used to key the probe cache. Null when acpx is missing. */
174
221
  export async function acpxVersion({ timeoutMs = 10_000 } = {}) {
175
222
  const result = await run(["--version"], { timeoutMs });
@@ -230,6 +277,7 @@ export async function probeSession({ agent, sessionName, cwd = undefined, timeou
230
277
  export async function execOneShot({
231
278
  agent,
232
279
  model = null,
280
+ effort = null,
233
281
  promptFile,
234
282
  cwd,
235
283
  timeoutSeconds = 300,
@@ -237,42 +285,56 @@ export async function execOneShot({
237
285
  approveReads = true,
238
286
  suppressReads = true,
239
287
  }) {
288
+ const invocation = prepareHarnessInvocation({ agent, model, effort });
240
289
  const args = [
241
- ...baseArgs({ cwd, model, timeoutSeconds, approveReads, suppressReads }),
290
+ ...baseArgs({ cwd, model: invocation.acpxModel, timeoutSeconds, approveReads, suppressReads }),
242
291
  "--prompt-retries",
243
292
  String(promptRetries),
244
- acpxAgentName(agent),
293
+ ...invocationAgentArgs(invocation, agent),
245
294
  "exec",
246
295
  "--file",
247
296
  promptFile,
248
297
  ];
249
298
 
250
299
  const startedAt = Date.now();
251
- const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd });
252
- if (result.spawnError && result.spawnError.code === "ENOENT") throw notFoundError(result);
253
- if (result.timedOut) {
254
- throw new AcpxError(`acpx ${agent} exec timed out after ${timeoutSeconds}s`, result);
255
- }
256
- if (result.code !== 0) {
257
- throw new AcpxError(`acpx ${agent} exec failed (exit ${result.code})`, result);
258
- }
300
+ try {
301
+ await verifyHarnessInvocation(invocation, cwd);
302
+ if (effort && invocation.setEffortKey) {
303
+ throw new UserError(
304
+ `${agent} cannot apply invocation-scoped effort=${effort} in an exec one-shot`,
305
+ "use a named session or omit the effort override",
306
+ );
307
+ }
308
+ const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd, env: invocation.env });
309
+ if (result.spawnError && result.spawnError.code === "ENOENT") throw notFoundError(result);
310
+ if (result.timedOut) {
311
+ throw new AcpxError(`acpx ${agent} exec timed out after ${timeoutSeconds}s`, result);
312
+ }
313
+ if (result.code !== 0) {
314
+ throw new AcpxError(`acpx ${agent} exec failed (exit ${result.code})`, result);
315
+ }
259
316
 
260
- const combined = `${result.stdout}\n${result.stderr}`;
261
- const usage = parseTokenLine(combined) ?? recoverUsageFromStore({ agent, promptFile, cwd, startedAt });
262
- return { text: stripAcpxNoise(result.stdout), usage, raw: result.stdout };
317
+ const combined = `${result.stdout}\n${result.stderr}`;
318
+ const usage = parseTokenLine(combined) ?? recoverUsageFromStore({ agent, promptFile, cwd, startedAt });
319
+ return { text: stripAcpxNoise(result.stdout), usage, raw: result.stdout, notes: invocation.notes };
320
+ } finally {
321
+ invocation.dispose();
322
+ }
263
323
  }
264
324
 
265
325
  /**
266
326
  * A named session that stays open across turns (design section 5).
267
327
  *
268
- * Reasoning effort is a session config option on every adapter that has one, and
269
- * `exec` cannot set it - so any call that wants effort applied goes through a session.
270
- * Synthesis also needs more than one turn in the same context: the agent edits the
271
- * staging copy, then annotates the changes backpass measured. Adapters that do not
272
- * advertise an effort option skip that step with a report line - never silently.
328
+ * Model and effort are applied as invocation-scoped overlays (`src/harness-invoke.js`),
329
+ * never by rewriting persistent harness defaults. `exec` cannot carry process-level
330
+ * effort for every harness, so an effortful call still goes through a session when
331
+ * the overlay needs one. Synthesis also needs more than one turn in the same context:
332
+ * the agent edits the staging copy, then annotates the changes backpass measured.
333
+ * OpenCode alone skips its unsupported effort overlay with a report line. Every other
334
+ * requested overlay without a proven mechanism stops rather than pretending it applied.
273
335
  *
274
336
  * Resolves to the handle, or throws an `AcpxError` (`unsupported: true` when the adapter
275
- * has no session support at all, which `sessionPrompt` turns into an exec fallback).
337
+ * has no session support; `sessionPrompt` only falls back when it can preserve the requested overlays).
276
338
  *
277
339
  * @returns {Promise<{ notes: string[],
278
340
  * prompt: (options: { promptFile: string, timeoutSeconds?: number, promptRetries?: number,
@@ -281,11 +343,33 @@ export async function execOneShot({
281
343
  * close: () => Promise<void> }>}
282
344
  */
283
345
  export async function openSession({ agent, model = null, effort = null, sessionName, cwd }) {
284
- const notes = [];
285
- const acpxAgent = acpxAgentName(agent);
286
- const created = await run([acpxAgent, "sessions", "new", "--name", sessionName], { timeoutMs: 60_000, cwd });
287
- if (created.spawnError && created.spawnError.code === "ENOENT") throw notFoundError(created);
346
+ const invocation = prepareHarnessInvocation({ agent, model, effort });
347
+ const notes = [...invocation.notes];
348
+ const acpxAgentArgs = invocationAgentArgs(invocation, agent);
349
+ const runOpts = { timeoutMs: 60_000, cwd, env: invocation.env };
350
+ try {
351
+ await verifyHarnessInvocation(invocation, cwd);
352
+ } catch (err) {
353
+ invocation.dispose();
354
+ throw err;
355
+ }
356
+ const created = await run(
357
+ [
358
+ ...(invocation.acpxModel ? ["--model", invocation.acpxModel] : []),
359
+ ...acpxAgentArgs,
360
+ "sessions",
361
+ "new",
362
+ "--name",
363
+ sessionName,
364
+ ],
365
+ runOpts,
366
+ );
367
+ if (created.spawnError && created.spawnError.code === "ENOENT") {
368
+ invocation.dispose();
369
+ throw notFoundError(created);
370
+ }
288
371
  if (created.code !== 0) {
372
+ invocation.dispose();
289
373
  // An auth or spawn failure must surface as such so the caller can fall through.
290
374
  if (classifyAcpxFailure(created)) {
291
375
  throw new AcpxError(`acpx ${agent} session create failed: ${firstLine(created.stderr)}`, created);
@@ -306,27 +390,23 @@ export async function openSession({ agent, model = null, effort = null, sessionN
306
390
  const close = async () => {
307
391
  if (closed) return;
308
392
  closed = true;
309
- const result = await run([acpxAgent, "sessions", "close", sessionName], { timeoutMs: 30_000, cwd });
393
+ const result = await run([...acpxAgentArgs, "sessions", "close", sessionName], {
394
+ timeoutMs: 30_000,
395
+ cwd,
396
+ env: invocation.env,
397
+ });
310
398
  if (result.code !== 0) warn(`could not close acpx session ${sessionName}`);
399
+ invocation.dispose();
311
400
  };
312
401
 
313
402
  try {
314
- if (model) {
315
- const set = await run([acpxAgent, "-s", sessionName, "set", "model", model], { timeoutMs: 60_000, cwd });
403
+ if (effort && invocation.setEffortKey) {
404
+ const set = await run([...acpxAgentArgs, "-s", sessionName, "set", invocation.setEffortKey, effort], runOpts);
316
405
  if (set.code !== 0) {
317
- if (classifyAcpxFailure(set) === "model-unavailable") {
318
- throw new AcpxError(`acpx ${agent} rejected model ${model}: ${firstLine(set.stderr)}`, set);
319
- }
320
- notes.push(`could not set model=${model} on ${agent}: ${firstLine(set.stderr)}`);
321
- }
322
- }
323
- if (effort) {
324
- const key = effortOptionKey(agent);
325
- const set = key
326
- ? await run([acpxAgent, "-s", sessionName, "set", key, effort], { timeoutMs: 60_000, cwd })
327
- : null;
328
- if (!set || set.code !== 0) {
329
- notes.push(`${agent} does not advertise a reasoning-effort option; ran without effort=${effort}`);
406
+ throw new AcpxError(
407
+ `acpx ${agent} could not apply invocation-scoped effort=${effort}: ${firstLine(set.stderr) || `exit ${set.code}`}`,
408
+ set,
409
+ );
330
410
  }
331
411
  }
332
412
  } catch (err) {
@@ -348,13 +428,13 @@ export async function openSession({ agent, model = null, effort = null, sessionN
348
428
  ...baseArgs({ cwd, model: null, timeoutSeconds, approveReads, approveAll, suppressReads }),
349
429
  "--prompt-retries",
350
430
  String(promptRetries),
351
- acpxAgent,
431
+ ...acpxAgentArgs,
352
432
  "-s",
353
433
  sessionName,
354
434
  "--file",
355
435
  promptFile,
356
436
  ];
357
- const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd });
437
+ const result = await run(args, { timeoutMs: (timeoutSeconds + 30) * 1000, cwd, env: invocation.env });
358
438
  if (result.timedOut) throw new AcpxError(`acpx ${agent} session prompt timed out after ${timeoutSeconds}s`, result);
359
439
  if (result.code !== 0) throw new AcpxError(`acpx ${agent} session prompt failed (exit ${result.code})`, result);
360
440
 
@@ -384,8 +464,9 @@ function subtractUsage(current, previous) {
384
464
 
385
465
  /**
386
466
  * One prompt through a short-lived named session: open, prompt, close. The analysis
387
- * pass uses this whenever an effort is configured. An adapter without session support
388
- * falls back to a one-shot `exec`, and says so.
467
+ * pass uses this whenever an effort is configured. Without session support, it falls
468
+ * back to one-shot `exec` only when that preserves the requested overlays; otherwise it
469
+ * stops with actionable guidance. OpenCode effort remains the explicit reported skip.
389
470
  */
390
471
  export async function sessionPrompt({
391
472
  agent,
@@ -404,11 +485,20 @@ export async function sessionPrompt({
404
485
  session = await openSession({ agent, model, effort, sessionName, cwd });
405
486
  } catch (err) {
406
487
  if (!(err instanceof AcpxError) || !err.unsupported) throw err;
488
+ if (effort && (agent === "claude" || agent === "codex")) {
489
+ throw new UserError(
490
+ `${agent} cannot apply invocation-scoped effort=${effort} because its acpx adapter does not support sessions`,
491
+ "upgrade acpx or omit the effort override",
492
+ );
493
+ }
407
494
  const notes = [`session unsupported for ${agent}; fell back to exec one-shot`];
408
- if (effort) notes.push(`${agent} cannot set effort without a session; ran without effort=${effort}`);
495
+ if (effort && agent === "opencode") {
496
+ notes.push(`${agent} does not advertise a reasoning-effort option; ran without effort=${effort}`);
497
+ }
409
498
  const fallback = await execOneShot({
410
499
  agent,
411
500
  model,
501
+ effort: agent === "opencode" ? null : effort,
412
502
  promptFile,
413
503
  cwd,
414
504
  timeoutSeconds,
package/src/analyze.js CHANGED
@@ -8,7 +8,7 @@ import { renderInstructionIndex } from "./memory.js";
8
8
  import { renderPrompt } from "./prompts.js";
9
9
  import { evidenceKey, isEvidenceFresh, safeFileName } from "./state.js";
10
10
  import { emitProgress } from "./progress.js";
11
- import { color, info, warn } from "./logger.js";
11
+ import { UserError, color, info, warn } from "./logger.js";
12
12
 
13
13
  /**
14
14
  * Stage 1 of the pipeline (design section 3): one cheap model call per transcript,
@@ -137,8 +137,8 @@ async function analyzeOne({ transcript, memoryFile, config, repo, slot = 0 }) {
137
137
  timeoutSeconds: config.timeoutSeconds,
138
138
  promptRetries: config.promptRetries,
139
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.
140
+ // Route effortful calls through a fresh per-transcript session so each harness's
141
+ // invocation-scoped overlay or safe fallback is applied; otherwise one-shot is cheaper.
142
142
  if (!pick.effort) return execOneShot(call);
143
143
  callCounter += 1;
144
144
  return sessionPrompt({
@@ -185,7 +185,16 @@ async function pool(items, limit, worker) {
185
185
  export async function analyzeTranscripts({ transcripts, memoryFile, config, repo, memoryHash, force = false }) {
186
186
  const state = config.state;
187
187
  const pending = [];
188
- const summary = { total: transcripts.length, cached: 0, analyzed: 0, skipped: 0, failed: 0, usage: [] };
188
+ const summary = {
189
+ total: transcripts.length,
190
+ cached: 0,
191
+ analyzed: 0,
192
+ skipped: 0,
193
+ failed: 0,
194
+ usage: [],
195
+ staleMemoryHash: 0,
196
+ };
197
+ const priorHashes = new Set();
189
198
 
190
199
  for (const transcript of transcripts) {
191
200
  const existing = state.readEvidence(transcript.id);
@@ -193,9 +202,23 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
193
202
  summary.cached += 1;
194
203
  continue;
195
204
  }
205
+ // Distinguish "no prior evidence" from "prior evidence exists, but it was judged
206
+ // against a memory-file set that no longer matches" - a re-analysis here, not a miss.
207
+ if (existing?.status === "ok" && existing.memoryHash && existing.memoryHash !== memoryHash) {
208
+ summary.staleMemoryHash += 1;
209
+ priorHashes.add(existing.memoryHash);
210
+ }
196
211
  pending.push(transcript);
197
212
  }
198
213
 
214
+ if (summary.staleMemoryHash) {
215
+ info(
216
+ `${color.yellow("·")} ${summary.staleMemoryHash} transcript(s) have evidence from a previous ` +
217
+ `memory-file set (${[...priorHashes].join(", ")} -> ${memoryHash}); that evidence is stale, not ` +
218
+ `missing, and reuse resumes once this pass re-judges it against the current memory-file set`,
219
+ );
220
+ }
221
+
199
222
  if (!pending.length) {
200
223
  emitProgress("analyze:start", { pending: 0, cached: summary.cached, total: transcripts.length, jobs: config.jobs });
201
224
  emitProgress("analyze:done", summary);
@@ -265,6 +288,7 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
265
288
  emitProgress("analyze:evidence", { ...evidenceTotals });
266
289
  }
267
290
  } catch (err) {
291
+ if (err instanceof UserError) throw err;
268
292
  // Per-transcript fail-soft: recorded, listed by `backpass status`, retried next run.
269
293
  summary.failed += 1;
270
294
  warn(`${transcript.harness} ${transcriptLabel(transcript)}: ${err.message}`);
@@ -2,6 +2,7 @@ import readline from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
3
 
4
4
  import { UserError, color } from "../logger.js";
5
+ import { editSkills } from "../skills.js";
5
6
  import { formatTokens } from "../tokens.js";
6
7
 
7
8
  /**
@@ -51,10 +52,10 @@ export function renderEdit(edit, index, total) {
51
52
  out.push("");
52
53
  out.push(renderDiff(edit));
53
54
 
54
- if (edit.kind === "extract" && edit.skill) {
55
+ for (const skill of edit.kind === "extract" ? editSkills(edit) : []) {
55
56
  out.push("");
56
- out.push(color.dim(` new skill: ${edit.skill.path}`));
57
- out.push(color.dim(` description: ${edit.skill.description}`));
57
+ out.push(color.dim(` new skill: ${skill.path}`));
58
+ out.push(color.dim(` description: ${skill.description}`));
58
59
  }
59
60
 
60
61
  if (edit.evidence?.length) {