diff --git a/.claude/agents/os-dev.md b/.claude/agents/os-dev.md index 3f522ef27d..9aa652d8d5 100644 --- a/.claude/agents/os-dev.md +++ b/.claude/agents/os-dev.md @@ -206,6 +206,29 @@ all real: forcing the template; #4984 is the family origin — fixtures spelling rejected aliases kept the tests green while the rule was dead). +**⛔ Take the fix out with `git checkout`, a patch file or a temp commit — NEVER +`git stash`.** The worktree isolates your files and your HEAD; it does **not** +isolate `refs/stash`, which lives in the **common** `.git` and is one LIFO stack +shared by every worktree of the repo. Reverse verification is what makes this +bite: "stash the fix, re-run, restore" is the reflex move, so two agents doing it +at the same time swap entries — one `pop` restores the *other's* changes into your +worktree while yours stay on the stack, `pop` reports **success**, and a following +`git add -A` commits their half-finished work into your PR (objectui#3430, two dev +agents, both changesets recoverable only as unreachable commits). Use instead, all +inside your own worktree: + +``` +git checkout origin/main -- # take the fix out; restore: git checkout -- +git diff > /tmp/wip.patch && git checkout -- # restore: git apply /tmp/wip.patch +git commit -am wip # restore: git reset --soft HEAD~1 +``` + +`.claude/hooks/guard-shared-stash.sh` blocks the mutating forms on the `Bash` +matcher (`push`/`pop`/`drop`/`clear`, and `stash@{N}` — a *position* in a stack you +don't own); `git stash list`/`show`/`create` and `apply`/`store` pinned to a literal +hex object id stay allowed. Escape hatch, when the stack really is yours alone: +`OS_ALLOW_STASH=1`. + **Rejection-class cases assert the envelope, not the throw.** For any case whose point is that bad input is *refused*, the minimum assertion set is the error's **`code` AND `status`** (the ADR-0112 envelope). `expect(...).toThrow()` / diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh index 6da7791780..2578d1cc93 100755 --- a/.claude/hooks/guard-main-checkout-bash.sh +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -14,9 +14,9 @@ # Ported from objectui's hook of the same name (objectstack-ai/objectui#3452, filed there # as objectui#3435) — the logic below is deliberately kept case-for-case identical to it so # the two repos' guards cannot drift; only issue references, example paths and the package -# name in the self-test are localised. objectui additionally runs guard-shared-stash.sh on -# the same matcher; this repo has no such hook, so the Bash matcher is created here rather -# than joined (that gap is tracked separately — see #5790's closing observation). +# name in the self-test are localised. The Bash matcher this hook created is now shared +# with guard-shared-stash.sh, mirrored here from objectui in the same way (#5742) — the +# gap #5790's closing observation recorded is closed; both hooks run on this one matcher. # # The rule and the escape hatch are deliberately the SAME as guard-main-checkout.sh's: # OS_ALLOW_MAIN_EDITS=1. One rule, one hatch — a second variable would just be another diff --git a/.claude/hooks/guard-shared-stash.selftest.sh b/.claude/hooks/guard-shared-stash.selftest.sh new file mode 100755 index 0000000000..9c628e6555 --- /dev/null +++ b/.claude/hooks/guard-shared-stash.selftest.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Self-test for guard-shared-stash.sh — run it after touching that hook: +# +# .claude/hooks/guard-shared-stash.selftest.sh +# +# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and +# asserts the block/allow verdict per command. Needs jq (to build payloads) and nothing +# else: no install, no build, no network. Exit 0 = all cases hold. +# +# Mirrored from objectui's self-test of the same name (objectui#3430 / PR #3433) alongside +# the hook itself; the case matrix is kept one-for-one so the two repos' guards cannot +# drift, and only the example paths, worktree names and package name are localised. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/guard-shared-stash.sh" +pass=0 +fail=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; } + +# verdict [env assignments…] -> prints "block" or "allow" +verdict() { + local cmd="$1"; shift + local payload out rc + payload="$(jq -nc --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')" + out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)" + rc=$? + case "$rc" in + 0) printf 'allow' ;; + 2) printf 'block' ;; + *) printf 'exit%s' "$rc" ;; + esac +} + +expect() { # expect [env…] + local want="$1" cmd="$2"; shift 2 + local got; got="$(verdict "$cmd" "$@")" + if [ "$got" = "$want" ]; then + pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$cmd" + else + fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$cmd" + fi +} + +echo "== mutating forms must be blocked ==" +expect block 'git stash' +expect block 'git stash push -- packages/spec/src/kernel/metadata-plugin.zod.ts' +expect block 'git stash pop' +expect block 'git stash save wip' +expect block 'git stash drop' +expect block 'git stash clear' +expect block 'git stash branch recovered' +expect block 'git stash pop > /dev/null' + +echo "== positional stash@{N} is NOT SHA-pinned: still the shared stack ==" +expect block 'git stash pop stash@{0}' +expect block 'git stash apply stash@{1}' + +echo "== reached through separators, substitution and git -C ==" +expect block 'cd /home/user/objectstack && git stash pop' +expect block 'git -C ../objectstack-issue-5742 stash pop' +expect block 'out=$(git stash pop)' +expect block 'git status && git stash push -m wip; pnpm test' + +echo "== read-only and SHA-pinned forms are allowed ==" +expect allow 'git stash list' +expect allow 'git stash show -p' +expect allow 'git stash create' +expect allow 'git stash --help' +expect allow 'git stash apply abc1234' +expect allow 'git stash apply --index deadbeefcafe1234' +expect allow 'git stash store -m "WIP issue-5742" b52e3aa1234567' + +echo "== unrelated commands are untouched ==" +expect allow 'pnpm --filter @objectstack/spec test' +expect allow 'git status' +expect allow 'git commit -am wip' +expect allow 'git diff > /tmp/wip.patch && git checkout -- packages/spec' + +echo "== writing ABOUT the ban must not trip the ban ==" +expect allow 'grep -n "git stash" AGENTS.md' +expect allow 'grep -rn "cd x && git stash pop" .claude/' +expect allow 'echo "never run git stash pop in a shared checkout"' +expect allow 'git grep -n "git stash"' + +echo "== escape hatch ==" +expect allow 'git stash pop' OS_ALLOW_STASH=1 + +echo "== payload with no command fails open ==" +if printf '%s' '{"tool_name":"Bash","tool_input":{}}' | "$hook" >/dev/null 2>&1; then + pass=$((pass + 1)); printf ' ok allow (empty tool_input)\n' +else + fail=$((fail + 1)); printf ' FAIL empty tool_input should fail open\n' +fi + +echo "== jq-less fallback still parses the command ==" +nojq="$(mktemp -d)" +for b in bash env cat sed head grep; do + p="$(command -v "$b")" && ln -s "$p" "$nojq/$b" +done +printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git stash pop"}}' \ + | PATH="$nojq" "$hook" >/dev/null 2>&1 +case "$?" in + 0) got_nojq=allow ;; + 2) got_nojq=block ;; + *) got_nojq="exit$?" ;; +esac +if [ "$got_nojq" = block ]; then + pass=$((pass + 1)); printf ' ok block (no jq on PATH)\n' +else + fail=$((fail + 1)); printf ' FAIL no-jq fallback got=%s\n' "$got_nojq" +fi +rm -rf "$nojq" + +printf '\n%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.claude/hooks/guard-shared-stash.sh b/.claude/hooks/guard-shared-stash.sh new file mode 100755 index 0000000000..6c512b3fb4 --- /dev/null +++ b/.claude/hooks/guard-shared-stash.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# guard-shared-stash.sh — PreToolUse guard: the stash stack is SHARED across worktrees. +# Blocks Bash commands that push to / pop from / drop the shared stash stack, and lets +# the read-only and SHA-pinned forms through. +# +# Why: `git stash` keeps its stack in refs/stash inside the COMMON .git directory. Every +# linked worktree shares that one LIFO stack, so the per-task worktree isolation AGENTS.md +# Prime Directive #11 mandates — and guard-main-checkout.sh enforces — does NOT extend to +# stash. Two agents stashing in their own worktrees operate on the same stack: A's pop +# restores whatever B pushed a moment earlier, and A's own changes stay on the stack for +# B to take. +# +# Live incident, objectui#3430 (2026-08-06, ~03:56Z): a reverse-verification +# `git stash push -- packages/fields/.../RecordPickerDialog.tsx` followed by +# `git stash pop` dropped b52e3aa instead — another agent's WIP on claude/issue-5733-…, +# two unrelated plugin-detail files. Both agents' in-flight work swapped places; a +# `git add -A` on either side would have merged the other's changes into the wrong PR. +# The failure mode is maximally confusing: pop reports SUCCESS, and someone else's files +# simply appear in your git status. Recovery worked only because the dropped SHA was still +# in scrollback — once the stack empties, refs/stash and logs/refs/stash are both gone and +# `git reflog refs/stash` answers `fatal: ambiguous argument`; a `git gc` in between makes +# the loss permanent. Reverse verification is routine here and stash is the handiest tool +# for it, so the collision probability is not small. +# +# Ported from objectui's hook of the same name (objectui#3430 / objectui PR #3433), filed +# for this repo as objectstack#5742. The verdict logic below is deliberately kept +# case-for-case identical to objectui's so the two repos' guards cannot drift; only issue +# references, example paths and the package name in the self-test are localised — the same +# mirroring discipline guard-main-checkout-bash.sh already documents. +# +# Alternatives — no shared state, all of these work inside your own worktree: +# 1. clean re-read git checkout origin/main -- (restore: git checkout -- ) +# 2. patch file git diff > /tmp/wip.patch && git checkout -- +# git apply /tmp/wip.patch (git apply -R to undo again) +# 3. temporary commit git commit -am wip (git reset --soft HEAD~1) +# 4. a second worktree for the comparison checkout +# +# Allowed through, deliberately: +# - `git stash list` / `git stash show` — read-only, they never mutate the stack. +# - `git stash create` — writes a commit object and prints its object id WITHOUT +# storing it in the ref namespace (git-stash(1)); the safe primitive underneath the +# SHA-pinned workflow. +# - `git stash apply ` / `git stash store ` — the recovery path used to repair +# the incident above. An explicit hex object id ONLY: stash@{0} is a POSITION in the +# shared stack and may be another agent's entry by the time your command runs. +# +# Deliberate exception (you know the stack is yours alone): OS_ALLOW_STASH=1. +# +# Exit-code contract, mirroring guard-main-checkout.sh: 0 = allow, 2 = block with the +# reason on stderr. Anything this cannot parse fails OPEN — a guard that blocks work it +# does not understand gets disabled, and then it guards nothing. +# +# Known boundary, stated so nobody has to rediscover it: the check reads the FIRST WORD of +# each shell segment, so a wrapped invocation (bash -c '…', xargs, ssh host '…') is not +# caught. That is the deliberate trade — the target is the reflexive `git stash push` an +# agent reaches for mid-task, not a determined evader, and OS_ALLOW_STASH=1 already exists +# for anyone who means it. Widening it to string-match anywhere in the command would block +# every `grep "git stash"` run against this very file. +# +# Self-test (32 cases, no network, no build): .claude/hooks/guard-shared-stash.selftest.sh + +set -uo pipefail + +[ "${OS_ALLOW_STASH:-}" = "1" ] && exit 0 + +input="$(cat 2>/dev/null || true)" +cmd="" +if command -v jq >/dev/null 2>&1; then + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" +fi +if [ -z "$cmd" ]; then + # jq-less fallback: lift the JSON string value honouring backslash escapes (so an + # embedded \" does not truncate the command), then unescape what matters for shell text. + cmd="$(printf '%s' "$input" \ + | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' \ + | head -1 \ + | sed 's/\\n/ /g; s/\\t/ /g; s/\\"/"/g; s/\\\\/\\/g')" +fi + +[ -n "$cmd" ] || exit 0 + +# --- split the command into shell segments, honouring quotes --------------------------- +# A separator inside '…' or "…" does NOT split, so writing *about* the ban is never caught +# by the ban: `grep -n "cd x && git stash pop" AGENTS.md` stays one segment whose first +# word is grep. (objectstack#4890's lesson — the PR writing a rule must not trip it.) +segments=() +split_segments() { + local s="$1" seg="" q="" ch i n=${#1} + for ((i = 0; i < n; i++)); do + ch="${s:i:1}" + if [ -n "$q" ]; then + seg+="$ch" + [ "$ch" = "$q" ] && q="" + continue + fi + case "$ch" in + "'" | '"') q="$ch" ; seg+="$ch" ;; + ';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;; + *) seg+="$ch" ;; + esac + done + segments+=("$seg") +} + +# --- verdict for one segment ----------------------------------------------------------- +# returns 0 = fine, 1 = this segment mutates the shared stash stack. +check_segment() { + local seg="$1" + local -a w=() + read -r -a w <<<"$seg" + local i=0 n=${#w[@]} + [ "$n" -gt 0 ] || return 0 + + # leading FOO=bar environment assignments + while [ "$i" -lt "$n" ]; do + case "${w[$i]}" in + [A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)) ;; + *) break ;; + esac + done + [ "$i" -lt "$n" ] || return 0 + + # /usr/bin/git -> git + [ "${w[$i]##*/}" = "git" ] || return 0 + i=$((i + 1)) + + # git's own global options, before the subcommand + while [ "$i" -lt "$n" ]; do + case "${w[$i]}" in + -C | -c | --exec-path | --git-dir | --work-tree | --namespace) i=$((i + 2)) ;; + -*) i=$((i + 1)) ;; + *) break ;; + esac + done + [ "$i" -lt "$n" ] || return 0 + [ "${w[$i]}" = "stash" ] || return 0 + i=$((i + 1)) + + local sub="${w[$i]:-}" + case "$sub" in + --help | -h) return 0 ;; # reading the manual is not stashing + list | show) return 0 ;; # read-only against refs/stash + create) return 0 ;; # makes an object, does NOT store it in the stack + apply | store) + # pinned to an explicit hex object id => this cannot pick up another agent's entry. + local j + for ((j = i + 1; j < n; j++)); do + [[ "${w[$j]}" =~ ^[0-9a-fA-F]{7,40}$ ]] && return 0 + done + ;; + esac + return 1 +} + +split_segments "$cmd" +for seg in "${segments[@]}"; do + check_segment "$seg" && continue + offending="${seg#"${seg%%[![:space:]]*}"}" + cat >&2 < + git checkout -- # put your version back + 2. patch file git diff > /tmp/wip.patch && git checkout -- + git apply /tmp/wip.patch # git apply -R to undo again + 3. temporary commit git commit -am wip # git reset --soft HEAD~1 + 4. a second worktree for the comparison checkout + +Already allowed, no flag needed: + git stash list | git stash show | git stash create + git stash apply | git stash store # literal hex id, never stash@{N} + +Deliberate exception (the stack really is yours alone): re-run with OS_ALLOW_STASH=1. +EOF + exit 2 +done + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index e7de8c8e70..c3eddf06fc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -35,6 +35,10 @@ { "matcher": "Bash", "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-shared-stash.sh\"" + }, { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout-bash.sh\"" diff --git a/AGENTS.md b/AGENTS.md index eef397b2f1..999373b5b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. When renaming a legacy var, use `readEnvWithDeprecation('OS_NEW', 'LEGACY')` from `@objectstack/types` (keeps legacy working one release). Third-party exceptions kept as-is: `NODE_ENV`, `HOME`, `OPENAI_API_KEY`, `TURSO_*`, OAuth `*_CLIENT_ID/SECRET`, `RESEND_API_KEY`, `POSTMARK_TOKEN`, `AI_GATEWAY_*`, `SMTP_*`. See #1382. 10. **File issues for out-of-scope findings — don't silently expand scope or leave them buried.** When you hit a bug, gap, or unenforced capability that's unrelated to the current task, or too large to fix in scope, open a GitHub issue (`gh issue create`) with a clear repro/decision and link it from your PR. Corollary: **never advertise or demo a capability the runtime doesn't actually deliver** (declared ≠ enforced) — fix it, trim it, or file an issue, but don't fake coverage. Example: the spec once declared 9 validation-rule types while the write-path validator enforced only 3 (`state_machine`/`script`/`cross_field`); the gap was filed as #1475 rather than demoed in the showcase, then closed by **trimming** what could never be enforced (`unique`/`async`/`custom`) and **implementing** the rest — the spec now declares 6 and `rule-validator.ts` handles all 6. Note how narrow that claim stayed even so: the evaluator was wired into insert and single-id update only, so a bulk `updateMany` silently skipped every rule — a second `declared ≠ enforced` gap one layer down, at the **call site** rather than the `switch`; filed as #3106 and closed by evaluating the bulk match set per row. A `case` label is not enforcement; check the **call site**. -11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared `main` tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work. Before your **first file edit**, you MUST be in a dedicated worktree on a feature branch: `git worktree add ../objectstack- -b main && cd ../objectstack- && pnpm install`. Two PreToolUse hooks **enforce** this — `.claude/hooks/guard-main-checkout.sh` blocks `Edit`/`Write`/`NotebookEdit`, and `.claude/hooks/guard-main-checkout-bash.sh` blocks the identical write arriving through **Bash** (`>`/`>>` redirection, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, `rm`, `touch`) — unless the target is in a dedicated **worktree** — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you) — and both check the **target file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (override for a deliberate non-task fix with `OS_ALLOW_MAIN_EDITS=1`, one switch for both). The Bash guard is precision-first: it never blocks reads, and any shape it cannot resolve with confidence (`bash -c …`, `xargs`, `node -e`, a `$VAR`/glob target) is allowed through — the rule still outranks the hook. Full playbook below. +11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared `main` tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work. Before your **first file edit**, you MUST be in a dedicated worktree on a feature branch: `git worktree add ../objectstack- -b main && cd ../objectstack- && pnpm install`. Two PreToolUse hooks **enforce** this — `.claude/hooks/guard-main-checkout.sh` blocks `Edit`/`Write`/`NotebookEdit`, and `.claude/hooks/guard-main-checkout-bash.sh` blocks the identical write arriving through **Bash** (`>`/`>>` redirection, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, `rm`, `touch`) — unless the target is in a dedicated **worktree** — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you) — and both check the **target file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (override for a deliberate non-task fix with `OS_ALLOW_MAIN_EDITS=1`, one switch for both). The Bash guard is precision-first: it never blocks reads, and any shape it cannot resolve with confidence (`bash -c …`, `xargs`, `node -e`, a `$VAR`/glob target) is allowed through — the rule still outranks the hook. **The one thing a worktree does *not* isolate is the stash**: `refs/stash` lives in the **common** `.git`, so a bare `git stash push`/`pop` operates on a LIFO stack shared with every other worktree — objectui#3430 swapped two agents' in-flight changes through it, silently. A third hook (`guard-shared-stash.sh`, `OS_ALLOW_STASH=1`) blocks the mutating forms; the collision-free replacements are in the discipline section below. Full playbook below. 12. **Contract-first — fix the metadata, not the runtime.** This is a metadata-driven framework: `packages/spec` is the one contract between metadata *producers* and the runtime/renderers that *consume* it. When a piece of metadata "doesn't work," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is wrong, fix it at the **producer** and **reject it at authoring/publish** (validation / lint) so the error surfaces loudly — do **not** add a lenient alias or `??` fallback in the consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an **internal** contract (we own both ends), so "be liberal in what you accept" (Postel) does **not** apply — that's for untrusted boundaries. Change the **spec** only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. The `cfg.filter ?? cfg.filters` / `cfg.objectName ?? cfg.object` fallbacks the flow executors once carried are **debt to pay down, not a pattern to copy** — and the way they are being paid down is the pattern to copy. `filters` → `filter` has **graduated** into the ADR-0087 D2 conversion layer (`flow-node-crud-filter-alias`): rewritten to the canonical key at load, including the `AutomationEngine.registerFlow` rehydration seam, so the CRUD executors read `cfg.filter` directly and no consumer-side fallback survives. `object` → `objectName` and the six open-coded stragglers #3796 tracked (notify `to`/`subject`/`body`/`url`, script `functionName`/`input`) graduated the same way at protocol 17 (`flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-script-config-aliases`), emptying the `readAliasedConfig` executor shim — deleted with them. When you must tolerate an alias at all, declare it as a conversion-layer entry (never a bare `??`, and no new executor shims) so it is declared, loud, tested, and *removable on a schedule*. Stored `sys_metadata` rows (data at rest) are covered from the other side: every rehydration seam replays the **full** conversion chain — retired entries included — via `applyConversionsToStoredItem` (#3903, ADR-0087 addendum), so a consumer never needs its own accommodation for a legacy stored shape either. *Worked example:* an AI-authored `create_record` used `fieldValues` / `today()` / `{{trigger.record.id}}` while the executor reads `fields` / `{TODAY()}` / `{record.id}` → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape (cloud#688), **not** a `cfg.fields ?? cfg.fieldValues` runtime alias (framework#2419, rejected). Strengthens #5. 13. **An accepted ADR binds until a superseding ADR says otherwise.** Reversing a recorded decision is itself a decision: it needs a **new ADR** (or an amended status line on the old one), not a changeset that quietly does the opposite. Before changing behaviour in `docs/adr/`-governed territory, **grep the ADRs for the surface you are touching** — the decision is often older and broader than the code comment in front of you. *Worked example:* three accepted ADRs said `sys_member.role` must never carry RBAC authority (ADR-0057 D4 "never as the authority for RBAC", ADR-0090 D3's word ban "distribution = `position`", ADR-0095 D3 "no enforcement-time code path may consult the better-auth role"). A patch-level changeset made app-declared names storable there anyway; a follow-up made it automatic in every host; the reversal held for a day and the tracking issue was closed, reopened and rewritten three times while the cause moved (#3723 → ADR-0108). The mechanism was not carelessness — **the file being edited never named the ADRs that governed it**, so the author could not have known. Hence the corollary: when you implement an ADR's decision, **leave its id in the code**, and anchor load-bearing spots in `scripts/adr-anchors.json` (`pnpm check:adr-anchors`) so the next author is told which decision they are standing on. A decision nobody can find is a decision that will be reversed. @@ -140,6 +140,38 @@ checkout is *not* a supported fallback: branches get switched and shared files including ones you just wrote — get reset *under you* mid-task (a full session's work was silently reverted twice before this rule was enforced). +**⛔ `git stash` is the one thing the worktree does NOT isolate — never run a bare +`git stash push`/`pop`.** The worktree gives you your own working tree and your own +HEAD; it does **not** give you your own stash. `refs/stash` and its reflog live in the +**common** `.git` directory, so every linked worktree pushes onto and pops off **one +shared LIFO stack**. Two agents stashing at the same time swap entries: A's `pop` +restores B's changes into A's worktree, A's own work stays on the stack for B to take, +and **`pop` reports success** — the only symptom is another agent's files appearing in +your `git status`, after which a `git add -A` commits their half-finished work into your +PR. Not hypothetical: objectui#3430 (2026-08-06) did exactly this to two parallel dev +agents mid reverse-verification, and both changesets survived only as unreachable commits +whose SHAs happened to still be in scrollback — once the stack empties, `refs/stash` and +`logs/refs/stash` are gone (`git reflog refs/stash` → `fatal: ambiguous argument`) and a +`git gc` in between makes the loss permanent. Reverse verification ("revert the fix, watch +the diagnostics") is the workflow every dev agent runs, which is exactly why the collision +window is wide. Use one of these instead — no shared state, all inside your own worktree: + +``` +git checkout origin/main -- # then: git checkout -- +git diff > /tmp/wip.patch && git checkout -- # then: git apply /tmp/wip.patch +git commit -am wip # then: git reset --soft HEAD~1 +git worktree add ../objectstack--cmp # a second tree to compare against +``` + +A third PreToolUse hook (`.claude/hooks/guard-shared-stash.sh`, mirrored from objectui +after that incident — #5742) enforces this on the `Bash` matcher: it blocks the mutating +forms (`push`/`pop`/`save`/`drop`/`clear`/`branch`, including `stash@{N}` positions, which +are positions in a stack you don't own) and allows what cannot take another agent's entry +— `git stash list`/`show`/`create`, and `apply`/`store` pinned to a **literal hex object +id**. It fails open on shapes it cannot parse (`bash -c …`, `xargs`), so the rule still +outranks the hook. Deliberate exception when the stack really is yours alone: +`OS_ALLOW_STASH=1`. Changing the hook? Re-run `.claude/hooks/guard-shared-stash.selftest.sh`. + **Claim the issue BEFORE you write any code.** Assign it to yourself (`gh issue edit --add-assignee @me`, or the `issue_write` MCP tool with `assignees`) as the *first* action of the task — before the worktree, before the diff --git a/CLAUDE.md b/CLAUDE.md index 92f06075c5..9e58cc4298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md **[AGENTS.md](./AGENTS.md) is the source of truth for working in this repo — read it.** -Its Prime Directives are binding. Do not rely on this file alone; the three rules that +Its Prime Directives are binding. Do not rely on this file alone; the four rules that must never be missed are inlined here because missing any one of them wastes or corrupts other agents' work. @@ -43,6 +43,34 @@ allowed through, so the rule still outranks the hook. Deliberate non-task except hooks, one switch): `OS_ALLOW_MAIN_EDITS=1`. Follow the rule because it's correct, not because the hook fires. +## ⛔ Never `git stash` — the stash stack is NOT covered by worktree isolation + +`git stash` keeps its stack in `refs/stash` inside the **common `.git` directory**, so +**every worktree of the repo shares one LIFO stack**. The per-task isolation above does +not extend to it: two agents stashing in their own worktrees push and pop the *same* +stack — your `pop` restores whatever the other agent pushed a moment earlier, and your +own changes stay on the stack for them to take. `pop` reports **success**; the only +symptom is someone else's files appearing in your `git status`, and a following +`git add -A` merges their work into your PR. Not hypothetical: it happened between two +parallel agents mid reverse-verification (objectui#3430) and cost both of them their +in-flight changes, recoverable only as unreachable commits. + +Use one of these instead — no shared state, all inside your own worktree: + +``` +git checkout origin/main -- # then: git checkout -- +git diff > /tmp/wip.patch && git checkout -- # then: git apply /tmp/wip.patch +git commit -am wip # then: git reset --soft HEAD~1 +git worktree add ../objectstack--cmp # a second tree to compare against +``` + +A PreToolUse hook (`.claude/hooks/guard-shared-stash.sh`) enforces this — it blocks the +`Bash` commands that push/pop/drop/clear the stack, and allows the forms that cannot take +another agent's entry: `git stash list`/`show`/`create`, and `git stash apply ` / +`store ` pinned to a **literal hex object id** (never `stash@{N}` — that is a +*position* in a stack you don't own). Deliberate exception: `OS_ALLOW_STASH=1`. Changing +the hook? Re-run `.claude/hooks/guard-shared-stash.selftest.sh`. + ## ⛔ Never edit `content/docs/releases/` in a code PR Release notes are written **centrally, at release time** — not accreted one PR at a