Skip to content

feat(worktree): park is not retire, and a checkout is not a node_modules - #1676

Merged
njrini99-code merged 1 commit into
mainfrom
agent/lifecycle-convergence
Aug 29, 2026
Merged

feat(worktree): park is not retire, and a checkout is not a node_modules#1676
njrini99-code merged 1 commit into
mainfrom
agent/lifecycle-convergence

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

Park is not retire, and a checkout is not a node_modules

Three leaks, one cause: worktree, branch, PR and dependency tree were treated
as one object with one lifetime.
Splitting them is the whole change. The plan
is explicit that separate PRs for parking, branch GC and dependency bloat would
generate exactly the residue being fixed.

1 — Removing a checkout no longer abandons its branch

The old model made "remove worktree" imply "abandon branch", so a checkout
could only go once its PR merged. #1659 has been open for days awaiting a
physical-iPhone test and held ~3.8 GiB the whole time
— its work was pushed
and the checkout was reproducible from a ref.

PARK    remove the disposable checkout, KEEP the branch   (no PR needed)
RETIRE  park, AND delete a branch proven merged

Parking asks a different question: not "has this work landed" but "is this
checkout reproducible from a pushed ref"
. That's the only place a remote tip is
required, and it's required in the safe direction.

2 — Branch deletion, proven by PR head OID

Nothing deleted branches, so they accumulated invisibly: gh pr merge --delete-branch fails while a worktree holds the branch, and worktree removal
never touched refs. Ten local branches for merged PRs were sitting in this
repo.

Evidence is PR MERGED + local tip === PR head OID, and deliberately not a
remote tip. delete_branch_on_merge: true removes origin/<branch> at merge
time, so requiring it is guaranteed false for exactly the safe branches —
#1654's shipped defect, whose first dry run printed "Nothing retirable" with
11.6 GiB in front of it.

Verified before writing the classifier — for #1669/#1670/#1671/#1672 the remote
branch is gone, GitHub still reports .head.sha, and it matched the local tip
exactly in all four.

The counterexample that earns its own verdict: someone commits after the PR
merged. The PR proves the old tip landed and says nothing about the new commits,
which exist nowhere else once the remote is gone → KEEP_DIVERGED_AFTER_PR.

git branch -D is unavoidable (squash-merge means -d always refuses), which is
precisely why it's gated behind that proof, re-verified immediately before
deleting, and never exposed as a bare command a caller can aim.

3 — A checkout is no longer a ~3.8 GiB commitment

new-worktree.sh installed dependencies unconditionally. Most control-plane,
docs and config work never runs a test. That coupling is why six worktrees in
one day took the volume to zero bytes free, where nothing runs at all because
writing a command's output needs disk.

HELM_DISK_RESERVE_GIB 12 — the floor that keeps the machine usable
HELM_INSTALL_BUDGET_GIB 5 — conservative estimate of one install

An install needs 17 GiB. Measured cost was 3.8 GiB; the budget is
deliberately larger, because one day's observation is not a promise about the
next. Unknown free space fails closed.

A near miss, found by my own test

