backpass 0.1.5 → 0.1.7

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
@@ -249,7 +249,24 @@ edit is not proposed again unless materially new evidence arrives.
249
249
  The live budget gauge is not just a readout. Apply rechecks the accepted subset against
250
250
  the same budget gate as synthesis: stay under the cap, or shrink if the file is already
251
251
  over. An incompatible set writes nothing and does not record rejections, so you can pick
252
- a compatible set and try again.
252
+ a compatible set and try again. If the run shrinks the file but leaves it above the cap,
253
+ that is progress, not a failure: it is written, and the remaining overage is printed.
254
+
255
+ Apply preflights every accepted edit before writing. The proposal was measured against one
256
+ exact version of your memory file, so apply first checks the file still exists and is still
257
+ that version. If it was removed or changed since - you pulled, edited it by hand, or another
258
+ 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.
266
+
267
+ For compatibility, proposals created by older backpass versions that do not contain a
268
+ memory-file hash skip the freshness check. Regenerate such a proposal before applying it if
269
+ the repository may have changed.
253
270
 
254
271
  ```sh
255
272
  backpass apply --no-ui # same decision, in the terminal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpass",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -78,7 +78,12 @@ export function parseDecisions(text, editIds) {
78
78
  }
79
79
 
80
80
  export async function openApplySurface(file) {
81
- const result = await runLavish([file]);
81
+ // `backpass apply` is itself the human asking for the review surface, so a session the
82
+ // reviewer ended from the browser in an earlier run has to be reopened, not refused.
83
+ // lavish-axi keys sessions by file path and `ended_by: "user"` is sticky, so without
84
+ // `--reopen` it exits 0 and prints the dead session's URL - which apply would then hand
85
+ // the reviewer as if it were live. On a live or agent-ended session the flag is a no-op.
86
+ const result = await runLavish([file, "--reopen", "--no-open"]);
82
87
  if (result.spawnError && result.spawnError.code === "ENOENT") {
83
88
  throw new UserError(
84
89
  `${LAVISH_BIN} not found on PATH`,
@@ -133,7 +138,13 @@ export async function pollDecisions(file, editIds, { delayMs = POLL_RETRY_DELAY_
133
138
  const decisions = parseDecisions(text, editIds);
134
139
  if (decisions) return decisions;
135
140
 
136
- if (/session\s+(ended|closed)/i.test(text)) {
141
+ // lavish-axi reports an end through the session fields, never in prose: `status: ended`
142
+ // when nothing was queued, `session_ended: true` alongside the final `status: feedback`.
143
+ // Scope these fields to the leading session metadata block so field-like feedback or
144
+ // diagnostics cannot end the wait. parseDecisions has already run, so a `Send & End`
145
+ // carrying a vector still applies.
146
+ const session = /^session:\s*\r?\n((?:[ \t]+[^\r\n]*(?:\r?\n|$))*)/m.exec(text)?.[1] || "";
147
+ if (/^\s*status:\s*ended\b/m.test(session) || /^\s*session_ended:\s*true\b/m.test(session)) {
137
148
  warn("review session ended without a decision vector - nothing applied");
138
149
  return null;
139
150
  }
@@ -2,18 +2,19 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  import { applyEdit, projectWithDecisions } from "../proposal.js";
5
- import { budgetGateKind, budgetStatus } from "../tokens.js";
5
+ import { memoryTextHash } from "../memory.js";
6
+ import { budgetGateKind, budgetStatus, formatTokens } from "../tokens.js";
6
7
  import { recordRejection } from "../state.js";
7
8
  import { writeSkill } from "../skills.js";
8
9
 
9
- function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
10
+ function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens, memoryText }) {
10
11
  if (!accepted.length) return null;
11
12
 
12
13
  const relative = proposal.memoryFile.path;
13
14
  const absolute = path.join(repo.root, relative);
14
- if (!fs.existsSync(absolute)) return null;
15
+ if (memoryText === null && !fs.existsSync(absolute)) return null;
15
16
 
16
- const before = fs.readFileSync(absolute, "utf8");
17
+ const before = memoryText ?? fs.readFileSync(absolute, "utf8");
17
18
  const { budget } = projectWithDecisions(
18
19
  before,
19
20
  accepted,
@@ -41,14 +42,75 @@ function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
41
42
  return null;
42
43
  }
43
44
 
45
+ /**
46
+ * Freshness before mutation.
47
+ *
48
+ * Every hunk was cut from one exact image of the memory file, and the proposal records
49
+ * that image's hash. If the file has disappeared or changed since - an upstream merge,
50
+ * a hand edit, another agent - then the hunks describe text that may no longer exist, and the ones
51
+ * that still happen to match would leave the file half-descended: part of a shrink plan
52
+ * applied against a file the plan was never measured against. So the run is refused
53
+ * before it writes anything, and the fix is to re-measure, not to salvage.
54
+ *
55
+ * A proposal saved before this field existed carries no hash and is left alone.
56
+ */
57
+ function memoryFileSnapshot(proposal, repo) {
58
+ const expected = proposal.memoryFile?.hash;
59
+ const relative = proposal.memoryFile?.path;
60
+ if (!relative) return { text: null };
61
+
62
+ const absolute = path.join(repo.root, relative);
63
+ if (!fs.existsSync(absolute)) {
64
+ if (!expected) return { text: null };
65
+ return {
66
+ text: null,
67
+ failure: {
68
+ file: relative,
69
+ error:
70
+ `${relative} no longer exists, so its edits no longer describe the file on disk; nothing was written. ` +
71
+ `Run \`backpass\` to re-propose against the current repository.`,
72
+ },
73
+ };
74
+ }
75
+
76
+ const text = fs.readFileSync(absolute, "utf8");
77
+ if (!expected) return { text };
78
+
79
+ const observed = memoryTextHash(text);
80
+ if (observed === expected) return { text };
81
+
82
+ return {
83
+ text,
84
+ failure: {
85
+ file: relative,
86
+ error:
87
+ `${relative} changed after this proposal was made (${expected} -> ${observed}), so its edits ` +
88
+ `no longer describe the file on disk; nothing was written. Run \`backpass\` to re-propose ` +
89
+ `against the current ${relative}.`,
90
+ },
91
+ };
92
+ }
93
+
94
+ function overBudgetWarning(relative, budget) {
95
+ return (
96
+ `${relative} is still ${formatTokens(budget.over)} tokens over the ${formatTokens(budget.capTokens)}-token ` +
97
+ "budget; run `backpass` again for the next shrink step"
98
+ );
99
+ }
100
+
44
101
  /**
45
102
  * The only place in backpass that writes to the repo.
46
103
  *
47
104
  * Everything upstream is read-only analysis; a run only changes the weights here, after
48
- * a human accepted specific edits. The accepted subset is rechecked with the same
49
- * cap/shrink budget gate as the full proposal (`budgetGateKind`); a failing subset
50
- * returns with no writes and no rejection ledger. Writes are grouped per file so a
51
- * memory file is rewritten once, atomically, rather than edit by edit.
105
+ * a human accepted specific edits. Three gates run before the first byte is written:
106
+ * the memory file must still be the file the proposal was measured against
107
+ * (`memoryFileSnapshot`), the accepted subset must clear the same cap/shrink budget gate as
108
+ * the full proposal (`budgetGateKind`), and every accepted edit for a file must compose
109
+ * against that file's single pre-write image. Any of them failing writes nothing and
110
+ * records no rejection.
111
+ *
112
+ * A file is therefore applied all at once or not at all. Skills are written only after
113
+ * every accepted edit has composed, and before the files that reference them.
52
114
  */
