Skip to content

feat(config): allow a per-file budget on nested memory file entries - #211

Open
Pantoria wants to merge 2 commits into
kunchenguid:mainfrom
Pantoria:feat/190-nested-per-file-budget
Open

Pantoria wants to merge 2 commits into
kunchenguid:mainfrom
Pantoria:feat/190-nested-per-file-budget

Conversation

@Pantoria

Copy link
Copy Markdown
Contributor

Intent

Implement issue #190: per-file budget for nested memory files. nestedBudgetTokens was one shared cap for every nestedMemoryFiles entry, which cannot fit files differing 5-10x in size. The user (issue author, Pantoria) asked to implement their own ready-for-pr backpass issues, each in its own worktree and PR. Follow AGENTS.md invariants and VISION.md. No live model calls were made while implementing.
Design, following the maintainer's stated preference for object entries over a path map: nestedMemoryFiles accepts plain strings (unchanged behavior: nestedBudgetTokens then budgetTokens) or { "path", "budgetTokens" } objects. Entries are normalized once at config load (splitNestedMemoryFiles in src/config.js) into a plain path list plus a derived nestedBudgetTokensByPath, so downstream string consumers are untouched; the derived key is always recomputed and ignored if set by hand. Propose and apply both read the cap through ONE helper, nestedBudget(config, relative) in src/nested.js (maintainer requirement, see #188); the apply change is deliberately a one-line call-site swap so the parallel #188 PR rebases cleanly. Malformed entries fail with named config errors (maintainer requirement). Deliberate choices: unknown keys in an entry are rejected (so a typo like 'budget' can't silently train under the wrong cap) even though other config objects are lenient; per-file budgetTokens must be a positive integer as the issue specified. --budget behavior is unchanged. contract-class opt-in: existing configs behave identically.

What Changed

  • nestedMemoryFiles entries can now be { "path", "budgetTokens" } objects as well as plain path strings. At config load, splitNestedMemoryFiles (src/config.js) turns them into a plain path list plus a derived nestedBudgetTokensByPath. applyNestedMemoryConfig then re-keys those caps to normalized paths. Any nestedBudgetTokensByPath set by hand is overwritten. Malformed entries fail with named config errors: a non-object, a missing or empty path, an unknown key, or a budgetTokens that isn't a positive integer.
  • nestedBudget(config, relative) (src/nested.js) now takes the file path. It returns the entry's own cap if it has one, otherwise nestedBudgetTokens, otherwise budgetTokens. Propose (nestedContext), run and status budget displays, and the apply gate in src/apply/writer.js all read the cap through this one helper. Plain string entries and --budget behave the same as before.
  • README updated with the object-entry form and its validation rules. test/nested.test.js adds tests for the new entry handling.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: This is a contained opt-in config extension. String entries keep their old behavior. Per-file caps are derived once at load, re-keyed to the normalized path in applyNestedMemoryConfig (any value set by hand is overwritten), and read through the single nestedBudget helper at every consumer (propose's nestedContext, run, status, and apply's writer). Malformed entries raise named errors.

Testing