The first draft resolved the repository from import.meta.url. A test that built
a throwaway git fixture and ran the CLI with cwd set to it therefore operated
on the real repository, and --park removed a live worktree
(keyboard-covers-distance, PR #1659).

Nothing was lost, and that was the design rather than luck: parking keeps the
branch, and PARKABLE requires the tip already equal its pushed remote. Verified
after the fact — local == remote == 03a13075d, PR still open, branch intact.

It was still wrong. A tool that deletes things must take its target from where
the caller is standing, not from where the script lives.
Fixed, and proven by
running it inside a fixture and seeing only the fixture. The same bug cost 140 s
of test time — the CLI was scanning 40 real branches through gh on every case.
The suite now runs in 2 s.

Two suites migrated, not deleted

Both encoded the previous model and failed correctly:

  • retire-worktrees.test.ts — a clean pushed worktree with an OPEN PR was
    KEEP; it is now PARK. That is the redesign. All 13 refusals preserved;
    the HELM_PR_LOOKUP seam kept, now carrying a head SHA so exact-match cases
    are expressible.
  • new-worktree-precheck.test.ts — asserted the refusal names
    retire-worktrees.sh, and that ENOSPC cleanup lives in new-worktree.sh. Both
    moved. Each assertion has a successor and got one; none was dropped.

AGENTS.md says the standing grant relies on these refusals firing, so
quietly weakening either file would quietly weaken that authorization.

35 cases. The pure-classifier layer covers all four worktree verdicts and all
nine branch verdicts — including the ones that only appear when evidence is
missing: a failed lookup is UNKNOWN_PR, never "no PR", which is #1668's whole
subject.

Docs

AGENTS.md and autonomy.md both enumerated five guarantees including "an
isolated dependency install"
. That's now four, and the install is separate.
Shipping the tool without that edit would have shipped the contradiction this
program exists to remove.

Deliberately not done here

The sweep across the 38 local branches. The plan sequences it after this merges,
report-only first, every branch re-verified. Being right about the logic is not
the same as being right about the branches.

Verified

npm test 1274 files / 12042 passed · typecheck 0 · lint 0 · check:cycles 0
· enforcement:check 0 · shellcheck 0 · repo:doctor 0 · markdown:ratchet 0 · both
drift gates 0

🤖 Generated with Claude Code

https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@supabase

supabase Bot commented Aug 29, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project qmnssrrolpinvwjjnufo because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
helmv3 Ignored Ignored Preview Aug 29, 2026 9:50pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 09d94b19-1f6f-4067-9f53-17637e7bd5b6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread scripts/ensure-worktree-deps.mjs Fixed
Three leaks with one cause: worktree, branch, PR and dependency tree were
treated as one object with one lifetime. Splitting them is the whole change,
and the plan is explicit that separate PRs for parking, branch GC and
dependency bloat would generate exactly the residue being fixed.

1 — REMOVING A CHECKOUT NO LONGER ABANDONS ITS BRANCH

The old model made "remove worktree" imply "abandon branch", so a checkout
could only go once its PR merged. #1659 has been open for days awaiting a
physical-iPhone test and held ~3.8 GiB the entire time for no reason: its work
was pushed, and the checkout was reproducible from a ref.

    PARK    remove the disposable checkout, KEEP the branch   (no PR needed)
    RETIRE  park, AND delete a branch proven merged

Parking asks a different question than retiring. Not "has this work landed" but
"is this checkout reproducible from a pushed ref". That is the only place a
remote tip is required, and it is required in the safe direction.

2 — BRANCH DELETION, PROVEN BY PR HEAD OID

Nothing deleted branches, so they accumulated invisibly: `gh pr merge
--delete-branch` fails while a worktree holds the branch, and the worktree
removal path never touched refs. Ten local branches for merged PRs were sitting
in this repo.

The evidence is `PR MERGED` + `local tip === PR head OID`, and deliberately NOT
a remote tip. `delete_branch_on_merge: true` removes origin/<branch> at merge
time, so requiring it is guaranteed false for exactly the branches that are
safe — #1654's shipped defect, whose first dry run printed "Nothing retirable"
with 11.6 GiB in front of it. Verified before writing the classifier: for
#1669/#1670/#1671/#1672 the remote branch is gone, GitHub still reports
.head.sha, and it matches the local tip exactly in all four.

The counterexample that earns its own verdict: someone commits to a branch
AFTER its PR merged. The PR proves the old tip landed and says nothing about
the new commits, which exist nowhere else once the remote is gone.
KEEP_DIVERGED_AFTER_PR.

`git branch -D` is unavoidable here — squash-merge means `-d` always refuses —
which is precisely why it is gated behind that proof, re-verified immediately
before deleting, and never exposed as a bare command a caller can aim.

3 — A CHECKOUT IS NO LONGER A ~3.8 GiB COMMITMENT

new-worktree.sh installed dependencies unconditionally. Most control-plane,
docs and config work never runs a test. That coupling is why six worktrees in
one day took the volume to ZERO BYTES FREE, where nothing runs at all because
writing a command's output needs disk.

Installs are now opt-in (--install) or on demand
(scripts/ensure-worktree-deps.mjs), behind a two-number policy:

    HELM_DISK_RESERVE_GIB   12   the floor that keeps the MACHINE usable
    HELM_INSTALL_BUDGET_GIB  5   conservative estimate of one install

An install needs reserve+budget = 17 GiB. The measured cost was 3.8 GiB and the
budget is deliberately larger: one day's observation is not a promise about the
next. Unknown free space fails closed. new-worktree.sh's own precheck drops to
the reserve alone, because creating a checkout no longer costs GiB.

ONE AUTHORITY, TWO LAYERS

scripts/lib/worktree-lifecycle.mjs decides; scripts/worktree-lifecycle.mjs
gathers. Pure classifier means every verdict is covered without a git fixture
per case, and a wrong verdict is a logic bug rather than a shell-quoting bug.
retire-worktrees.sh becomes a forwarding shim — two implementations of a rule
that deletes things is how they drift.

A NEAR MISS, FOUND BY MY OWN TEST

The first draft resolved the repository from `import.meta.url`. A test that
built a throwaway git fixture and ran the CLI with cwd set to it therefore
operated on the REAL repository, and --park removed a live worktree
(keyboard-covers-distance, PR #1659).

Nothing was lost, and the reason is the design rather than luck: parking keeps
the branch, and PARKABLE requires the tip already equal its pushed remote.
Verified after the fact — local == remote == 03a1307, PR still open, branch
intact. The checkout was recreatable the whole time.

It was still wrong. A tool that deletes things must take its target from where
the caller is standing, not from where the script lives. Fixed, and proven by
running it inside a fixture and seeing only the fixture. The bug also cost 140s
of test time — the CLI was scanning 40 real branches through `gh` on every
case. The suite now runs in 2s.

TWO SUITES MIGRATED, NOT DELETED

Both encoded the previous model and failed correctly:

  retire-worktrees.test.ts   a clean pushed worktree with an OPEN PR was KEEP.
                             It is now PARK. That IS the redesign. All 13
                             refusals are preserved in the new suite; the
                             HELM_PR_LOOKUP seam is kept, now carrying a head
                             SHA so exact-match cases are expressible.

  new-worktree-precheck.test.ts  asserted the refusal names retire-worktrees.sh
                             and that ENOSPC cleanup lives in new-worktree.sh.
                             Both moved. Each assertion has a successor and got
                             one; none was dropped.

AGENTS.md says the standing grant RELIES on these refusals firing, so quietly
weakening either file would quietly weaken that authorization.

30 lifecycle cases + 5 precheck cases. The classifier layer covers all four
worktree verdicts and all nine branch verdicts, including the ones that only
appear when evidence is MISSING — a failed lookup is UNKNOWN_PR and never "no
PR", which is #1668's whole subject.

DOCS

AGENTS.md and autonomy.md both enumerated five guarantees including "an
isolated dependency install". That is now four, and the install is separate.
Shipping the tool without that edit would have shipped the contradiction this
program exists to remove.

DELIBERATELY NOT DONE HERE: the sweep across the 38 local branches. The plan
sequences it after this merges, report-only first, every branch re-verified.
Being right about the logic is not the same as being right about the branches.

CI caught one more: the Lint ratchet, a gate distinct from `npm run lint`, went
+1 on an unused `HERE` constant left over from an earlier draft of
ensure-worktree-deps.mjs. `npm run preflight` is the blocking static set and
runs it; I had run the individual gates instead. Fixed, and preflight is green.

Verified: npm run preflight 0; npm test 1274 files / 12042 passed; typecheck 0; lint 0;
check:cycles 0; enforcement:check 0; shellcheck 0; repo:doctor 0;
markdown:ratchet 0; both drift gates 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH
@njrini99-code
njrini99-code force-pushed the agent/lifecycle-convergence branch from 8f997d3 to 7843291 Compare August 29, 2026 21:50
@njrini99-code
njrini99-code merged commit 72fab33 into main Aug 29, 2026
47 checks passed
@njrini99-code
njrini99-code deleted the agent/lifecycle-convergence branch August 29, 2026 22:07
njrini99-code added a commit that referenced this pull request Aug 29, 2026
)

Two days of repairs left the pieces better and unable to check each other. This
makes drift observable, so the next forensic pass is a command instead of a
week.

THE PATTERN, STATED ONCE

    control written -> documented -> assumed -> stops running -> docs remain
    -> agent trusts docs -> failure

Observed, all of it:

    guard-bash.sh        existed, unwired, deleted
    guard-sql.sh         existed, unwired, deleted
    retire-worktrees.sh  existed, never invoked
    four rule files      claimed hooks that were gone
    worktree GC          keyed on a ref GitHub deletes on merge
    Docs Regen           opened a PR that sat open while main went stale
    a fixture test       operated on the real repository

Not one was a missing control. Every one was a control that stopped running
while its documentation stayed put.

WHAT THIS ADDS

npm run control-plane:verify           static + runtime
npm run control-plane:verify:static    CI-safe, wired as its own required job

READ-ONLY by construction. It regenerates nothing, deletes nothing, mutates no
config. The previous docs gate re-ran the generator and diffed, so the only way
to learn the docs were stale was to have already fixed them — a verifier that
repairs what it inspects cannot tell you what was wrong.

    0  VERIFIED           passed, or a named acknowledged gap
    1  CONTROL FAILURE    a control that should hold does not
    2  UNKNOWN            a required state could not be established

UNKNOWN outranks FAIL and never becomes PASS. If something could not be
established, "these specific things are broken" would imply everything else was
checked.

ACKNOWLEDGED GAP != UNKNOWN. Four gaps carry an id, an owner, a date, a reason
and a closing condition in config/control-plane-gaps.json, and every run prints
them so they cannot fade.

MUTATION BUDGET, ENFORCED BEFORE ALLOCATION

HELM_MAX_MUTATION_WORKTREES defaults to 1. Refused before `git worktree add`
and before any install, so a refusal costs nothing. Classification FAILS TOWARD
MUTATION: an unreadable or undeclared workspace counts. A declared release
workspace escapes the budget only if it is ALSO detached — on a branch it can
still be committed to.

The disk reserve stopped a catastrophe; it never stopped waste. Six worktrees in
one day was a concurrency problem that only became visible as a disk problem.

BRANCH-GC AUTHORIZATION, RECORDED IN THE TOOL ITSELF

Verdict renamed DELETE_SAFE -> DELETE_MERGED_EXACT so it states its own
evidence, and the grant lives in the authority, not in a paragraph elsewhere:

    PR MERGED + local tip === PR head OID + not protected + not checked out

Nine verdicts are explicitly excluded. The sharpest is new:
NO_UPSTREAM_UNIQUE_WORK. Measured — ten branches hold up to 19 commits with no
upstream, existing nowhere else. Folding those into UNKNOWN_PR conflated "the
lookup failed" with "this is the only copy", and only one of those is permanent.

THE GATE-LIE FROM #1672, ACTUALLY CLOSED

#1672 raised the timeout and its own PR said that makes the unknown RARER, not
DISTINGUISHABLE. The eleven repo sweeps now run in a dedicated `guards` project
under scripts/repo-guards.mjs:

    PASS                    ran, found nothing
    POLICY_FAILURE          ran, found violations        exit 1
    INFRASTRUCTURE_FAILURE  did not finish — UNKNOWN     exit 2

Proven end to end by injecting a real 200s hang into a real guard: exit 2, never
reported as a violation.

The guards are UNTOUCHED. They are 116-357 lines of bespoke matching logic each;
rewriting them to fix a REPORTING defect would risk weakening the guards
themselves. The lie was at the reporting boundary and is fixed there.

Worth recording: vitest's JSON reporter does not carry the timeout text — a real
timeout arrives as `Error: STACK_TRACE_ERROR`. A first draft scraped the
human-readable stream to recover it. That did not match, and it was the wrong
idea: parsing a tool's console output to make a safety decision is the
brittle-regex pattern this repo has already deleted guards for. The guarantee is
the DEFAULT — an unrecognised failure is INFRASTRUCTURE_FAILURE — not a pattern.

TOOL AUTHORITY, AND EVIDENCE THAT EXPIRES

docs/TOOL_AUTHORITY_MATRIX.md, generated. Five words that are not synonyms:
CONFIGURED, CONNECTED, EXPOSED, ALLOWED, EXERCISED. ET-4 lost two days reading
the first as the last.

Probed live, not inferred:

    Sentry   BOTH namespaces connected, find_organizations byte-identical.
             Redundant -> mcp__claude_ai_Sentry__* is authority, plugin denied.
             Reversible: one deny line restores it.
    Vercel   plugin exposes ONLY authenticate -> CONFIGURED, not CONNECTED.
             Account connector is authority; dead namespace denied.
    GitHub   the gh CLI is the lifecycle path, exercised three ways: MERGED
             #1676, OPEN #1659, and a branch with no PR. A failed lookup stays
             UNKNOWN_PR (#1668).
    Supabase sanctioned mcp__supabase__* is installed but NOT connected.

Every observation carries a fingerprint of the configuration that produced it.
Change a deny rule and the matching EXERCISED claims become STALE on the next
regeneration. Nobody has to remember to invalidate them.

Two bugs found by running it: Sentry and GitHub produced an IDENTICAL
fingerprint (both slices empty), which would have made drift undetectable for
both; and a service governed by nothing now says `ungoverned` rather than
implying coverage it does not have.

SUPABASE — A STRUCTURAL FINDING

Starting the sanctioned server's OAuth flow shows its requested scopes:

    organizations:read projects:read database:read analytics:read
    secrets:read edge_functions:read environment:read storage:read

Every scope is :read. That is connector-enforced capability restriction — real
evidence, not a URL string, and obtainable without any write probe.

It also means mcp__supabase__apply_migration almost certainly CANNOT function
under this grant, while an explicit owner authorization says it may. Surfaced as
gap SUPABASE_MIGRATION_GRANT_VS_READ_SCOPES rather than resolved, because
resolving it means attempting a production migration to answer a documentation
question.

USER-GLOBAL, UNDER EXPLICIT AUTHORIZATION AND WITH A BACKUP

    removed  mcp__plugin_supabase_supabase__{execute_sql,apply_migration}
             — no such plugin is installed; a grant waiting to activate on
               install is a trap, not a capability
    removed  mcp__claude_ai_Supabase__apply_migration
             — account-WIDE mutation nobody authorized
    kept     mcp__supabase__apply_migration — the owner's explicit grant
    kept     mcp__claude_ai_Supabase__execute_sql as TEMPORARY_FALLBACK, named
             UNENFORCED, because it is the only working query path until the
             sanctioned one connects
    fixed    the autoMode prose claiming a PreToolUse hook blocks destructive
             SQL. It never did; it had been copied from this repo's own docs.

SANDBOX — MEASURED, NOT ASSUMED, AND NOT ENABLED

Ten disposable probes: writes inside the project, outside it, into
.claude/settings.json and .claude/hooks, reads of .env.local, and ../ traversal
ALL SUCCEEDED — including paths the config lists under denyRead and
denyWithinAllow. sandbox.filesystem.disabled is true and authoritative. Network
sandboxing IS enforced (observed: deny network-outbound analytics.brew.sh:443).

NOT enabled. The configured allowWrite covers /private/tmp/helmv3-*, ~/.npm and
~/Library/Caches/deno — not the repository — so flipping it as configured would
block every worktree write, user-globally, for every project and any concurrent
session. Recorded as gap SANDBOX_FILESYSTEM_DISABLED with the closing condition.
No fake Bash parser was added as compensation.

DOCS REGEN — created != integrated

#1623 existed because main carried a stale route count (234 vs 235, after
/admin/self-heal shipped) and the fix lived in a bot PR that nothing merged.
That workflow's own header recorded that GITHUB_TOKEN PRs may receive no
required CI, so it could not be merged confidently either.

regen-docs.mjs gains a genuinely NON-MUTATING --check that computes expected
blocks, diffs them, names the first differing line, and leaves the tree
byte-identical. It is now a required PR-time gate. The auto-PR workflow is
verification-only and creates nothing. #1623's diff is byte-for-byte what
regeneration produces here, so it closes as superseded.

FAILURE INJECTION — 15 cases, every one in a disposable COPY

    delete a configured hook                      -> FAIL
    point a hook at a nonexistent script          -> FAIL
    break the blocking hook's matcher             -> FAIL
    reintroduce a false safety claim              -> FAIL
    the same claim, QUOTED                        -> PASS (corrections stay writable)
    declare a namespace never observed            -> FAIL
    move the budget check after allocation        -> FAIL
    remove the budget check                       -> FAIL
    change a service's config                     -> its observations STALE
    change one service                            -> others NOT staled
    a service governed by nothing                 -> ungoverned, not fresh
    inject a real 200s guard hang                 -> INFRASTRUCTURE_FAILURE, exit 2
    inject a real banned import                   -> POLICY_FAILURE, exit 1

Plus a SENTINEL proving the injections cannot reach the live checkout. That is
the direct regression for #1676, where a fixture test resolved its target back
to the real repository and parked a live worktree.

THREE THINGS THE VERIFIER CAUGHT IN ITS OWN AUTHORS' WORK

  1. It reported mutation-budget-enforced FAIL against a correctly-ordered
     script, because indexOf matched `git worktree add` in the script's own HELP
     TEXT at line 15. Substring is not mechanism — the same error the
     enforcement generator made on its first run. Now line-based and
     comment-aware.
  2. The matrix rendered the whole Supabase namespace as DENIED when only its
     ten mutators are, and folded DENIED_BY_POLICY and NOT_EXERCISED into
     "FAILED" — which reads as "we tried and it broke", untrue of both.
  3. #1672's own test asserted `unit` must be no stricter than its siblings,
     because `unit` held the sweeps. They moved. The failure was the premise
     announcing itself, and the test now pins the invariant that survives:
     whichever project runs the sweeps must be the most generous.

DELIBERATELY NOT DONE

No UNKNOWN or no-upstream branch deleted. No production SQL write as a probe. No
regex Bash/SQL pseudo-guard. #1638 not merged. sandbox.filesystem not enabled.
The Repair LaunchAgent not re-armed — a green control plane is not evidence
about a different unresolved runtime problem.

Verified: npm run preflight 0; npm test 1265 files / 12049 passed;
control-plane:verify 0 (20 pass, 0 fail, 0 unknown, 1 acknowledged gap);
control-plane:verify:static 0; enforcement:check 0; docs:inventory-check 0;
tool-authority:check 0; guards 32/32; check:cycles 0; shellcheck 0;
repo:doctor 0.


Claude-Session: https://claude.ai/code/session_01NbDxyygyXRUEERuGocpZZH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants