backpass 0.1.3 → 0.1.5

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
@@ -90,6 +90,11 @@ backpass reads the local transcript stores of seven harnesses directly. No API,
90
90
  | **cursor CLI** | `~/.cursor/chats/<md5(cwd)>/<uuid>/` | `meta.json` `cwd` |
91
91
  | **hermes** | `~/.hermes/state.db` (sqlite) | session cwd, with CLI prompt / ACP config fallbacks |
92
92
 
93
+ Claude collection covers `$CLAUDE_CONFIG_DIR/projects` alongside the default store, so a
94
+ relocated config dir does not hide its sessions. The variable is read from backpass's own
95
+ environment: if you reach that profile through an alias that only prefixes `claude`, set it
96
+ for the backpass run too (`CLAUDE_CONFIG_DIR=~/.claude-work backpass`, or export it).
97
+
93
98
  Hermes collection includes CLI and ACP sessions only. Gateway, cron, and WhatsApp sessions
94
99
  are excluded because their recorded cwd belongs to the shared gateway process, not a project.
95
100
 
@@ -241,6 +246,11 @@ a headless box or `--no-open` just hands you the link.
241
246
  There is no DEFER button, and it isn't missing: **rejections are remembered.** A rejected
242
247
  edit is not proposed again unless materially new evidence arrives.
243
248
 