I drove the real backpass CLI against throwaway monorepos with an isolated HOME and XDG config directory. The model and the browser review page were replaced by the repo's own stand-ins (fake acpx and fake lavish). - status showed each nested file's own cap. - String-only configs gave output identical to the base commit, both with and without --budget. - A hand-set nestedBudgetTokensByPath was ignored. - Every malformed entry failed with a config error naming the problem. - A full run plus apply proposed and wrote under the entry's 8000-token cap. When I lowered that cap to 20 between propose and apply, apply refused the write with "8 over its 20-token budget" and wrote nothing. - A plain entry kept nestedBudgetTokens (2000). - The targeted test/nested.test.js passed (31/31). Everything passed. Temporary repos were removed.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
status shows an object entry's own budgetTokens (8000) as its cap, keyed to the normalized path ('./apps/api/...'), while a string entry beside it shows nestedBudgetTokens (2000) ✅ pass live status-and-config-errors.txt S1: 'apps/api/AGENTS.md ... 14 / 8,000 tok', 'apps/web/AGENTS.md ... 13 / 2,000 tok'
Existing string-only configs: status --json output is byte-identical to the base commit, both with and without --budget ✅ pass live status-and-config-errors.txt S2: identical-to-base=true for all 4 runs
--budget 3000 with a mixed config: the string entry and the root follow --budget, and the object entry keeps 8000 ✅ pass live status-and-config-errors.txt S3 caps
Adversarial: a hand-set nestedBudgetTokensByPath {api:1} in .backpassrc.json is ignored, so the cap stays 5000 ✅ pass live status-and-config-errors.txt S4
Adversarial: malformed entries fail with a config error naming the problem and exit 1. Covers: unknown key 'budget'; budgetTokens 0, -5, 1.5, "8000" or null; a missing or empty path; an array; a numbe… ✅ pass live status-and-config-errors.txt S5
Full backpass run proposes the nested file under the entry's 8000 cap (not nestedBudgetTokens 2000), and apply writes it, reporting / 8,000 tok ✅ pass live propose-apply.txt S6
Adversarial: apply reads the cap through the same helper. A proposal made under 8000 whose entry is lowered to 20 before apply is refused ('8 over its 20-token budget'), the file is unchanged, and app… ✅ pass live propose-apply.txt S7
A plain string entry keeps nestedBudgetTokens (2000) through propose and apply ✅ pass live propose-apply.txt S8
Evidence: CLI transcript: status caps, base-commit comparison, --budget, ignored hand-set key, malformed-entry errors
=== S1 status: per-file cap for object entry, shared cap for string entry ===
{"nestedBudgetTokens":2000,"nestedMemoryFiles":["apps/web/AGENTS.md",{"path":"./apps/api/AGENTS.md","budgetTokens":8000}]}

exit 0
  AGENTS.md          [................................] 14 / 5,000 tok · 1 instructions
  apps/web/AGENTS.md [................................] 13 / 2,000 tok · 1 instructions · nested, loads under apps/web/
  apps/api/AGENTS.md [................................] 14 / 8,000 tok · 1 instructions · nested, loads under apps/api/
--json caps: [["AGENTS.md",5000],["apps/web/AGENTS.md",2000],["apps/api/AGENTS.md",8000]]

=== S2 string-only config: identical status output to base commit ===
{"nestedMemoryFiles":["apps/api/AGENTS.md"]}  -> caps [["AGENTS.md",5000],["apps/api/AGENTS.md",5000]] identical-to-base=true
{"nestedMemoryFiles":["apps/api/AGENTS.md"]} --budget 3000 -> caps [["AGENTS.md",3000],["apps/api/AGENTS.md",3000]] identical-to-base=true
{"nestedBudgetTokens":1500,"nestedMemoryFiles":["apps/api/AGENTS.md","apps/web/AGENTS.md"]}  -> caps [["AGENTS.md",5000],["apps/api/AGENTS.md",1500],["apps/web/AGENTS.md",1500]] identical-to-base=true
{"nestedBudgetTokens":1500,"nestedMemoryFiles":["apps/api/AGENTS.md","apps/web/AGENTS.md"]} --budget 3000 -> caps [["AGENTS.md",3000],["apps/api/AGENTS.md",1500],["apps/web/AGENTS.md",1500]] identical-to-base=true

=== S3 --budget with object entry: entry cap wins, string entry follows --budget ===
caps: [["AGENTS.md",3000],["apps/web/AGENTS.md",3000],["apps/api/AGENTS.md",8000]]

=== S4 hand-set nestedBudgetTokensByPath is ignored ===
caps: [["AGENTS.md",5000],["apps/api/AGENTS.md",5000]]

=== S5 malformed entries are named config errors ===
entry {"path":"apps/api/AGENTS.md","budget":8000} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has unknown key "budget" |   an entry object takes only "path" and "budgetTokens"
entry {"path":"apps/api/AGENTS.md","budgetTokens":0} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has budgetTokens 0; it must be a positive integer |   omit budgetTokens to use nestedBudgetTokens
entry {"path":"apps/api/AGENTS.md","budgetTokens":-5} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has budgetTokens -5; it must be a positive integer |   omit budgetTokens to use nestedBudgetTokens
entry {"path":"apps/api/AGENTS.md","budgetTokens":1.5} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has budgetTokens 1.5; it must be a positive integer |   omit budgetTokens to use nestedBudgetTokens
entry {"path":"apps/api/AGENTS.md","budgetTokens":"8000"} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has budgetTokens "8000"; it must be a positive integer |   omit budgetTokens to use nestedBudgetTokens
entry {"path":"apps/api/AGENTS.md","budgetTokens":null} -> exit 1: error config.nestedMemoryFiles entry for apps/api/AGENTS.md has budgetTokens null; it must be a positive integer |   omit budgetTokens to use nestedBudgetTokens
entry {"budgetTokens":8000} -> exit 1: error config.nestedMemoryFiles entry {"budgetTokens":8000} needs a "path" naming a repo-relative file
entry {"path":"","budgetTokens":8000} -> exit 1: error config.nestedMemoryFiles entry {"path":"","budgetTokens":8000} needs a "path" naming a repo-relative file
entry ["apps/api/AGENTS.md"] -> exit 1: error config.nestedMemoryFiles entry ["apps/api/AGENTS.md"] must be a repo-relative path or { "path", "budgetTokens" }
entry 7 -> exit 1: error config.nestedMemoryFiles entry 7 must be a repo-relative path or { "path", "budgetTokens" }
entry "" -> exit 1: error config.nestedMemoryFiles must be an array of repo-relative paths
Evidence: CLI transcript: full backpass run + apply under per-file cap, apply refusing a lowered per-file cap, plain entry fallback

=== S6 propose+apply under entry cap 8000 (nestedBudgetTokens 2000 for others) ===
config: {"nestedBudgetTokens":2000,"nestedMemoryFiles":[{"path":"apps/api/AGENTS.md","budgetTokens":8000}]}
backpass exit 0
  budget [................................] 14 -> 28 / 5,000 tok
  nested apps/api/AGENTS.md · 2 session(s) under apps/api/ · budget [................................] 14 -> 28 / 8,000 tok
  e2 ADD      apps/api/AGENTS.md: Run the api contract tests before editing a handler. (+14 tok, 2 transcript(s))
backpass v0.1.34 · backpass-nested-repo-bOOUYk · budget 5,000 tok · since all
· AGENTS.md: [................................] 14 / 5,000 tok · 1 instructions
· apps/api/AGENTS.md (nested): [................................] 14 / 8,000 tok · 1 instructions
· synthesizing apps/api/AGENTS.md (nested) with claude (fake-model) effort=high
proposal edits: [{"id":"e1","file":"AGENTS.md"},{"id":"e2","file":"apps/api/AGENTS.md"}]
proposal nested section: [{"memoryFile":{"path":"apps/api/AGENTS.md","hash":"sha256:aab5b0f01dd0ba93","tokens":14},"dir":"apps/api","sessions":2,"budget":{"capTokens":8000,"current":14,"projected":28,"delta":14,"withinBudget":true,"over":0,"utilization":0.0035,"mode":"cap","startedOverBudget":false,"descriptionTokens":0},"maxEdits":5,"edits":["e2"]}]
backpass apply exit 0
2 accepted · 0 rejected
  corpus interactive 4 · non-interactive 0
  wrote apps/api/AGENTS.md (e2)
    budget [................................] 14 -> 28 / 8,000 tok
  wrote AGENTS.md (e1)
    budget [................................] 14 -> 28 / 5,000 tok
· review surface: http://127.0.0.1:4387/session/51233fdaf6389690
waiting for your decisions in the browser (Ctrl-C to abort; nothing is written until you send)
apps/api/AGENTS.md changed: true
apps/api/AGENTS.md now:
# API memory