53
115
  export function applyDecisions({ proposal, decisions, repo, state, config, dryRun = false }) {
54
116
  const accepted = proposal.edits.filter((e) => decisions[e.id] === "accepted");
@@ -65,11 +127,22 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
65
127
  rejectionsRecorded: false,
66
128
  };
67
129
 
130
+ let memoryText = null;
131
+ if (accepted.length || rejected.length) {
132
+ const snapshot = memoryFileSnapshot(proposal, repo);
133
+ if (snapshot.failure) {
134
+ results.failed.push(snapshot.failure);
135
+ return results;
136
+ }
137
+ memoryText = snapshot.text;
138
+ }
139
+
68
140
  const budgetFailure = acceptedSubsetBudgetFailure({
69
141
  proposal,
70
142
  accepted,
71
143
  repo,
72
144
  capTokens: config.budgetTokens,
145
+ memoryText,
73
146
  });
74
147
  if (budgetFailure) {
75
148
  results.failed.push(budgetFailure);
@@ -81,6 +154,11 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
81
154
  byFile.get(edit.file).push(edit);
82
155
  }
83
156
 
157
+ // Compose first, write later. Each file's accepted edits are applied to one immutable
158
+ // image of that file; only a set that composes completely earns a write.
159
+ const planned = [];
160
+ const landed = new Set();
161
+
84
162
  for (const [relative, edits] of byFile) {
85
163
  const absolute = path.join(repo.root, relative);
86
164
  if (!fs.existsSync(absolute)) {
@@ -88,38 +166,83 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
88
166
  continue;
89
167
  }
90
168
 
91
- const before = fs.readFileSync(absolute, "utf8");
169
+ const before =
170
+ relative === proposal.memoryFile?.path && memoryText !== null ? memoryText : fs.readFileSync(absolute, "utf8");
92
171
  let text = before;
93
172
  const applied = [];
173
+ const failures = [];
94
174
 
95
175
  for (const edit of edits) {
96
176
  try {
97
177
  text = applyEdit(text, edit);
98
178
  applied.push(edit.id);
99
179
  } catch (err) {
100
- results.failed.push({ file: relative, edit: edit.id, error: err.message });
180
+ failures.push({ file: relative, edit: edit.id, error: err.message });
101
181
  }
102
182
  }
103
183
 
104
- if (text === before) continue;
105
-
106
- const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
184
+ if (failures.length) {
185
+ results.failed.push(...failures);
186
+ if (applied.length) {
187
+ results.failed.push({
188
+ file: relative,
189
+ error: `${relative} was left unchanged: a file takes every accepted edit or none of them`,
190
+ });
191
+ }
192
+ continue;
193
+ }
107
194
 
108
- if (!dryRun) fs.writeFileSync(absolute, text);
109
- results.written.push({ file: relative, edits: applied, budget, dryRun });
195
+ if (text === before) continue;
196
+ planned.push({ relative, absolute, before, text, applied });
197
+ for (const id of applied) landed.add(id);
110
198
  }
