Skip to content

Always-on context grows unchecked: the proposal writer ignores context-budgets.json, ProposalGC has no invoker, and the budget nag only fires after the cap is breached #1669

Description

@xmasyx

Summary

The project treats four files as an always-on context stack with declared byte ceilings, but the only automatic writer into those files ignores the ceilings, and the collector that would remove entries has no invoker. Net effect: the files that load on every turn — and are inherited by every subagent — can only grow. The single existing signal fires after the ceiling is already breached.

Three shipped pieces, none of which are connected to each other:

1. LIFEOS/TOOLS/context-budgets.json declares the policy. It names OPERATIONAL_RULES.md, PROJECTS.md, PRINCIPAL_IDENTITY.md and DA_IDENTITY.md as the "re-bloat offenders", with per-file caps and a total cap, and states the rule plainly: "Budgets are ceilings, not targets: to add, first prune."

2. LIFEOS/PULSE/lib/telegram-proposals.tsapplyProposalEdit() appends to those exact files with no budget check. Approving a proposal writes an entry into the ## Memory-System Proposals section of the target and returns { ok: true } unconditionally:

const entry = `\n- ${editText} <!-- applied: ${ts} -->`;
...
next = current.replace(sectionHeader, `${sectionHeader}\n${entry.trim()}`);

Nothing reads context-budgets.json, nothing measures the resulting size, and there is no cap on the section. The writer can push a file past its declared ceiling and report success.

3. LIFEOS/TOOLS/ProposalGC.ts — the remedy — has no invoker. A code search for ProposalGC across the repo returns three hits: the tool itself, and two references inside skills/Trim (its SKILL.md and workflow). No hook, no scheduled job. It runs only when a human types /trim. This is the same shape as the accepted report in #1541 (a shipped checker with no invoker).

4. The applied: marker does not enable collection. The timestamp records when the entry was appended to the proposals section — not that the text was incorporated into the file's body. ProposalGC removes only provable cases (exact duplicate, superseded, absorbed-into-body), so entries that were appended and never incorporated are reported as clean. On a real install: one always-on file carries 12 such entries, 5,179 bytes (12.7% of the file), every one marked applied, and ProposalGC (dry-run) reports "nothing to collect — all proposal sections clean". There is no path, automatic or manual, that retires them. The section is append-only by construction.

5. The one live signal is binary and late. hooks/IntegrityCheck.hook.ts calls BudgetCheck.ts --quiet at session end, and --quiet prints only on violation (exit≠0). A file at 92% of its ceiling produces no output at all — the warning appears only once the cap is already exceeded, i.e. after the cost has been paid on every turn for however long the growth took. BudgetCheck's own docstring names a second, harder boundary — "the sanctioned push path should call this and refuse to push over budget" — which is not implemented anywhere in the payload.

Why it matters

The cost of an always-on file is paid per turn and per subagent, not once. A monotonically growing proposals section is a permanent tax whose size is set by how long the install has been in use, and the design intent (ceilings, "to add, first prune") is documented but unenforced at the only place where automatic growth happens.

Suggested fixes

A. Make the writer respect the declared ceiling (telegram-proposals.ts, ~8 lines). Before the rename, compare the prospective size against the target's budget and fail closed, so the proposal stays pending instead of breaching the cap:

const budgets = JSON.parse(readFileSync(join(claudeDir, "LIFEOS/TOOLS/context-budgets.json"), "utf8"));
const cap = budgets.budgets.find(b => resolve(claudeDir, b.path) === resolvedTarget)?.maxBytes;
if (cap && Buffer.byteLength(next) > cap) {
  return { ok: false, reason: `would exceed always-on budget for ${basename(resolvedTarget)} (${Buffer.byteLength(next)} > ${cap} B) — run /trim first` };
}

Fail-closed is the right default here: a rejected proposal is recoverable (it stays pending), a silently breached ceiling is not observable.

B. Give ProposalGC an invoker. IntegrityCheck.hook.ts already spawns BudgetCheck at session end; adding ProposalGC --apply alongside it costs one spawn and removes only provable entries, which is the invariant the tool already guarantees. A scheduled job works equally well.

C. Warn before the breach, not after. Have BudgetCheck --quiet emit at a threshold (e.g. ≥85% of cap) rather than only on violation, so the nag arrives while pruning is still cheap. Today the only pre-breach visibility is the statusline summary written by --cache, which is passive.

Happy to send a PR for any of these, one at a time, if the shape looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions