Skip to content

chore(worktrees): fail-fast Docker gate, branch-synced env files, generated artifacts, main-checkout guard hook - #116

Merged
agreenspan merged 9 commits into
mainfrom
chore/worktree-provisioning
Sep 24, 2026
Merged

agreenspan merged 9 commits into
mainfrom
chore/worktree-provisioning

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Port of userevidence/Zealot-Monorepo#2411 (worktree-provisioning), adapted to this repo: Postgres + MinIO slots, bun run scripts that actually exist here, and the eight open review findings on the source PR fixed rather than carried over.

Why

bun run worktree:create printed a green ready while silently skipping the slot databases whenever docker was off PATH or Docker was down; installed with --ignore-scripts and then never built the gitignored artifacts (packages/db/src/generated/*, packages/sdk/src/generated/*, apps/*/app/routeTree.gen.ts, handlers.gen.ts), so every API test file died at boot in a fresh worktree; copied apps/api/.env.local from root without rewriting its PORT / API_URL, which override root's slot values under with-env (slot 1 today has PORT=8000 in apps/api/.env.local — its API would bind main's port); and templated env files from root's copies, which lag the branch's .env.*.example (the probe run appended 11 keys root does not have). The bashMainCheckoutGuard hook closes the other recurring failure: the Bash tool's cwd resets to the main checkout between calls, and a bare bun add / git commit / git push lands on main.

What

scripts/worktree/create.sh

  • Pre-flight before anything is created: resolves docker (PATH → ~/.docker/bin → Docker.app), requires the daemon, starts a stopped ${PROJECT_NAME}_postgres, waits for Postgres to answer. Each failing is a hard error with the fix in the message. --skip-db (any position) for docs-only worktrees: no Docker gate, databases, buckets, or schema push; artifacts are still built.
  • Resolves the main checkout via git rev-parse --git-common-dir, so running the script from inside a worktree still lands the new worktree under the main checkout's .worktrees/ (and reads PROJECT_NAME from there) instead of nesting.
  • Slot allocation: a slot is taken when any registered worktree carries WORKTREE_SLOT=<N> or references ${PROJECT_NAME}_wt_<N> / ${PROJECT_NAME}_test_wt_<N> in any env file; the chosen slot is held under a mkdir lock in .worktrees/.locks/ (pid-stamped, stale locks reclaimed) until the script exits.
  • Env files: WORKTREE_SLOT is written as the first line before anything else; root and apps/* env files are copied, then scripts/setup/sync-env.sh runs inside the worktree against the branch's own .env.*.example, then slot ports / URLs / DB names / Redis index / buckets are rewritten — including PORT and API_URL in apps/api/.env.local and BETTER_AUTH_BASE_URL in root.
  • Databases: a database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without worktree:destroy); if one does, the script refuses and names it. psql is invoked through a bash array, never a re-split string.
  • After bun install --force --ignore-scripts, verifies apps/api/node_modules/@template/db resolves inside the worktree (bun's isolated linker puts workspace links under the consuming package, not the root).
  • Builds the artifacts the checkout does not carry: bun run generate:routes, bun run db:generate, bun run generate:sdk; then bun run db:push:dev.
  • Summary is green ready only when every artifact and the schema push landed; otherwise yellow created WITH GAPS listing each gap with the exact command to rerun. (The old heredoc summary also printed \033[… literally; fixed.)

scripts/worktree/destroy.sh — same docker resolution; DROP DATABASE … WITH (FORCE) so open connections don't block it; buckets removed via the shared minio container; Redis flushed on whichever container serves localhost:6379; clear warnings when Docker or a container is down.

scripts/worktree/lib.sh (new) — shared helpers for all three scripts (main_checkout_root, resolve_docker, slot_resources, worktree_claims_slot, slot_claimant, ensure_env_var, rewrite_database_url, run_step, require_artifact). list.sh now uses it too.

.claude/hooks/bashMainCheckoutGuard.ts + tests/ (new), registered in a new .claude/settings.json as PreToolUse on Bash + SessionStart. Two rules: (1) git worktree add/remove/move/prune is denied in every session, pointing at bun run worktree:*; (2) once a session (or subagent, keyed by agent_id) has worked in a registered worktree of this checkout, a mutating command (git commit/push/merge/rebase/cherry-pick/revert/am, bun add/remove/update, bun install <pkg>, bun run db:migrate, prisma migrate dev) landing in the main checkout is denied on any branch, with the cd <worktree> && … form. Naming main by absolute path is allowed. Deny only, never rewrite: PreToolUse hooks run in parallel and RTK's user-level hook already rewrites via updatedInput. See Guard cut back below.

Docs: .claude/skills/worktrees/SKILL.md (rewritten to match the scripts and the hook exactly), AI/ENTRYPOINT.md §0.0 worktree bullet, docs/claude/SCRIPTS.md (worktree section + tree), docs/claude/DOCKER.md (bucket provisioning line).

Guard cut back (supersedes the guard notes in the adversarial rounds below)

The adversarial rounds grew the guard into a ~940-line shell parser defending against evasion (scripts piped into shells, sudo/caffeinate option tables, case folding, symlink tricks). Its job is narrower: catch an honest agent whose Bash cwd drifted back to the main checkout, and keep worktree lifecycle on the scripts. It is now ~290 lines (tests ~320, 20 pass):

  • REQUIRE_MAIN_BRANCH is gone — the main checkout usually sits on a feature branch, which is exactly where a drifted commit does damage.
  • git worktree add/remove/move/prune is denied in every session, not only after drift.
  • Commands are read by words: quoted strings masked, heredoc bodies dropped (a commit message mentioning git worktree add is not a command); cd / git -C / bun --cwd move the landing, ( ) scopes a cd, an absolute path into main (and moves relative to it) is explicit, a $VAR target is unknown and allowed.
  • State is keyed per session, subagent (agent_id — subagent hooks carry the parent's session_id) and main checkout, so parallel subagents are never pointed at each other's worktree and another repository's worktree never replaces this one's.
  • The deny sentence in AI/ENTRYPOINT.md, docs/claude/SCRIPTS.md and the worktrees skill is replaced by a short description of the two rules.

Verified after merging main: hook tests 20/20, tsc --strict clean, live probes against the real checkout (main checkout on a feature branch: bare git commit denied, cd <worktree> && git commit / git status allowed, git worktree add denied). bun run check: lint, typecheck, permissions 81, db 327, email 519, api 1254 green; the UI suite has the same 3 failures as main (signup verification, logout revocation, live-query subscription — also reported on #114; this PR touches no UI code). Note: on main, generate:sdk now needs AUTH_PROVIDER_SECRETS_ENCRYPTION_* in the local env, which .env.local.example does not carry.

Review findings from #2411 addressed

  1. Slot ownership / unconditional DROP — ownership is marker or DB-name reference across every registered worktree's env files; WORKTREE_SLOT is written before any destructive step; per-slot mkdir lock around allocation; orphan databases are dropped only when slot_claimant finds no worktree on that slot, otherwise the script dies naming the claimant.
  2. sync-env aborting on a missing .env.test.example — this repo's scripts/setup/sync-env.sh treats a missing example as a no-op ([ ! -f "$example_file" ] && return 0), so the die only fires on a real sync error; a worktree that still ends up without .env.test gets a warning, not an abort.
  3. Unquoted command string splitting on whitespace — PSQL=("$DOCKER" exec "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -qtA) expanded as "${PSQL[@]}" everywhere in create and destroy.
  4. cd /nonexistent; git push bypass — a leading cd/pushd is honoured only when its target exists and is inside a git checkout: a linked worktree by any path, the main checkout only by an absolute path (cd . && … is not a choice of main). Otherwise the guard applies and the deny message says which target failed. Tests: missing dir, || form, non-checkout dir, $VAR target, relative main.
  5. Global options before the subcommand — GLOBAL_OPTIONS between git/bun and the subcommand (git -C . commit, git --no-pager push, git -c k=v rebase, bun --cwd . add) and runners in front (bunx, bun run [--opts], npx, env K=V, sudo, time). git -C <path> is honoured like an explicit cd under the same existence + checkout check. Tests for each.
  6. Bare \s prefix firing on mentions — separator is ^, ;, &, |, ( only (runners are enumerated instead); gh pr comment --body "remember to git push", an echo, and heredoc lines are asserted unmatched; rtk git push is likewise outside (RTK sees the original command).
  7. .git/HEAD rewrite without restore — onMain(head, run) helper wraps the assertion in try/finally.
  8. Doc claim vs regex — git worktree add/remove/move/prune is now in the regex (this repo's rule is scripts-only lifecycle) and the skill/entrypoint list the guarded set verbatim, including that the denial fires only under the drift condition.

Not ported, and why

  • packages/monitoring build step, generate:sdk via with:local api, MySQL grants/collations, LEGACY_API_PORT / NEXT_PUBLIC_* URLs, db:clone hint — Zealot-only; template's equivalents are generate:routes / db:generate / generate:sdk / db:push:dev and the MinIO bucket step.
  • .gloss/ sidecar — template has no gloss setup (no @inixiative/gloss dependency, no .gloss/); the hook's prose lives in .claude/skills/worktrees/SKILL.md and the code carries no comments.
  • docs/AI.md command-list change — template's equivalents are AI/ENTRYPOINT.md §0.0 and docs/claude/SCRIPTS.md.
  • Not touched: apps/*/vite.config.ts hardcode server.port 3000/3001/3002, so the slot's Web/Admin/Superadmin ports are advertised but not honoured by the dev servers. Pre-existing and app-side; out of this PR's scope, flagged here.