111
199
 
200
+ if (results.failed.length) return results;
201
+
202
+ // Skills go in before the memory file. A skill nothing points at yet is inert, while a
203
+ // memory file pointing at a skill that is not there is actively wrong - so if a skill
204
+ // cannot be written, the files that would reference it are left alone.
205
+ const skillFailures = [];
206
+ const writtenSkillPaths = [];
112
207
  for (const edit of accepted) {
113
208
  if (edit.kind !== "extract" || !edit.skill) continue;
209
+ if (!landed.has(edit.id)) continue;
114
210
  try {
115
211
  const layout = dryRun ? { created: [], warnings: [] } : writeSkill(repo.root, edit.skill);
116
212
  results.skills.push({ path: edit.skill.path, dryRun, created: layout.created });
213
+ if (!dryRun) writtenSkillPaths.push(edit.skill.path);
117
214
  for (const w of layout.warnings) if (!results.warnings.includes(w)) results.warnings.push(w);
118
215
  } catch (err) {
119
- results.failed.push({ file: edit.skill.path, edit: edit.id, error: err.message });
216
+ skillFailures.push({ file: edit.skill.path, edit: edit.id, error: err.message });
120
217
  }
121
218
  }
122
219
 
220
+ if (skillFailures.length) {
221
+ results.failed.push(...skillFailures);
222
+ if (writtenSkillPaths.length) {
223
+ results.failed.push({
224
+ error: `skill paths already written in this round: ${writtenSkillPaths.join(", ")}; remove them before retrying`,
225
+ });
226
+ }
227
+ for (const { relative } of planned) {
228
+ results.failed.push({
229
+ file: relative,
230
+ error: `${relative} was left unchanged: its edits point at a skill that could not be written`,
231
+ });
232
+ }
233
+ return results;
234
+ }
235
+
236
+ for (const { relative, absolute, before, text, applied } of planned) {
237
+ const budget = relative === proposal.memoryFile.path ? budgetStatus(before, text, config.budgetTokens) : null;
238
+
239
+ if (!dryRun) fs.writeFileSync(absolute, text);
240
+ results.written.push({ file: relative, edits: applied, budget, dryRun });
241
+
242
+ // Shrinking over several runs is the design, so this is a heading, not a failure.
243
+ if (budget && !budget.withinBudget) results.warnings.push(overBudgetWarning(relative, budget));
244
+ }
245
+
123
246
  // Rejections are remembered so the same edit is not re-proposed without new evidence.