- Handlers live in apps/api/src/routes.
- Run the api contract tests before editing a handler.


=== S7 apply re-reads the per-file cap: proposed under 8000, entry lowered to 20 before apply ===
config: {"nestedMemoryFiles":[{"path":"apps/api/AGENTS.md","budgetTokens":8000}]}
backpass exit 0
  budget [................................] 14 -> 28 / 5,000 tok
  nested apps/api/AGENTS.md · 2 session(s) under apps/api/ · budget [................................] 14 -> 28 / 8,000 tok
  e2 ADD      apps/api/AGENTS.md: Run the api contract tests before editing a handler. (+14 tok, 2 transcript(s))
backpass v0.1.34 · backpass-nested-repo-DC4ghD · budget 5,000 tok · since all
· AGENTS.md: [................................] 14 / 5,000 tok · 1 instructions
· apps/api/AGENTS.md (nested): [................................] 14 / 8,000 tok · 1 instructions
· synthesizing apps/api/AGENTS.md (nested) with claude (fake-model) effort=high
proposal edits: [{"id":"e1","file":"AGENTS.md"},{"id":"e2","file":"apps/api/AGENTS.md"}]
proposal nested section: [{"memoryFile":{"path":"apps/api/AGENTS.md","hash":"sha256:aab5b0f01dd0ba93","tokens":14},"dir":"apps/api","sessions":2,"budget":{"capTokens":8000,"current":14,"projected":28,"delta":14,"withinBudget":true,"over":0,"utilization":0.0035,"mode":"cap","startedOverBudget":false,"descriptionTokens":0},"maxEdits":5,"edits":["e2"]}]
config changed before apply: {"nestedMemoryFiles":[{"path":"apps/api/AGENTS.md","budgetTokens":20}]}
backpass apply exit 1
2 accepted · 0 rejected
  corpus interactive 4 · non-interactive 0
  failed apps/api/AGENTS.md: accepted edits leave apps/api/AGENTS.md at 28 tokens, 8 over its 20-token budget; choose a compatible set of edits
  nothing written
· review surface: http://127.0.0.1:4387/session/51233fdaf6389690
waiting for your decisions in the browser (Ctrl-C to abort; nothing is written until you send)
apps/api/AGENTS.md changed: false
apps/api/AGENTS.md now:
# API memory

- Handlers live in apps/api/src/routes.


=== S8 plain string entry keeps nestedBudgetTokens through propose+apply ===
config: {"nestedBudgetTokens":2000,"nestedMemoryFiles":["apps/api/AGENTS.md"]}
backpass exit 0
  budget [................................] 14 -> 28 / 5,000 tok
  nested apps/api/AGENTS.md · 2 session(s) under apps/api/ · budget [................................] 14 -> 28 / 2,000 tok
  e2 ADD      apps/api/AGENTS.md: Run the api contract tests before editing a handler. (+14 tok, 2 transcript(s))
backpass v0.1.34 · backpass-nested-repo-Dl7RGQ · budget 5,000 tok · since all
· AGENTS.md: [................................] 14 / 5,000 tok · 1 instructions
· apps/api/AGENTS.md (nested): [................................] 14 / 2,000 tok · 1 instructions
· synthesizing apps/api/AGENTS.md (nested) with claude (fake-model) effort=high
proposal edits: [{"id":"e1","file":"AGENTS.md"},{"id":"e2","file":"apps/api/AGENTS.md"}]
proposal nested section: [{"memoryFile":{"path":"apps/api/AGENTS.md","hash":"sha256:aab5b0f01dd0ba93","tokens":14},"dir":"apps/api","sessions":2,"budget":{"capTokens":2000,"current":14,"projected":28,"delta":14,"withinBudget":true,"over":0,"utilization":0.014,"mode":"cap","startedOverBudget":false,"descriptionTokens":0},"maxEdits":5,"edits":["e2"]}]
backpass apply exit 0
2 accepted · 0 rejected
  corpus interactive 4 · non-interactive 0
  wrote apps/api/AGENTS.md (e2)
    budget [................................] 14 -> 28 / 2,000 tok
  wrote AGENTS.md (e1)
    budget [................................] 14 -> 28 / 5,000 tok