Verified

Ran the new scripts from this worktree against the main checkout (they resolve the main checkout themselves):

  • Full create main → tmp-provisioning-probe: Docker resolved at ~/.docker/bin/docker, slot 3 claimed under lock, sync appended VITE_OTEL_* to .env.local and 9 keys to apps/api/.env.local (root lags), WORKTREE_SLOT=3 first line of both env files, apps/api/.env.local PORT=8300 / API_URL / DATABASE_URL=…/template_wt_3 / REDIS_URL=…/3, .env.test DATABASE_URL=…/template_test_wt_3 + API_URL=…:8300, buckets ensured, workspace resolution check passed, all 8 required artifacts present, schema pushed, DB ping ok, green ready, probe worktree git-clean, lock released.
  • destroy: both DBs dropped, buckets removed, Redis DB 3 flushed (template_redis), slot freed.
  • Planted an orphan template_test_wt_3 with a table, re-created on slot 3: Slot 3 still has database template_test_wt_3 … dropping it, table gone afterwards, green ready; destroyed.
  • --skip-db create: no Docker gate, no slot DBs created (verified in pg_database), artifacts still built, green ready; destroyed. Final state: no *_wt_3 databases, no probe worktree, probe branch deleted, .worktrees/.locks/ empty.
  • worktree_claims_slot / slot_claimant exercised directly: a marker-less env file referencing template_wt_7 claims slot 7, does not claim 1 or 17; slot 1's claimant resolves to the existing FEAT-003-feature-flags worktree.
  • bun run worktree:list from the worktree lists all worktrees with the main checkout as slot 0 (was aborting on the main checkout's missing marker under set -e; fixed).
  • __TEST_REDIRECT__=1 bun test ./.claude/hooks/tests/bashMainCheckoutGuard.test.ts: 16 pass, 0 fail (103 expects). tsc --noEmit --strict clean on hook + test; biome check --write produces identical output for both (biome's own config excludes .claude/).
  • Live hook binary against the real paths: SessionStart in this worktree records it; a bare git push from the main checkout is silent because main is currently on INFRA-031, not main — the branch condition working as designed (the sandboxed binary test covers the deny path).
  • bun run check: exit 0 in this worktree (slot 2) — biome clean (54 pre-existing warnings), all 10 workspaces typecheck, tests: permissions 81, db 327, email 504, api 1203, shared ok, ui 255, frontend suite green, all 17 CI rules passed.

Not exercised live: the Docker-daemon-down and stopped-template_postgres branches (would disrupt the other worktrees on the shared stack), a concurrent create racing for the same slot (lock logic exercised only sequentially), and the claimant-refusal die path (only reachable through that race).

Adversarial round one

Fourteen confirmed findings plus the addendum, each with a fix and a probe or test. Commit e097f0d0.

Guard hook — rewritten from a single regex into a segment analyser (segmentCommand → strip runners → mutationIn per segment, with a directory cursor). Every item below has a named test in .claude/hooks/tests/bashMainCheckoutGuard.test.ts (31 tests, 203 expects, all pass).

  1. Newline/indent/comment/continuation bypass — the command is joined on \-newline, then split into segments on \n ; && || | & ( ) { }; each segment is trimmed and matched from its start. Tests: echo hi\ngit push, git push, \tgit push, # comment\ngit push, cd /tmp\ngit push, git \\\n push, CRLF, blank lines.
  2. Wrappers — leading VAR=value (any number), env [-i] [VAR=..], command, exec, nohup, sudo, timeout <n>, nice [-n N], time, xargs, rtk, rtk proxy, bunx / bun x / npx are stripped before matching; sh|bash|zsh|dash [-flags] -c "…" (including combined -lc) and eval "…" are analysed inside the quoted argument with the same cursor; { … }, if/then/else/elif/fi, while/until/do/done, ! are separators/keywords. Every string in the finding is a test case.
  3. Command-wide whitelisting — cd/pushd move a directory cursor for the segments after them (relative targets resolve against the cursor; popd, cd -, $VAR, a missing path or a non-checkout set it to unknown); git -C <dir> / bun --cwd <dir> apply only to their own segment. A mutation is denied when its effective directory is the main checkout or unknown. Tests: git -C <wt> status && git push (deny), cd <wt> && cd .. && git push (deny), cd <wt>; cd <main>; git push (deny), cd . && git push (deny), cd <wt> && git push / git -C <wt> push && git status / bash -c 'cd <wt> && git push' (allow).
  4. False positives — single/double-quoted contents are masked (length-preserving) and heredoc bodies (<<EOF, <<'EOF', <<-EOF) skipped before segmentation; --dry-run anywhere in the segment, merge|rebase|cherry-pick --abort|--continue|--quit|--skip, git worktree list, and git worktree prune --dry-run are not mutations. Tests: all six strings from the finding plus git log --grep="git push", cat <<EOF … EOF followed by a real git push (still denied).
  5. REQUIRE_MAIN_BRANCH — the branch clause is one exported constant (true) consulted through guard(input, state, { requireMainBranch }); tests assert the constant, the inert-on-feature-branch behaviour with it on, and the deny with it off. The compared branch is now defaultBranch(root) read from .git/refs/remotes/origin/HEAD — the same source create.sh uses (default_branch in lib.sh) — with a test flipping origin/HEAD to develop. Behaviour otherwise unchanged, as instructed.
  6. db:migrate\b — replaced by an exact script-name set (bun run db:migrate only); db:migrate:anything, db:migrate-status, db:push:dev, db:deploy are asserted non-mutating.
  • Plausible: stale state files — SessionState.prune(maxAge) removes per-session files older than a day on SessionStart; tested at the API level and through the binary.

Scripts — bash -n clean on create.sh, destroy.sh, lib.sh, list.sh, sync-env.sh.

  1. Fork onto an existing branch — with a base given and <new-branch> already existing (local or origin/), create dies with "already exists … attach it with worktree:create <branch> or pick a new name". Probe: create.sh main tmp-existing-probe → exit 1, no worktree created. Attach mode unchanged.
  2. Multi-line quoted env values — sync-env.sh copies the whole entry (a " that opens on the key line closes on a later line) via an awk block, and its key scan skips the continuation lines. Probe on a copy of .env.test.example: 20/20 keys, all four PEM markers, WEBHOOK_SIGNING_PRIVATE_KEY sources as 28 lines; the previous script's output fails to source (PUBLIC: command not found). create.sh sources every generated env file in a subshell (env_file_sources) and records a failure as a gap.
  3. Multi-pid port kill — kill_port_listeners loops over each pid, polls kill -0 up to 1s after kill -9, prints Killed PID <pid> per process and a warning naming any survivor. Probe with a stub lsof returning two detached sleep pids: both reported killed, both gone.
  4. Dead merged-branch warning — sed 's/^[*+ ]*//'. Probe: tmp-merged-probe (== main) attached in a worktree shows as + tmp-merged-probe in git branch --merged; the next create prints Slot 4: tmp-merged-probe (tmp-merged-probe) — bun run worktree:destroy tmp-merged-probe.
  5. destroy.sh rm -rf on any name — names starting with . are rejected; the target must be a registered worktree, or (half-created fallback) contain a .git file. Probe: destroy.sh .locks / .slots → exit 1; destroy.sh not-a-worktree → exit 1, directory survives, .locks intact.
  6. Unvalidated WORKTREE_SLOT — env_value strips CR, surrounding quotes, and a leading export ; valid_slot (^[1-9]$) is enforced in destroy (die) and in the create scan. Probe: WORKTREE_SLOT="5"\r and export WORKTREE_SLOT=5 both parse to 5; WORKTREE_SLOT=1; DROP DATABASE x → destroy exits 1 with the offending value quoted, worktree untouched.
  7. Lock dir without pid — claim_slot_lock reclaims a lock whose pid is dead, or that has no pid file and is older than 60s (mtime); mkdir is immediately followed by the pid write. Probe: no-pid lock aged to 2025 → reclaimed with notice; fresh no-pid lock → not reclaimed; dead pid → reclaimed; live pid → claim fails.
  8. Destroy drops by marker alone — destroy runs slot_claimant excluding the worktree being destroyed; another registered claimant → DB/bucket/Redis cleanup skipped with a message naming it, worktree still removed. Probe: squatter with every reference on the owner's slot 3 → Warning: tmp-provisioning-probe also references slot 3 — leaving template_wt_3 / template_test_wt_3, the buckets, and Redis DB 3 in place, both DBs still present, owner's registry entry intact; owner destroy then drops them.
  • Plausible: credential-less DATABASE_URL — rewrite_database_url handles scheme://host/db (no user:pw@) and returns non-zero when the URL was not rewritten; create dies on that. Probe: postgresql://localhost:5432/template → …/template_wt_9; …?schema=public preserved; DATABASE_URL=garbage rejected.
  • Plausible: PROJECT_NAME fallback — project_name falls back to the literal template, matching docker-compose.yml, in all three scripts.

Addendum

  • A. Slot registry — .worktrees/.slots/<N> holds the owning worktree path, written right after git worktree add and removed by destroy. worktree_claims_slot consults registry → marker → DB-name references; prune_slot_registry removes an entry whose worktree is no longer registered (with a notice) at the start of every create.
  • B. Marker precedence with conflict refusal — worktree_slots <wt> lists every slot a worktree's registry entry, markers, and env files name. Create reserves all of them and warns; destroy refuses (references more than one slot (marker WORKTREE_SLOT='5', env files/registry name slots: 5 8)) and asks for the files to be fixed. Probe: appending a template_wt_8 URL to the squatter's apps/api/.env.local → destroy exit 1, worktree untouched; reverting → worktree_slots back to one.

Docs (14) — SKILL.md, docs/claude/SCRIPTS.md, and AI/ENTRYPOINT.md now carry the same deny sentence verbatim (including the branch clause), the marker-first-line guarantee (prepend_slot_marker runs after the sync, so it holds even when root has no .env.test), the merged-branch warning, stale-lock reclaim, the registry, conflict refusal, shared-slot skip, and per-pid port-kill reporting.

Verification for this round

  • __TEST_REDIRECT__=1 bun test ./.claude/hooks/tests/bashMainCheckoutGuard.test.ts: 31 pass, 0 fail, 203 expects. tsc --noEmit --strict clean; biome on the two files: 2 warnings (the repo-wide noUndeclaredEnvVars turbo warning), 0 errors.
  • Real create/destroy cycles on slots 3–6 covering P1 (green ready, marker first line in both env files, all four env files source, artifacts present, registry written, lock released, git-clean), F5, F7, F8, F9, F10, F11, F12, B, credential-less URL, and stub-docker runs for daemon down (dies before creating anything) and stopped container (template_postgres is stopped — starting it…, then the DB ping gap produced created WITH GAPS).
  • bun run check: exit 0 (biome 54 pre-existing warnings / 0 errors, 10 workspaces typecheck, permissions 81 / db 327 / email 504 / api 1203 / ui 255 pass, frontend suite green, 17 CI rules passed).
  • Left behind: nothing — no template_*wt_[3-9] databases, .worktrees/.slots and .worktrees/.locks empty, no tmp-* branches, worktree list back to main + slot 1 + slot 2.

Still not exercised live: two creates racing for one slot (lock logic exercised sequentially), and the claimant-refusal die inside create (only reachable through that race, since the scan already reserves a claimed slot).

Round one, addendum C and D (commit bc0bc7b9)

C — hook (__TEST_REDIRECT__=1 bun test ./.claude/hooks/tests/bashMainCheckoutGuard.test.ts: 40 pass, 0 fail, 275 expects; tsc --strict clean; both files formatted explicitly with biome check --write over stdin (the repo config ignores .claude/)). The test sandbox is now a real git init repo with a git worktree add-ed worktree, so the registry lookup is exercised for real, including through the hook binary.

  • bun aliases and install-with-package — bun a x, bun rm x, bun install x, bun i x, bun i -d x, bun install --force x are mutating; bare bun install, bun i, bun install --force, bun i --frozen-lockfile are not.
  • Migrate script in every --cwd shape — bun --cwd <pkg> db:migrate, bun run --cwd <pkg> db:migrate, bun --cwd <pkg> run db:migrate, and the prisma migrate dev equivalents; db:push:dev, db:generate, db:deploy, prisma generate in the same shapes are not. db:push* stays outside the set on purpose: it writes no tracked files, so it cannot land on main.
  • realpathSync on every cd / -C / --cwd target — landings carry both the realpath (directory) and the token as typed (named). Tests: a symlink to a worktree resolves to the worktree (lifts), a symlink to main resolves to main but is denied with …through <link>, which is not its own absolute path.
  • git worktree move — in GIT_WORKTREE_MUTATING, asserted.
  • cd <main abs> and git -C <main abs> identical — both lift (also bun --cwd <main abs>, quoted forms, and cd <main abs>/apps/api); cd <main abs> && cd apps/api && git push is denied because the last cd was relative.
  • Glued separators — cd <wt>;git push, cd <wt>&&git push, cd <wt>||git push, cd <wt>|git push all resolve the cd token correctly (tests in mutationsIn and through guard).
  • Lift rule — registeredWorktrees(mainRoot) reads git worktree list --porcelain, realpaths each entry, drops main. A mutation is lifted only when its realpath is inside a registered worktree, or it is this main checkout named by its own absolute path. Denied with a specific reason: a non-checkout (/tmp, not a git checkout), an unregistered copy of a worktree or a different repository (neither this repository's main checkout nor one of its registered worktrees), a relative path into main (reached by a relative path), a symlink to main, an unresolvable target. Interpretation flagged: a different repository named by absolute path is denied too, per the literal rule — flip denialFor if cross-repo commits from a template session should pass.

D — scripts (bash -n clean on all five; probes below ran against the real stack on slot 3 plus stubbed docker).

  • Inherited WORKTREE_SLOT — copy_env_without_marker drops WORKTREE_SLOT= / export WORKTREE_SLOT= lines from every copied root env file; probe: WORKTREE_SLOT=1 … export WORKTREE_SLOT=2 in → A=1 B=2 out; the real create shows exactly one marker line. Main excluded from the claimant scan — linked_worktrees realpath-compares each worktree list entry against ROOT_DIR and drops it; is_registered_worktree/slot_claimant compare realpaths. Probe: linked_worktrees lists only the two linked worktrees, slot_claimant 0 is empty, is_registered_worktree <main> is no.
  • sed escaping — sed_replacement escapes \ & | for every value that reaches a sed s||| (URLs, buckets, ports); rewrite_database_url now uses # as delimiter, accepts any userinfo up to the last @ (so / & | in a password survive), and still verifies the rewrite. Probe with postgresql://user:p&ss|w/rd@localhost:5432/template → …p&ss|w/rd@localhost:5432/template_wt_9; ensure_env_var FOO 'a&b|c\\d/e' lands verbatim. (Sourcing that probe file then fails — an unquoted & is not valid sh in any env file; the real env files quote such values, which is what the sourcing gap check in create is for.)
  • Inventory query failure aborts — DB_EXISTS="$(psql …)" || die "could not list databases … not assuming <db> is absent". Probe with a stub docker whose pg_database query fails: create exits 1 with that message, no database created; the half-created worktree is removed by destroy --force (destroy's half-created path).
  • --skip-db needs neither credentials nor Docker — probe: root .env.test and apps/api/.env.local temporarily stripped of DATABASE_URL/REDIS_URL (restored under trap, cmp byte-identical), PATH without any docker directory, HOME=/nonexistent → green ready on slot 3, the branch's .env.*.example supplied the URLs and the slot rewrite applied. require_env_key runs only when databases are provisioned.
  • Explicit die per required value — require_env_key DATABASE_URL <files…> names the key and every file searched (probe output in the log), WORKTREE_SLOT validated (round one), resolve_docker tolerates an unset HOME (env -u HOME probe).
  • Destroy: dirty refusal + merge status — destroy.sh <name> [--force]; prints branch 'X' is merged into main / is NOT merged into main (N commit(s) not in main) / detached HEAD, then refuses on git status --porcelain --untracked-files=all output unless --force (paths listed either way). Probes: untracked file → exit 1, worktree kept; --force → destroyed with the listed path; a worktree with one extra commit → NOT merged … (1 commit(s) not in main).

Docs — the deny sentence (now including the alias/install forms, the --cwd shapes, the realpath rule, and the exact lift rule) is identical in SKILL.md, docs/claude/SCRIPTS.md, and AI/ENTRYPOINT.md; destroy … [--force] and the merge-status print are documented in all three; SKILL.md also covers glued separators, symlinks, the inventory abort, the marker strip, and the --skip-db credential independence.

Verification for this addendum — hook tests 40/40; bash -n on create.sh, destroy.sh, lib.sh, list.sh, sync-env.sh; probes above plus a full real create/destroy regression on slot 3 (green ready, one marker line, all four env files source, git-clean, DBs dropped, Redis flushed); bun run check exit 0 (biome 54 pre-existing warnings / 0 errors, 10 workspaces typecheck, permissions 81 / db 327 / email 504 / api 1203 / ui 255, frontend green, 17 CI rules). Final state: no template_*wt_[3-9] databases, .worktrees/.slots and .locks empty, no tmp-* branches, worktree list back to main + slot 1 + slot 2, root env files unchanged.

Adversarial round two

Eleven confirmed findings plus seven shared-design findings from the Zealot review, on commit c7d1bad8. Hook tests 61 pass / 0 fail / 397 expects in a sandbox that is now a real git init repo with git worktree add-ed worktrees (own and foreign); tsc --strict clean; both files formatted explicitly with biome check --write over stdin (the repo config ignores .claude/). bash -n clean on create.sh, destroy.sh, lib.sh, list.sh, sync-env.sh, minio-remove.sh. Script probes ran from a script file against a sandbox repo whose path contains a space, with stubbed docker/lsof/bun/stat on PATH, then against the real stack.

Scripts

  • C1 (HIGH) is_registered_worktree SIGPIPE under pipefail — linked_worktrees | grep -q replaced by [ -n "$(linked_worktrees | grep -xF -- "$dir")" ]; every other multi-line-producer grep moved to a here-string or [[ == *" $i "* ]]; container_on_port no longer pipes through head. Probe: three worktrees, the MIDDLE one dirty → wt-b registered: yes, Error: wt-b has uncommitted or untracked changes … ?? DIRTY.txt, exit 1, directory and dirty file survive, registry still 1 2 3; the following create scan lists all three worktrees and prunes nothing. Live: two probes on slots 3 and 4, slot 3 (non-last) destroyed first — DBs dropped, four buckets removed, Redis 3 flushed.
  • C4 GNU stat — file_mtime branches on stat --version (-c %Y / -f %m) like sedi. Probe with a GNU-like stat stub whose -f prints junk and exits 1: an old pid-less lock is stale, a fresh one is not, no arithmetic error.
  • C5 rewrites accept export/quotes/comments and verify — one canonical env_key_pattern (^(export[[:space:]]+)?KEY[[:space:]]*=) behind env_line/env_has_key/env_value/set_env_var/copy_env_without_marker/prepend_slot_marker/require_env_key; rewrite_database_url/rewrite_redis_db parse the value with bash regexes (no password ever passes through sed), rewrite via set_env_var (keeps an export prefix, escapes the replacement), and die naming file/key/cause on empty or malformed input. Probe: export DATABASE_URL="postgresql://u:p&ss|w/rd@localhost:5432/template" → export DATABASE_URL=postgresql://u:p&ss|w/rd@localhost:5432/template_wt_9; REDIS_URL="redis://localhost:6379" → …/9; PORT=8000 # api → PORT=8900; DATABASE_URL="garbage" → … ('garbage') is not scheme://[user:password@]host[:port]/database; empty → … is empty; redis://noop:6379 left alone. The first probe run caught a real regression of mine here — BSD sed rejects t; followed by another command, so every double-quoted value read back empty; the parser is now pure bash.
  • C6 list.sh on a path with a space — parses --porcelain blocks. Probe on …/base sp/repo: 0 (main) main 3000 3001 3002 8000 plus the three worktree rows.
  • C7 bucket removal honesty — new scripts/db/minio-remove.sh mirrors minio-provision.sh (same STORAGE_* env, host.docker.internal mapping, readiness check) and reports each bucket honestly: was already absent when the pre-check mc ls finds nothing, removed only when it existed and the post-check shows it gone, could NOT remove … (still present) otherwise; destroy prints Warning: bucket removal did not complete. on any failure. Probe with a stub failing one rb: removed template-system-wt-2, could NOT remove template-user-wt-2 (still present), warning, 6 mc invocations over host.docker.internal.
  • C10 cleanup hint — on_exit trap: any non-zero exit after git worktree add prints The worktree '<name>' was created before this failure. Remove it and free slot N with: bun run worktree:destroy <name> --force; a failure before it unregisters the slot. Probe: stub bun fails install → hint printed; destroy wt-d --force cleans up.
  • C11 sync-env quote parity — escaped quotes are ignored when counting, a # after the closing quote is a comment, export KEY= matches on both sides, an unterminated quote is a hard error naming the key and line. Probe: A="x \" y" / B="v" # say "hi / export C=3 / multi-line D / E=after against an env file holding export A=0 → only B C D E added, A count 1 with export kept, file sources (D=[multi\nline]); F="never closes → sync-env: unterminated quote in .env.local.example — the value of F opened at line 1 never closes, exit 1.
  • Z1 registry/lock semantics — the slot is registered right after the lock is claimed (before git worktree add) and the lock is held until the script exits; registry entries count as claims whether or not their directory exists; prune_slot_registry removes an entry only when its directory is missing AND no lock exists for the slot AND the entry is older than 120s; unregister_slot only when the entry's realpath equals the worktree's. Probe: a fresh entry for a missing dir reserved slot 4 (Claimed slot 5); aged to 2020 it was pruned with a notice and slot 4 reclaimed; unregister_slot 1 <other path> kept entry 1.
  • Z2 symlinked repo path — ROOT_DIR is pwd -P; linked_worktrees, is_registered_worktree, slot_claimant, unregister_slot, and list.sh's main detection compare realpaths. Probe: scripts invoked through link-to-repo report the realpath root and the (main) row.
  • Z6 independent repo under .worktrees/ — worktree_shares_main_git requires git rev-parse --git-common-dir to be this repo's .git. Probe: a nested git init is refused (no .git file), a .git file pointing at another repo is refused with does not belong to this repository.
  • Z10 duplicate registry entries — registry_duplicates (owner path may contain spaces); list.sh warns Slot registry conflict: <path> is registered under slots 1 7; create warns and reserves all of them.
  • Z12 env_value — K='v', K="v", K=v # c, K = v, K= probed: [single] [v] [v] [spaced] [] (empty is missing), E="esc \" aped" → esc " aped.
  • Plausible — stale-lock reclaim does mv "$lock" "$lock.stale.$$" before rm -rf (probe: two reclaimers, only A claims); worktree_claims_slot has a left boundary (mytemplate_wt_5 does not claim 5, template_wt_5 claims 5).

Hook

  • C2 cursor leaks — the segmenter emits open/close pieces for ( ) and records each command's terminator; the cursor is pushed on ( and popped on ), and a cd ending in |, |&, or a single & does not move it; { } stays in-shell. Tests: (cd <wt>) && git push, (cd <wt> && bun run check) && git commit, cd <wt> | cat; git push, cd <wt> & git push → main (denied); { cd <wt>; } && git push, (cd <wt> && git push), (cd <wt>; (cd ..); git push) → worktree (allowed); 2>&1 is not a separator.
  • C3 options after the subcommand — parseBun consumes value options (--cwd --filter -F --backend --registry --config -c --target --env-file --port --shell --linker --omit …) wherever they appear; --cwd anywhere in the segment is the segment's directory. Tests: bun install --cwd <wt>, bun install --filter web, bun install --backend hardlink not mutating; bun add x --cwd <wt>, bun add x --cwd=<wt>/apps/api, bun run db:migrate --cwd <wt>/packages/db resolve to the worktree; docs now say "anywhere in the segment".
  • C8 word-0 identity and runner options — headOf strips a leading \, quotes ("git", gi""t, $'git'), and a path prefix (/usr/bin/git, ./node_modules/.bin/bun); runners take options (command -p, exec -a x, env -u FOO, time -p, sudo -u me, nohup --, xargs -n 1 / -I {}, timeout -k 5 10, nice -n 5); bash -o pipefail -c and -eo pipefail -c clusters parse; $( ) and backticks are analysed as substitutions (echo "$(git push)", echo `git push`, x=$(git push)). --git-dir/--work-tree/GIT_DIR/GIT_WORK_TREE redirect the landing (a .git dir resolves to its checkout): cd <wt> && git --git-dir=<main>/.git push → targets <main> through --git-dir, GIT_DIR=<main>/.git git push and GIT_WORK_TREE=<main> git commit denied, git --git-dir=<wt>/.git push allowed. command -v git is not a run.
  • C9 false denials and message — # at a word boundary skips to end of line (echo hi # ; git push not mutating; git push # done still is; $# untouched); cd -P <wt> / cd -L -- <wt> take the path, not the flag. Landings carry the original segment text with keywords stripped and runners kept (FOO="a b" git push, not the masked bytes); the remediation is cd <wt> && <that segment> per denied landing, never prefixed onto a segment that already cds.
  • Z3 cat <<< "x"\ngit push denied, cat <<< "git push" not. Z4 a cd target containing $( ), backticks, or $VAR sets the cursor to unknown → denied (cd <wt> && cd $(pwd) && git push). Z5 the guard arms only when the remembered worktree is a registered worktree of THIS main: a foreign repo's worktree or an unregistered copy never arms. Z7 backslash-newline inside a word is removed (git pu\<nl>sh); scanParen/scanDouble balance quotes inside $( ) (echo "$(echo "a"; git push)", echo "$(echo ")")"; git push). Z8 nesting deeper than 6 is reported as a landing and denied (nested more deeply than this hook analyses); state files are named by sha256(session_id); state-file errors and any top-level throw are reported once on stderr (bashMainCheckoutGuard: …) with exit 0 — test spawns the binary with a file as the state dir and asserts exactly one report. Plausible git revert and git am added (they create commits; --abort/--continue/--skip excluded); git pull, git reset --hard, git checkout remain outside by design and are documented as such.

Docs — the deny sentence in SKILL.md, docs/claude/SCRIPTS.md, AI/ENTRYPOINT.md now carries the revert/am additions, the subshell/pipe/background rule, --cwd anywhere in the segment, the git-dir/work-tree redirects, the word-0 and wrapper list, and the by-design exclusions; SKILL.md documents the registry/lock semantics (claim before dir, 120s prune, mv-before-rm reclaim, GNU/BSD stat), the common-dir requirement on destroy, per-bucket reporting, the env-parsing rules, and the post-worktree add cleanup hint.

Verification — hook tests 61/61; bash -n all six scripts; sandbox probes above; live: two probes on slots 3/4, slot 3 destroyed while not last (DBs dropped, 4 buckets removed, Redis flushed), slot 4 destroyed, worktree:list back to main + slot 1 + slot 2; bun run check exit 0 on the final tree (biome 54 pre-existing warnings / 0 errors, 10 workspaces typecheck, permissions 81 / db 327 / email 504 / api 1203 / ui 255, frontend green, 17 CI rules). Left behind: nothing — no template_*wt_[3-9] databases, .slots/.locks empty, no tmp-* branches, root env files untouched. Not exercised live: two creates racing for one slot; a real Linux stat (covered by the stub only).

Adversarial round three

Fourteen confirmed findings plus the Zealot round-three mirrors, on commit e997588f. Hook tests 72 pass / 0 fail / 470 expects; tsc --strict clean; both files formatted explicitly with biome check --write over stdin (the repo config ignores .claude/). bash -n clean on all six scripts; grep finds no remaining echo "$var" | head pipe. Script probes ran from a script file against a sandbox git init repo with git worktree add-ed worktrees and stubbed docker/lsof/bun/stat, then a live create/destroy on slot 3.

Scripts

  • T1 (HIGH) quoting preserved — set_env_var re-emits with the style quote_style_of_line observed: single stays single (unless the value itself contains ', then double), double stays double with \ " $ ` escaped, unquoted stays unquoted unless the value needs quoting; env_value unescapes \$ and \` too, and the write is read back and compared. Probe: for each of $ \ & | ; space ' ?a=b&c=d × {none, single, double} the rewritten file was sourced and the value compared byte-for-byte — 24/24 ok (KEY='postgresql://u:p$ss@…' stays single-quoted; unquoted p ss gains double quotes); rewrite_database_url on DATABASE_URL='…p$ss@…/template?schema=public&sslmode=disable' → …/template_wt_7?schema=public&sslmode=disable single-quoted, sourced value intact.
  • T2 (HIGH) porcelain SIGPIPE — print_first_lines is sed -n '1,10{s/^/ /;p;}' <<< "$DIRTY". Probe: 1500 untracked files (75,393 bytes of porcelain), destroy big --force → prints the first lines, Worktree 'big' destroyed, exit 0.
  • T7 half-created fallback — classification happens before any output: registered → common-dir check; .git FILE whose gitdir: resolves under $ROOT_DIR/.git/worktrees/ → half-created (removed + pruned); .git file pointing at another checkout → refused with run 'git worktree repair' from it. Nothing was deleted.; own .git directory → refused as independent repository; no .git → refused as stray. Probe: half removed (exit 0), moved refused with hint and its uncommitted file untouched, stray refused.
  • T8 MinIO endpoint parity — storage_env_from "$ROOT_DIR/.env.local" feeds the same STORAGE_* to minio-provision.sh in create and minio-remove.sh in destroy. Probe with root STORAGE_ENDPOINT=http://minio.example:9100 and probe keys: create MC_HOST_local=http://probe-key:probe-secret@minio.example:9100, destroy identical.
  • T9 signals and kill -9 state — trap 'exit 129' HUP; 'exit 130' INT; 'exit 143' TERM so the EXIT trap sees the failure: after git worktree add it prints the destroy hint; before it, it unregisters the slot and removes any partial directory. The allocation loop consults the lock FIRST: dead pid → slot N: stale lock (pid P dead) — reclaimed and a directory-less registry entry is removed with a notice; live pid → slot N: being provisioned by <path> (pid P) — skipping; every other skipped slot prints one line with the reason and the worktree:destroy to free it. list prints stale lock (pid N dead) — next create reclaims only for a dead pid and create in progress (pid N) for a live one. Probes: SIGTERM during a hanging install → created before this failure (exit 143) … bun run worktree:destroy wt-int --force; planted dead-pid lock + dead entry and a live-pid lock → list and create output exactly as above, slot 1 reclaimed, slot 2 skipped.
  • T10 — sync-env writes a newline first when the target lacks one: E=1 + F=2 → E=1|F=2|.
  • T11 BusyBox stat — file_mtime tries stat -c %Y, then stat -f %m, accepting only a numeric answer, else the current time with a warning; file_age clamps at 0. Probe with a stub whose -c fails and -f prints a mount point: warning, age=[0], no arithmetic abort.
  • T12 — regex_escape "$PROJECT_NAME" in the claim pattern: my.app does not match myXapp_wt_3, does match my.app_wt_3.
  • T13/T14 — validate_slot_registry dies on a non-regular entry with the repair hint before any lock is taken (probe: .slots/5 directory → exit 1, .locks empty); every prune loop guards [ -f ] || continue before file_age.
  • Zealot mirrors — empty entry: list warns empty (corrupt), create removes with notice; relative entry: list warns, create rewrites absolute; the repair hint says ABSOLUTE; slot evidence goes through env_value (WORKTREE_SLOT="4" # note claims 4, a commented-out DATABASE_URL does not); each skipped slot prints its reason; case-insensitive FS: destroy WT-C → Note: 'WT-C' is spelled 'wt-c' on disk — using that name, registry entry removed; duplicate entries resolved by the marker slot (keeping 3 (its WORKTREE_SLOT marker), removing 7), newest as tiebreak.

Hook

  • T3 scripts fed to a shell — heredoc bodies are captured per command piece; sh|bash|zsh|dash with no -c and no positional script walks its own heredoc, or the literal output (cat <<EOF body, echo/printf args) of the piece piped into it; a -c or eval argument that is $(…)/backticks is replaced by that command's literal output. Deny tests: bash <<'EOF', bash -s <<'EOF', echo 'git push' | bash, cat <<'EOF' | sh, bash -c "$(cat <<'EOF' … )", eval "$(printf 'git push')", printf 'git push' | bash, sh <<EOF bun add x, printf '%s\n' 'git push' | bash, echo "git push" | bash -s. Allow tests: cat <<EOF > notes.md, echo git push | grep, heredoc with only git status, bash f.sh, . f.sh, a heredoc followed by an unrelated bash. A heredoc that cds into the worktree first lands there and passes from main.
  • T4 env -C/--chdir — moves the segment's directory (worktree → allowed); it does not carry the absolute-path lift for main, so env -C /abs/main git push and env -C . git push are denied (reached without cd /abs/main).
  • T5 word 0 — heads are lower-cased on darwin and every unquoted backslash is stripped: GIT push, Git commit, bUn add x deny on macOS (asserted against process.platform), gi\t push / g\it push deny everywhere.
  • T6 — a # glued to ${x}, $( ), or backticks is not a comment (echo ${x}#; git push denied; echo x #; git push still clean).
  • Runner tables — per-runner value options (sudo -u -g -p -D -C -h -r -t -T -U, caffeinate -t -w, env -C --chdir -u -S, xargs -n -L -P -I -d -a -E -s, timeout -k -s, nice -n; nohup/command/rtk none) with a test per string (sudo -E|-n|-S|-k|-s git push, caffeinate -t 600 git push, …). Bun options are table-switched at the subcommand: global -d/--define takes a value, but after add|install|i|a|remove|rm|update -d/-D are flags — bun add -d --cwd <wt> pkg lifts, bun -d FOO=1 add x is still mutating.

Docs — the deny sentence in SKILL.md, docs/claude/SCRIPTS.md, AI/ENTRYPOINT.md carries the heredoc rule, env -C, the word-0 case rule, the per-runner wrapper list; SKILL.md §5 describes the preserved quoting, the destroy row lists the .git-file fallback and the repair hint, the bucket sentence says "the same STORAGE_* values from the root .env.local", §11 covers signals and the kill -9 recovery, the registry paragraph covers empty/relative/directory entries and the ABSOLUTE hint, the quoted-evidence sentence, and duplicate resolution.

Verification — hook tests 72/72; bash -n all six scripts; sandbox probes above; live: create main tmp-provisioning-probe on slot 3 printed the in-use lines for slots 1 and 2, Claimed slot 3, green ready, WORKTREE_SLOT=3 first in both env files, both DATABASE_URLs on _wt_3, all four env files source, git-clean, both DBs present; destroy → merged into main, DBs dropped, four buckets removed, Redis DB 3 flushed, Slot 3 freed; worktree:list back to main + slot 1 + slot 2. bun run check exit 0 on the final tree (biome 54 pre-existing warnings / 0 errors, 10 workspaces typecheck, permissions 81 / db 327 / email 504 / api 1203 / ui 255, frontend green, 17 CI rules). Final state: no template_*wt_[3-9] databases, .slots/.locks empty, no tmp-* branches, main checkout untouched. Not exercised: a real Linux/BusyBox stat (stubs only), two creates racing one slot.

Final pass

Four items on commit 15feae31. Hook tests 74 pass / 0 fail / 482 expects; tsc --strict clean; both hook files formatted explicitly with biome check --write over stdin (the repo config ignores .claude/). bash -n clean on all six scripts.

  1. HIGH — hand-moved worktree destroyed without --force — destroy.sh now treats a .git FILE whose gitdir: is under this repository's .git/worktrees/ as half-created only when that gitdir is GONE; when it still exists the directory is a worktree moved by hand and destroy refuses: Re-attach it with: git worktree repair '<path>' — then destroy it normally. Nothing was deleted. Probe: git worktree add unreg-orig; mv → unreg-moved; echo precious > uncommitted.txt; destroy unreg-moved → exit 1, file intact; after git worktree repair it is registered and destroy --force removes it; a .git file pointing at a gitdir that no longer exists is still removed and pruned (exit 0).
  2. minio-remove.sh "removed" for absent buckets — each bucket is mc ls-checked first (was already absent), then rb --force, then re-checked (removed only when it is gone, could NOT remove … (still present) otherwise, non-zero exit). Live on slot 3: removed ×4 during destroy, then a second run on the same names → was already absent ×2, exit 0; stub with a bucket rb cannot delete → could NOT remove stuck (still present), exit 1.
  3. Duplicate-resolution label — the notice says (its WORKTREE_SLOT marker) only when the kept slot came from the marker, else (newest); probes: marker 5 over {1,5} → keeping 5 (its WORKTREE_SLOT marker); marker 7 over {1,5} → keeping 5 (newest); no marker over {5,1} with 1 newest → keeping 1 (newest). list.sh says "keeps the slot its WORKTREE_SLOT marker names, else the newest entry".
  4. Pass-through pipes into a shell — producedText(piece, stdin) forwards incoming piped text through tee (any args) and cat whose args are all options/redirections; scriptFedToShell reads that carried text instead of looking one pipe back. Deny: cat <<'EOF' | tee f | bash, cat <<EOF | cat | bash, cat <<'EOF' | tee -a log | cat | sh, echo 'git push' | tee /dev/null | bash, printf 'git push' | cat - | bash. Allow: | sed … | bash, | cat other.sh | bash, | tee f with no shell, | tee f | grep, a git status body; a piped body that cds into the worktree lands there.

Docs: the deny sentence (byte-identical in SKILL.md, docs/claude/SCRIPTS.md, AI/ENTRYPOINT.md) now says "piped into one (including through tee or an argument-less cat, any number of times)"; SKILL.md's destroy row carries the moved-worktree refusal and the per-bucket pre-/post-check wording, and its registry paragraph the "marker, else newest" rule; docs/claude/SCRIPTS.md's scripts/db/ tree now lists push-dev.sh, release.sh, minio-init.sh, minio-provision.sh, minio-remove.sh.

bun run check: exit 0 on the final tree (biome 54 pre-existing warnings / 0 errors, 10 workspaces typecheck, permissions 81 / db 327 / email 504 / api 1203 / ui 255, frontend green, 17 CI rules). Live slot-3 cycle: create green ready, destroy dropped both DBs, removed four buckets, flushed Redis 3, freed the slot; final state has no template_*wt_[3-9] databases, empty .slots/.locks, no tmp-* branches, worktree:list = main + slot 1 + slot 2.

Not exercised (cumulative): two creates racing for one slot; a real Linux/BusyBox stat (stubs only); the docker-not-found branch of create, which cannot be probed on a Mac with Docker Desktop because resolve_docker falls through to /Applications/Docker.app/Contents/Resources/bin/docker regardless of PATH.

🤖 Generated with Claude Code

agreenspan and others added 9 commits September 23, 2026 00:09
…erated artifacts, main-checkout guard hook

Port of Zealot-Monorepo#2411 adapted to this repo's Postgres/MinIO slots and bun run scripts.

create.sh: Docker/Postgres pre-flight (--skip-db opt-out), main-checkout resolution via
git-common-dir, slot ownership by marker or DB-name reference under a per-slot mkdir lock,
WORKTREE_SLOT written first, env files synced against the branch's .env.*.example with slot
rewrites in apps/api/.env.local too, orphan DBs dropped only when no worktree claims the slot,
workspace resolution check, generate:routes + db:generate + generate:sdk, db:push:dev, green
ready vs yellow WITH GAPS summary.

destroy.sh: shared docker resolution, DROP DATABASE WITH (FORCE), Redis flush on whichever
container serves 6379. lib.sh: shared helpers; list.sh uses them.

.claude/hooks/bashMainCheckoutGuard.ts (+ tests, .claude/settings.json): PreToolUse on Bash and
SessionStart; denies mutating commands from the main checkout on branch main once the session
has worked in a worktree. Fixes the eight open review findings on #2411: slot ownership and
lock, array-built psql, explicit cd/-C honoured only for real checkouts, global options before
the subcommand, no bare-whitespace separator, try/finally HEAD restore, worktree add/remove in
the guarded set with docs matching the regex.

Docs: worktrees skill, AI/ENTRYPOINT.md, docs/claude/SCRIPTS.md, docs/claude/DOCKER.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…egistry, hardened create/destroy

Guard hook rewritten as a segment analyser: newline/indent/continuation splitting, runner and
shell -c / eval unwrapping, quoted-string and heredoc masking, per-segment directory cursor
(cd moves it; git -C / bun --cwd are segment-local), --dry-run and --abort/--continue exclusions,
exact db:migrate script name, REQUIRE_MAIN_BRANCH isolated and compared against origin/HEAD,
state files pruned after a day. 31 tests.

Scripts: slot registry under .worktrees/.slots/<N>, marker-or-reference slot ownership with
conflict refusal, fork-onto-existing-branch refusal, whole multi-line quoted values in sync-env
plus an env-source gap check, per-pid port kill, merged-branch warning for + prefixed branches,
destroy restricted to registered worktrees, WORKTREE_SLOT validation, stale lock reclaim by
dead pid or age, destroy skips DB drop when another worktree claims the slot, credential-less
DATABASE_URL rewrite verified, PROJECT_NAME fallback aligned with docker-compose.

Docs carry the identical deny condition and the new script behaviours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, bun aliases, hardened env copy and destroy

Hook: bun a/rm/install <pkg>/i <pkg> and every --cwd shape of db:migrate are mutating; every
cd / -C / --cwd target goes through realpath; a mutation is lifted only inside a worktree that
git worktree list registers or in this main checkout named by its own absolute path (cd and -C
alike); symlinks to main, relative paths into main, unregistered or foreign checkouts, and
non-checkout directories never lift; glued separators tokenize. Test sandbox is a real git repo.

Scripts: WORKTREE_SLOT stripped from copied root env files; main excluded from the claimant
scan by realpath; sed replacements escaped and DATABASE_URL rewrite tolerant of & | / in the
password; database inventory failure aborts; --skip-db needs neither credentials nor Docker;
required env keys die by name; destroy prints merge status and refuses a dirty worktree unless
--force.

Docs carry the identical deny sentence and the destroy flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ks, subshell-aware guard cursor, honest bucket removal

Scripts: registered-worktree check no longer greps a multi-line producer under pipefail;
GNU/BSD stat branch for lock age; env reads and rewrites share one export/quotes/comment-aware
parser and verify themselves (no password through sed); list.sh parses --porcelain; destroy
requires this repo's git common dir and reports each bucket via minio-remove.sh over the
provisioning endpoint; create registers the slot before the worktree is added, keeps the lock
until exit, counts registry entries as claims, prunes only missing+unlocked+old entries, warns on
duplicate registry owners, and prints the destroy --force hint on any failure after the
worktree exists; sync-env handles escaped quotes, trailing comments, export on both sides, and
dies on an unterminated quote; realpath comparisons everywhere; mv-before-rm lock reclaim;
left boundary on DB-name claims.

Hook: subshell/pipe/background cursor rules; bun option values consumed anywhere; word-0
normalisation and runner options; --git-dir/--work-tree/GIT_DIR/GIT_WORK_TREE redirects;
comments stripped; cd options; original segment text in the deny message; here-strings;
substitutions in cd targets unknown; arm only for this repo's worktrees; balanced quotes in
$( ); depth cap; hashed state files; single stderr report on failure; revert/am guarded.

Docs follow every change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ites, signal-safe create, heredoc-aware guard

Scripts: set_env_var re-emits with the quoting it found and verifies by sourcing semantics;
destroy prints porcelain through a here-string (no SIGPIPE at 64KB), classifies a directory
before printing (registered / half-created under this repo's .git/worktrees / foreign gitdir
with a repair hint / independent repo / stray), normalises the typed name on a case-insensitive
filesystem, and passes the same STORAGE_* as provisioning to bucket removal; create traps
HUP/INT/TERM so the EXIT hook runs, consults the slot lock before the registry (dead pid →
reclaim and prune, live pid → skip with the owner), prints one line per skipped slot, validates
registry entries are files, treats empty entries as corrupt and relative ones as rewritable,
resolves duplicate entries by the worktree's marker, reads slot evidence through env_value, and
escapes PROJECT_NAME in the claim pattern; sync-env ensures a trailing newline before appending;
file_mtime tolerates BusyBox stat.

Hook: scripts fed to a shell (heredoc, piped echo/printf/cat, $(cat <<EOF) in -c or eval) are
walked as commands; env -C/--chdir moves the segment directory; word 0 is case-folded on darwin
and stripped of backslashes; a # glued to a substitution is not a comment; per-runner value
option tables (sudo, caffeinate, env, xargs, timeout, nice); bun option tables switch at the
subcommand so add -d is a flag.

Docs follow every change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cket removal, pass-through pipes in the guard

destroy: a .git file whose gitdir under this repo's .git/worktrees/ still exists is a moved
worktree — refused with a git worktree repair hint; only a gone gitdir is removed and pruned.
minio-remove: mc ls before and after rb, so "removed" means it existed and is gone.
Duplicate registry resolution labels the rule that applied (marker or newest); list.sh matches.
Hook: piped text is carried through tee and argument-less cat into a shell.
Docs: deny sentence updated identically in all three places; scripts/db tree completed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The guard exists to catch an honest agent whose Bash cwd drifted back to the
main checkout. Three adversarial rounds turned it into a ~940-line shell
parser chasing evasion (piped heredocs, sudo tables, case folding) while it
missed the two rules that matter:

- git worktree add/remove/move/prune is denied in every session, pointing at
  bun run worktree:create/destroy.
- once a session (or subagent, keyed by agent_id) has worked in a registered
  worktree of this repository, a mutating command landing in the main
  checkout is denied on ANY branch; REQUIRE_MAIN_BRANCH is gone, since the
  main checkout usually sits on a feature branch and that is exactly where a
  drifted commit does damage.

Commands are read by words with quoted strings masked and heredoc bodies
dropped; cd / git -C / bun --cwd move the landing, ( ) scopes a cd, an
absolute path into main is an explicit choice, a $VAR target is unknown and
allowed. State is keyed per session, subagent and main checkout.

939 -> ~290 lines; tests 1002 -> ~320 lines, 20 pass. Docs replace the
long deny sentence in ENTRYPOINT, SCRIPTS and the worktrees skill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@agreenspan
agreenspan marked this pull request as ready for review September 24, 2026 03:29
@agreenspan
agreenspan merged commit 907c618 into main Sep 24, 2026
@agreenspan

Copy link
Copy Markdown
Contributor Author

Added 2b8c98d — env gaps found while running worktrees in Kingdom and Tribe today:

  • Ports: VITE_API_URL / VITE_*_URL (root and apps) now follow the slot, and PORT is only rewritten in apps/api/.env.local. Before, every Kingdom worktree's frontend called the main checkout's API on 8000, and this branch wrote the API port into web/admin/superadmin PORT.
  • SYNC_TEST_ENV_FILES=false (Kingdom's root .env, for kingdom:check's isolated stack) is honoured by sync-env.sh and create.sh, so worktrees don't get test env files that make kingdom:check refuse to run.
  • Redaction: the two die messages that echo env values now mask URL credentials (they printed DATABASE_URL passwords).
  • Trusted postinstalls: bun install --ignore-scripts skipped trustedDependencies postinstalls, so @embedded-postgres/* had no ICU symlinks and kingdom:check failed in every worktree. They now run explicitly; resolves bun's isolated .bun/<pkg>@<ver> layout; no-op for repos with no trusted deps (template, Tribe).

Verified: bash -n on all three scripts; redaction and SYNC_TEST_ENV_FILES=false exercised directly; the postinstall runner restored the symlinks (3 → 9) in a real Kingdom worktree; CI rules pass.

🤖 Generated with Claude Code

agreenspan added a commit that referenced this pull request Oct 12, 2026
…ans on worker start (#187)

The cronJobSync hook passed jobId inside the repeat options, but BullMQ's removeRepeatable
overwrites repeat.jobId with its third argument, so the computed key never matched and no
old repeatable was ever removed: a pattern edit, rename, disable or delete left the old one firing.

- Hook: removeRepeatable(handler, { pattern }, jobId).
- registerCronJobs (worker start) removes every repeatable no enabled row registered, matched by
  the repeatJobKey each add returns; it prunes nothing if any row failed to register.
- cronRepeatablesRedis.test.ts runs against real Redis/BullMQ: the old call shape removes nothing,
  edit/rename/delete move the repeatable, startup prunes orphans. Skips when the test REDIS_URL
  is unreachable (the default test env uses the in-memory mock).

Port of Kingdom #116 (7c3a3737).

Co-authored-by: Aron Greenspan <aron.greenspan@inixiative.com>
Co-authored-by: Claude Opus 5.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