124
247
  if (!dryRun && rejected.length) {
125
248
  const rejections = state.readRejections();
@@ -10,8 +10,8 @@ import { budgetBar, formatTokens } from "../tokens.js";
10
10
  *
11
11
  * By default it serves the shipped static template through lavish-axi and waits for one
12
12
  * structured decision vector; `--no-ui` keeps the same ACCEPT/REJECT decision in the
13
- * terminal. `applyDecisions` revalidates the accepted subset against the budget before
14
- * writing; a failing set records no rejections.
13
+ * terminal. `applyDecisions` owns the pre-write freshness, budget, and composition gates;
14
+ * a failing gate records no rejections.
15
15
  */
16
16
  export async function cmdApply(ctx) {
17
17
  const { config, repo } = ctx;
package/src/memory.js CHANGED
@@ -116,6 +116,15 @@ export function parseMemoryUnits(text) {
116
116
  }));
117
117
  }
118
118
 
119
+ /**
120
+ * The fingerprint of one memory file's exact bytes. It is written into the proposal and
121
+ * re-checked at apply, so it has exactly one definition: a proposal and the freshness
122
+ * check that guards it can never disagree about what "unchanged" means.
123
+ */
124
+ export function memoryTextHash(text) {
125
+ return `sha256:${sha256(text).slice(0, 16)}`;
126
+ }
127
+
119
128
  export function readMemoryFile(repoRoot, relativePath) {
120
129
  const absolute = path.join(repoRoot, relativePath);
121
130
  if (!fs.existsSync(absolute)) return null;
@@ -124,7 +133,7 @@ export function readMemoryFile(repoRoot, relativePath) {
124
133
  path: relativePath,
125
134
  absolute,
126
135
  text,
127
- hash: `sha256:${sha256(text).slice(0, 16)}`,
136
+ hash: memoryTextHash(text),
128
137
  tokens: estimateTokens(text),
129
138
  units: parseMemoryUnits(text),
130
139
  };
package/src/tokens.js CHANGED
@@ -47,9 +47,16 @@ export function budgetGateKind(budget) {
47
47
  return null;
48
48
  }
49
49
 
50
- /** Fixed-width ASCII gauge for `backpass status`. */
50
+ /**
51
+ * Fixed-width ASCII gauge for `backpass status`.
52
+ *
53
+ * Over budget the `!!` marker gets its own cells rather than the leftover ones, because
54
+ * past 100% there are no leftover cells: a file at twice its cap must not render as a
55
+ * merely-full bar.
56
+ */
51
57
  export function budgetBar(status, width = 32) {
52
- const filled = Math.min(width, Math.round(status.utilization * width));
53
- const overflow = status.withinBudget ? 0 : Math.min(width - filled, 2);
54
- return `[${"#".repeat(filled)}${"!".repeat(overflow)}${".".repeat(Math.max(0, width - filled - overflow))}]`;
58
+ const overflow = status.withinBudget ? 0 : Math.min(width, 2);
59
+ const cells = width - overflow;
60
+ const filled = Math.min(cells, Math.round(status.utilization * width));
61
+ return `[${"#".repeat(filled)}${"!".repeat(overflow)}${".".repeat(Math.max(0, cells - filled))}]`;
55
62
  }