249
+ The live budget gauge is not just a readout. Apply rechecks the accepted subset against
250
+ the same budget gate as synthesis: stay under the cap, or shrink if the file is already
251
+ over. An incompatible set writes nothing and does not record rejections, so you can pick
252
+ a compatible set and try again.
253
+
244
254
  ```sh
245
255
  backpass apply --no-ui # same decision, in the terminal
246
256
  backpass apply --no-open # print the surface URL, don't launch a browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backpass",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -1,17 +1,54 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
- import { applyEdit } from "../proposal.js";
5
- import { budgetStatus } from "../tokens.js";
4
+ import { applyEdit, projectWithDecisions } from "../proposal.js";
5
+ import { budgetGateKind, budgetStatus } from "../tokens.js";
6
6
  import { recordRejection } from "../state.js";
7
7
  import { writeSkill } from "../skills.js";
8
8
 
9
+ function acceptedSubsetBudgetFailure({ proposal, accepted, repo, capTokens }) {
10
+ if (!accepted.length) return null;
11
+
12
+ const relative = proposal.memoryFile.path;
13
+ const absolute = path.join(repo.root, relative);
14
+ if (!fs.existsSync(absolute)) return null;
15
+
16
+ const before = fs.readFileSync(absolute, "utf8");
17
+ const { budget } = projectWithDecisions(
18
+ before,
19
+ accepted,
20
+ accepted.map((edit) => edit.id),
21
+ capTokens,
22
+ );
23
+ const gate = budgetGateKind(budget);
24
+ if (gate === "cap") {
25
+ return {
26
+ file: relative,
27
+ error:
28
+ `accepted edits leave ${relative} at ${budget.projected} tokens, ${budget.over} over the ` +
29
+ `${capTokens}-token budget; choose a compatible set of edits`,
30
+ };
31
+ }
32
+ if (gate === "shrink") {
33
+ return {
34
+ file: relative,
35
+ error:
36
+ `${relative} is already ${budget.current - capTokens} tokens over the ${capTokens}-token budget, ` +
37
+ `so accepted edits must shrink it, but they change it by ${budget.delta >= 0 ? "+" : ""}${budget.delta} ` +
38
+ "tokens; choose a compatible set of edits",
39
+ };
40
+ }
41
+ return null;
42
+ }
43
+
9
44
  /**
10
45
  * The only place in backpass that writes to the repo.
11
46
  *
12
47
  * Everything upstream is read-only analysis; a run only changes the weights here, after
13
- * a human accepted specific edits. Writes are grouped per file so a memory file is
14
- * rewritten once, atomically, rather than edit by edit.
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.
15
52
  */
16
53
  export function applyDecisions({ proposal, decisions, repo, state, config, dryRun = false }) {
17
54
  const accepted = proposal.edits.filter((e) => decisions[e.id] === "accepted");
@@ -25,8 +62,20 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
25
62
  warnings: [],
26
63
  accepted: accepted.length,
27
64
  rejected: rejected.length,
65
+ rejectionsRecorded: false,
28
66
  };
29
67
 
68
+ const budgetFailure = acceptedSubsetBudgetFailure({
69
+ proposal,
70
+ accepted,
71
+ repo,
72
+ capTokens: config.budgetTokens,
73
+ });
74
+ if (budgetFailure) {
75
+ results.failed.push(budgetFailure);
76
+ return results;
77
+ }
78
+
30
79
  for (const edit of accepted) {
31
80
  if (!byFile.has(edit.file)) byFile.set(edit.file, []);
32
81
  byFile.get(edit.file).push(edit);
@@ -76,6 +125,7 @@ export function applyDecisions({ proposal, decisions, repo, state, config, dryRu
76
125
  const rejections = state.readRejections();
77
126
  for (const edit of rejected) recordRejection(edit, rejections);
78
127
  state.writeRejections(rejections);
128
+ results.rejectionsRecorded = true;
79
129
  }
80
130
 
81
131
  return results;
@@ -10,7 +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.
13
+ * terminal. `applyDecisions` revalidates the accepted subset against the budget before
14
+ * writing; a failing set records no rejections.
14
15
  */
15
16
  export async function cmdApply(ctx) {
16
17
  const { config, repo } = ctx;
@@ -97,7 +98,7 @@ export async function cmdApply(ctx) {
97
98
  out(` ${color.red("failed")} ${failure.file}${failure.edit ? ` (${failure.edit})` : ""}: ${failure.error}`);
98
99
  }
99
100
 
100
- if (results.rejected) {
101
+ if (results.rejectionsRecorded) {
101
102
  out(color.dim(" rejections recorded - they will not be re-proposed without new evidence"));
102
103
  }
103
104
  if (!results.written.length && !results.skills.length) out(" nothing written");
@@ -19,23 +19,33 @@ import {
19
19
  * directory name is a lossy munge of the cwd (slashes and dots both become dashes),
20
20
  * so it is only used to narrow the search - the per-line `cwd` is the authority.
21
21
  * No git remote is recorded, so a deleted worktree can only reach tier 3.
22
+ *
23
+ * `CLAUDE_CONFIG_DIR` relocates the whole config dir, and it is commonly set per
24
+ * invocation (a shell alias for a work profile), which splits a machine's sessions across
25
+ * two stores rather than moving them - so both roots are scanned. The variable is read
26
+ * from this process's environment; an alias that only prefixes `claude` never reaches it.
22
27
  */
23
28
 
24
29
  const HEADER_LINES = 40;
25
30
 
26
31
  export const name = "claude";
27
32
 
28
- export function storeRoot() {
29
- return home(".claude", "projects");
33
+ export function storeRoots() {
34
+ const roots = [home(".claude", "projects")];
35
+ const configured = process.env.CLAUDE_CONFIG_DIR;
36
+ if (configured) roots.push(path.join(configured, "projects"));
37
+ return [...new Set(roots)];
30
38
  }
31
39
 
32
40
  export function enumerate() {
33
41
  const out = [];
34
- for (const dir of listDirs(storeRoot())) {
35
- for (const file of listFiles(dir, ".jsonl")) {
36
- const stat = statOrNull(file);
37
- if (!stat) continue;
38
- out.push({ key: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
42
+ for (const root of storeRoots()) {
43
+ for (const dir of listDirs(root)) {
44
+ for (const file of listFiles(dir, ".jsonl")) {
45
+ const stat = statOrNull(file);
46
+ if (!stat) continue;
47
+ out.push({ key: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
48
+ }
39
49
  }
40
50
  }
41
51
  return out;
package/src/proposal.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { renderHunkLines } from "./diff.js";
2
- import { budgetStatus, estimateTokens } from "./tokens.js";
2
+ import { budgetGateKind, budgetStatus, estimateTokens } from "./tokens.js";
3
3
 
4
4
  /**
5
5
  * The proposal model: what a synthesis pass is allowed to produce, and the mechanical
6
- * gates it must clear before a human ever sees it (design sections 3, 6, 7).
6
+ * gates it must clear before a human ever sees it (design sections 3, 6, 7). The
7
+ * budget gate (`budgetGateKind`) runs again on the accepted subset at apply.
7
8
  *
8
9
  * The synthesis agent edits a staging copy of the memory file natively
9
10
  * (`src/workspace.js`); backpass measures the result as anchored hunks (`src/diff.js`)
@@ -355,12 +356,13 @@ export function buildProposal(rawResult, context) {
355
356
  budget.mode = memoryFile.tokens > config.budgetTokens ? "shrink" : "cap";
356
357
  budget.startedOverBudget = budget.mode === "shrink";
357
358
 
358
- if (budget.mode === "cap" && !budget.withinBudget) {
359
+ const gate = budgetGateKind(budget);
360
+ if (gate === "cap") {
359
361
  violations.push(
360
362
  `applying every proposed edit leaves ${memoryFile.path} at ${budget.projected} tokens, ` +
361
363
  `${budget.over} over the ${config.budgetTokens}-token budget`,
362
364
  );
363
- } else if (budget.mode === "shrink" && budget.delta >= 0) {
365
+ } else if (gate === "shrink") {
364
366
  violations.push(
365
367
  `${memoryFile.path} is already ${budget.current - config.budgetTokens} tokens over the ` +
366
368
  `${config.budgetTokens}-token budget, so this run must shrink it, but the proposed edits ` +
package/src/tokens.js CHANGED
@@ -40,6 +40,13 @@ export function budgetStatus(currentText, projectedText, capTokens) {
40
40
  };
41
41
  }
42
42
 
43
+ /** Cap: stay under. Shrink: already over, so the delta must be negative. */
44
+ export function budgetGateKind(budget) {
45
+ if (budget.current <= budget.capTokens && !budget.withinBudget) return "cap";
46
+ if (budget.current > budget.capTokens && budget.delta >= 0) return "shrink";
47
+ return null;
48
+ }
49
+
43
50
  /** Fixed-width ASCII gauge for `backpass status`. */
44
51
  export function budgetBar(status, width = 32) {
45
52
  const filled = Math.min(width, Math.round(status.utilization * width));