· review surface: http://127.0.0.1:4387/session/51233fdaf6389690
waiting for your decisions in the browser (Ctrl-C to abort; nothing is written until you send)
apps/api/AGENTS.md changed: true
apps/api/AGENTS.md now:
# API memory

- Handlers live in apps/api/src/routes.
- Run the api contract tests before editing a handler.
  • Evidence: Scenario driver scripts (local file: ~/.no-mistakes/evidence/01M503TY2WM6YVASFNZNJAK556/driver)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
status shows an object entry's own budgetTokens (8000) as its cap, keyed to the normalized path ('./apps/api/...'), while a string entry beside it shows nestedBudgetTokens (2000) ✅ pass live status-and-config-errors.txt S1: 'apps/api/AGENTS.md ... 14 / 8,000 tok', 'apps/web/AGENTS.md ... 13 / 2,000 tok'
Existing string-only configs: status --json output is byte-identical to the base commit, both with and without --budget ✅ pass live status-and-config-errors.txt S2: identical-to-base=true for all 4 runs
--budget 3000 with a mixed config: the string entry and the root follow --budget, and the object entry keeps 8000 ✅ pass live status-and-config-errors.txt S3 caps
Adversarial: a hand-set nestedBudgetTokensByPath {api:1} in .backpassrc.json is ignored, so the cap stays 5000 ✅ pass live status-and-config-errors.txt S4
Adversarial: malformed entries fail with a config error naming the problem and exit 1. Covers: unknown key 'budget'; budgetTokens 0, -5, 1.5, "8000" or null; a missing or empty path; an array; a numbe… ✅ pass live status-and-config-errors.txt S5
Full backpass run proposes the nested file under the entry's 8000 cap (not nestedBudgetTokens 2000), and apply writes it, reporting / 8,000 tok ✅ pass live propose-apply.txt S6
Adversarial: apply reads the cap through the same helper. A proposal made under 8000 whose entry is lowered to 20 before apply is refused ('8 over its 20-token budget'), the file is unchanged, and app… ✅ pass live propose-apply.txt S7
A plain string entry keeps nestedBudgetTokens (2000) through propose and apply ✅ pass live propose-apply.txt S8
  • backpass status (text and --json) on a repo with a string entry and an {path:'./apps/api/AGENTS.md', budgetTokens:8000} entry (driver: evidence/driver/scenarios.mjs)
  • backpass status --json with string-only configs, with and without --budget 3000, on this commit vs. a git archive copy of base e6a030a, comparing the full output byte for byte
  • backpass status --json --budget 3000 with a mixed config
  • backpass status --json with a hand-set nestedBudgetTokensByPath
  • backpass status with 11 malformed nestedMemoryFiles entries
  • full backpass run (pi sessions plus the repo's stand-in acpx for the model) followed by backpass apply (stand-in lavish accepting all edits), with the per-file entry, the per-file cap lowered before apply, and a plain string entry (driver: evidence/driver/run-apply.mjs)
  • node --test test/nested.test.js
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → no changes applied ✅
  • ⚠️ ESLint and tsc typecheck could not run: the worktree has no node_modules and pnpm/corepack are not on PATH, so eslint.config.js fails to resolve '@eslint/js'. Prettier --check passed on all changed files. Run pnpm install && pnpm run lint && pnpm run typecheck to finish the lint pass.

🔧 No changes applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

A `nestedMemoryFiles` entry may now be `{ "path", "budgetTokens" }` to hold
that file to its own cap; a plain path entry keeps `nestedBudgetTokens`, then
`budgetTokens`, exactly as before. Entries are split once at config load into
the path list every consumer reads plus per-path caps (re-keyed to the
normalized path by `applyNestedMemoryConfig`), and propose, status, run, and
apply all read the cap through `nestedBudget(config, path)`. A malformed
entry (missing path, non-positive-integer budgetTokens, unknown key) is a
named config error.

Fixes kunchenguid#190

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Low impact] The PR appears safe to merge; no actionable issues were found.

