backpass 0.1.6 → 0.1.8

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
@@ -166,9 +166,9 @@ a sighting retires once the memory file gains an instruction that covers it, and
166
166
  sightings expire after `gapLedgerMaxAge` (default 90d). Until a gap corroborates it stays
167
167
  out of the proposal entirely.
168
168
 
169
- ### 5. Gradient descent - one session, native edits
169
+ ### 5. Gradient descent - native edits
170
170
 
171
- A single high-reasoning session turns the aggregated gradients into concrete edits: ADD,
171
+ A high-reasoning synthesis run turns the aggregated gradients into concrete edits: ADD,
172
172
  REMOVE, REWRITE, or EXTRACT→SKILL. The agent does not describe edits for backpass to
173
173
  splice in - it makes them, with its harness's own file tools, in a **staging copy** of the
174
174
  memory file under `.backpass/synthesis/` (the repo itself is read-only to it, for
@@ -189,10 +189,31 @@ Then mechanical gates run, and they are not negotiable:
189
189
  - every edit carries a verbatim quote
190
190
  - the post-edit file must fit the budget, measured on the staged file
191
191
 
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.
192
+ An extraction is the created `SKILL.md` plus the memory-file change that pays for it.
193
+ Neighbouring removals are merged into one measured change, and a merged change cannot be
194
+ accepted in halves - so when several sections leave together, their skills arrive as one
195
+ extract with several skills, which is one honest accept/reject decision. Skills whose
196
+ removals were measured separately stay separate decisions, and bundling them is refused.
197
+
198
+ A malformed answer or gate violation triggers a re-prompt naming the exact breach (at
199
+ most two). If those also fail, backpass **fails loudly** and preserves the latest parseable
200
+ rejected proposal, if one was produced. It never silently truncates. A harness that writes
201
+ past the staging copy into the repo is an error, never an apply.
202
+
203
+ Not every annotate turn is an answer, and the three cases are kept apart because they call
204
+ for different things:
205
+
206
+ - **the agent edited the copy again** - the ids it was given no longer describe the files.
207
+ It is shown the fresh measurement and answers again; this costs no re-prompt.
208
+ - **the turn came back empty** - the harness returned success with no text at all. Nothing
209
+ was said, so there is nothing to correct: the annotation is retried once in a **new**
210
+ session, since the accumulated context of the old one is the likeliest cause.
211
+ - **the turn returned text** - malformed JSON or a gate violation uses an annotation
212
+ attempt and can trigger a re-prompt. Only a parseable, gate-rejected answer writes a
213
+ rejected proposal, stamped with the attempt that produced it.
214
+
215
+ When a run does fail, the advice it prints comes from the condition it ended on. Run
216
+ `backpass propose` again to start a fresh synthesis.
196
217
 
197
218
  Token deltas shown to you are measured by backpass from the actual text - never taken from
198
219
  the model's own arithmetic.
@@ -249,7 +270,26 @@ edit is not proposed again unless materially new evidence arrives.
249
270
  The live budget gauge is not just a readout. Apply rechecks the accepted subset against
250
271
  the same budget gate as synthesis: stay under the cap, or shrink if the file is already
251
272
  over. An incompatible set writes nothing and does not record rejections, so you can pick
252
- a compatible set and try again.
273
+ a compatible set and try again. If the run shrinks the file but leaves it above the cap,
274
+ that is progress, not a failure: it is written, and the remaining overage is printed.
275
+
276
+ Apply preflights every accepted edit before writing. The proposal was measured against one
277
+ exact version of your memory file, so apply first checks the file still exists and is still
278
+ that version. If it was removed or changed since - you pulled, edited it by hand, or another
279
+ agent did - the edits no longer describe what is on disk, so nothing is written and you are
280
+ told to run `backpass` again to re-propose against the current file. Within a run every file
281
+ is composed from one version: it takes every accepted edit or none of them. Apply also
282
+ refuses the whole write if any created skill target already exists or two accepted paths
283
+ resolve to the same file.
284
+
285
+ Skills and non-memory files are written only after every edit has composed, with the memory
286
+ file committed last. A later write failure rolls back files, skills, and loading-layout
287
+ entries created earlier in that round. If another process changes a committed file before
288
+ rollback reaches it, apply leaves that change untouched and reports the rollback conflict.
289
+
290
+ For compatibility, proposals created by older backpass versions that do not contain a
291
+ memory-file hash skip the freshness check. Regenerate such a proposal before applying it if
292
+ the repository may have changed.
253
293
 
254
294
  ```sh
255
295
  backpass apply --no-ui # same decision, in the terminal
@@ -335,9 +375,10 @@ backpass \
335
375
  --synthesis-agent claude --synthesis-model claude-opus-5 --synthesis-effort high
336
376
  ```
337
377
 
338
- `--no-auto-agent` pins the pre-ladder defaults (codex / claude). If an adapter does not
339
- advertise reasoning effort, backpass says so in the run report rather than pretending it
340
- applied.
378
+ `--no-auto-agent` pins the pre-ladder defaults (codex / claude). Model and effort
379
+ overrides are scoped to that Backpass invocation, so they never rewrite your harness
380
+ defaults. If an adapter has no proven overlay, backpass stops rather than pretending it
381
+ applied, except OpenCode effort which is skipped with a report note.
341
382
 
342
383
  ### Configuration
343
384
 
@@ -388,7 +429,7 @@ Everything mutable lives in `.backpass/`, kept out of git via the repo's local e
388
429
  scan-cache.json collect-samples verdicts by path + mtime + size
389
430
  evidence/<id>.json per-transcript loss
390
431
  evidence-summary.json aggregated gradients
391
- proposal.json the latest gradient-descent step
432
+ proposal.json the latest parseable gradient-descent step (absent if none was produced)
392
433
  synthesis/ the staging copy the gradient-descent agent edited (memory file + skills)
393
434
  prompts/ the exact prompts of the last run
394
435
  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.6",
3
+ "version": "0.1.8",
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({
@@ -265,6 +265,7 @@ export async function analyzeTranscripts({ transcripts, memoryFile, config, repo
265
265
  emitProgress("analyze:evidence", { ...evidenceTotals });
266
266
  }
267
267
  } catch (err) {
268
+ if (err instanceof UserError) throw err;
268
269
  // Per-transcript fail-soft: recorded, listed by `backpass status`, retried next run.
269
270
  summary.failed += 1;
270
271
  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) {