Reviews (2) · Last reviewed commit: "Merge origin/main into feat/190-nested-p..." · Reviewed by Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Thanks @Pantoria, this one looks good. I reviewed the diff against main and it was ready to merge. It's now conflicting only because #210 just merged (f24dbab) and touched the same spots in README.md, src/apply/writer.js and test/nested.test.js.

contract-class: opt-in. I checked the unconfigured path myself. nestedMemoryFiles defaults to [], plain string entries still resolve to nestedBudgetTokens ?? budgetTokens, and a per-file cap exists only if you write an { "path", "budgetTokens" } entry. Your live S2 shows string-only status --json is byte-identical to base.

VISION (per rule):

  1. Evidence is the only currency: aligns (no evidence path touched).
  2. The human owns the weights: aligns (the human sets each file's cap explicitly; nothing widens write scope).
  3. Nothing the model says is taken on faith: aligns (propose, run, status and the apply gate all read the cap through one nestedBudget(config, path) helper, and a hand-set nestedBudgetTokensByPath is overwritten).
  4. The budget is the constraint, not a setting: aligns strongly (small nested files finally get a real zero-sum cap).
  5. It reads what you already have and owns nothing: aligns.
  6. Failure is loud and named: aligns (malformed entries fail with named config errors).
  7. Scope / align-resist: aligns.

One thing to fix while you rebase (this is more than a text conflict): #210 added budgetMismatches() in src/apply/writer.js, and it compares each nested file's recorded nested[].budget.capTokens against nestedBudget(config) with no path. Once per-file caps exist, that call has to become nestedBudget(config, nested.memoryFile.path). Otherwise a nested file proposed under its own entry cap (say 8000) gets compared to the shared cap (2000), and every apply of it is refused. The remedy text should also point at the entry's budgetTokens when the file has one, instead of nestedBudgetTokens / --budget. A test that fails without that path argument would be great, e.g. a per-file-capped nested proposal that applies cleanly when the config is unchanged.

Please rebase onto main and push through no-mistakes again. The required "PR must be raised via no-mistakes" check is bound to the head SHA, so a maintainer-side conflict fix would invalidate your attestation anyway. Once it's green and clean, I'll take it from there.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is opt-in and was ready apart from the conflict with #210, so we're resolving that conflict ourselves on your branch, including passing the file path into budgetMismatches so per-file caps aren't compared against the shared cap. Nothing is needed from you, and please don't rebase. Once CI is green on the new head, we'll merge it. VISION per-rule verdict unchanged (aligns on every rule); contract-class opt-in.

Resolve conflicts with kunchenguid#210 so per-file nested budgets and apply's
budgetMismatches gate both survive. budgetMismatches now passes the
file path into nestedBudget so a file's own cap is checked instead of
the shared nestedBudgetTokens.

Co-authored-by: Kun Chen <[email protected]>
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: quick update so you know where this stands, @Pantoria. We merged current main into your branch ourselves (new head df6f2f9), and that includes passing the file path into budgetMismatches so per-file caps aren't compared against the shared cap. Build and test on ubuntu and macOS and the generated-files guard are green on df6f2f9. The only red check is "PR must be raised via no-mistakes", and that's only because your attestation is still bound to the earlier head c5c03de after our merge commit.

This is a captain-decision hold with Kun, not waiting on you. He's deciding how to handle the attestation after our own conflict fix. Please don't rebase or re-push; nothing is needed from you. Contract class is still opt-in, and the VISION verdict hasn't changed: it aligns on all seven rules (evidence, human owns the weights, nothing on faith, budget, reads what you have, failure is loud, scope).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants