From aa205d786b8fe1650159c8fa96072ffb321b28dd Mon Sep 17 00:00:00 2001 From: Dennis Vorobyov Date: Sat, 12 Sep 2026 02:09:06 +0100 Subject: [PATCH] feat(hooks): SessionStart hook to offer a project CLAUDE.md when a repo has none Add bootstrap-claude-md.sh: at session start, when a git repo has no root CLAUDE.md, nudge Claude to offer to create one from the standard template. Populated repos get just the CLAUDE.md filled from what is detectable; bare repos defer to the new-repo skill; existing files are never overwritten. The hook only inspects and emits context, never writes a file, and is fail-open and silent otherwise (not a git tree, home directory, or the file already exists). Adds the section 4C project-bootstrap policy, docs/bootstrap.md, the SessionStart registration in both settings files, the ~/.claude/templates install step, and tests/hooks/test-bootstrap-claude-md.sh (9 cases). Sweeps the hook count to three across README, STRUCTURE, and section 19. --- .github/workflows/gate.yml | 3 + CLAUDE.md | 18 +++++- README.md | 3 +- STRUCTURE.md | 6 +- docs/bootstrap.md | 32 ++++++++++ hooks/bootstrap-claude-md.sh | 46 ++++++++++++++ settings.json | 3 + settings2.json | 3 + tests/hooks/test-bootstrap-claude-md.sh | 81 +++++++++++++++++++++++++ 9 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 docs/bootstrap.md create mode 100755 hooks/bootstrap-claude-md.sh create mode 100755 tests/hooks/test-bootstrap-claude-md.sh diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 26b7b8f..224b82d 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -38,3 +38,6 @@ jobs: - name: Format hook behavior run: bash tests/hooks/test-format.sh + + - name: Bootstrap hook behavior + run: bash tests/hooks/test-bootstrap-claude-md.sh diff --git a/CLAUDE.md b/CLAUDE.md index c74e836..e846f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,17 @@ The layer that wraps the four hats. `technical-program-manager` owns what / why --- +## 4C. Project Bootstrap + +When you start working in a repo that has **no root `CLAUDE.md`**, offer to create one before doing other work — don't proceed silently, and don't create it unless the user agrees. On yes: + +- **Bare repo** (no manifest or source — a fresh `git init`): use the `new-repo` skill for the full hygiene set (`.gitignore`, CI, docs stubs, and the `CLAUDE.md`). +- **Populated repo** (the common case): create `./CLAUDE.md` from `~/.claude/templates/CLAUDE.project.md`, fill the §19 fields you can confidently detect now (stack, quality-gate commands), and leave the rest as `TODO`. Don't lay down the rest of the `new-repo` scaffolding in a repo that already has its own shape. + +Complete the remaining §19 `TODO`s over time as PRDs, specs, and READMEs reveal them. **Never fabricate** a version or a compliance scope, and **never overwrite** an existing `CLAUDE.md`. The `hooks/bootstrap-claude-md.sh` SessionStart hook fires this check automatically and stays silent once the file exists. Detection order and the bare-vs-populated test: `~/.claude/docs/bootstrap.md`. + +--- + ## 9. Stacked PR Workflow Shipping multiple related PRs in a session: local feature branch per ticket; **don't push the version-bump + CHANGELOG combo until the prior PR merges** (else both touch the same version lines and the second hits a rebase conflict); when it merges, `git checkout && git pull --ff-only`, then `git rebase ` the next branch **locally** rather than relying on platform conflict-resolution (squash-merge SHAs don't match local commits). Full rebase discipline, the stash-and-checkout pitfall, and what to do between PR-open and merge (CI, review-thread replies with a pushed SHA, re-check after every push): `~/.claude/docs/git-workflows.md`. @@ -157,12 +168,12 @@ Four no-code extensions cover almost everything before you'd fork the binary: ** ### 19.1 What is this project? -- **One-paragraph description:** This repository _is_ a distributed Claude Code configuration, not an application: a stack-agnostic engineering spine (this `CLAUDE.md`, §1–18), platform rule packs (`rules/`), a 42-agent roster across four stacks (`agents/`, `agents-android/`, `agents-ios/`, `agents-compute/`), two hooks — a commit guard and a format-on-save hook (`hooks/`) — and a repo-scaffolder skill (`skills/new-repo/`). Users copy it into `~/.claude/` and per-repo. The product is the configuration's correctness and internal consistency; nothing is compiled or deployed. Public, MIT: github.com/roadhero/claude-code-setup. +- **One-paragraph description:** This repository _is_ a distributed Claude Code configuration, not an application: a stack-agnostic engineering spine (this `CLAUDE.md`, §1–18), platform rule packs (`rules/`), a 42-agent roster across four stacks (`agents/`, `agents-android/`, `agents-ios/`, `agents-compute/`), three hooks — a commit guard, a format-on-save hook, and a session-start project-bootstrap hook (`hooks/`) — and a repo-scaffolder skill (`skills/new-repo/`). Users copy it into `~/.claude/` and per-repo. The product is the configuration's correctness and internal consistency; nothing is compiled or deployed. Public, MIT: github.com/roadhero/claude-code-setup. ### 19.2 Stack - **Language(s):** Markdown (spine/rules/agents/docs) + Bash targeting macOS system bash 3.2 (`hooks/*.sh`) + JSON (`settings.json`, `settings2.json`). No compiled code. -- **Runtime / platform:** Claude Code CLI on macOS/Linux; hooks run via the user shell and `jq` is a hard runtime dependency of both hooks. +- **Runtime / platform:** Claude Code CLI on macOS/Linux; hooks run via the user shell and `jq` is a hard runtime dependency of all three hooks (the commit-guard and bootstrap hooks also need `git`). - **Framework(s):** None (only Claude Code extension points — §18). - **Storage:** None. - **Build / package:** None — files are copied verbatim into `~/.claude/`; `package.json` is intentionally absent. @@ -182,6 +193,7 @@ diff -q templates/CLAUDE.project.md skills/new-repo/templates/web/CLAUDE.md.tmpl diff -q templates/CLAUDE.project.md skills/new-repo/templates/android/CLAUDE.md.tmpl # stay byte-identical bash tests/hooks/test-guard-commit.sh # commit guard: stdin payload → exit code, one case per rule/regression bash tests/hooks/test-format.sh # format hook: exit 0, touches only the tool's own target +bash tests/hooks/test-bootstrap-claude-md.sh # bootstrap hook: SessionStart payload → nudge only when a git repo lacks a root CLAUDE.md # Optional, advisory (not installed by default; repo ships no markdownlint config): # npx --yes markdownlint-cli2 "**/*.md" "!skills/**/templates/**" ``` @@ -205,7 +217,7 @@ Requires `shellcheck`, `jq`, and `git` (the hooks need `jq` at runtime too) — > Each override erodes the predictability §1–18 provides; treat them as debt with a documented reason. Review quarterly: can any be removed? -- §19.3 replaces the build/unit/integration gate with static analysis (shellcheck + jq + a name-invariant grep + a copies-in-sync diff) plus behavioral tests for the two hooks — reason: this repo ships configuration; the hooks are its only executable code, so they are the only thing unit-tested. +- §19.3 replaces the build/unit/integration gate with static analysis (shellcheck + jq + a name-invariant grep + a copies-in-sync diff) plus behavioral tests for the three hooks — reason: this repo ships configuration; the hooks are its only executable code, so they are the only thing unit-tested. - Release notes come from the GitHub Release body instead of a `CHANGELOG.md` — reason: no CHANGELOG is maintained here. - Platform rule-pack path-triggering (`rules/{web,android,ios,compute}.md`) never fires in this repo — it has no matching source files. Expected. diff --git a/README.md b/README.md index 04a02bc..c5b0c62 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Most people publish a single `CLAUDE.md` and call it a setup. The thing that act | `agents-android/` (7), `agents-ios/` (7), `agents-compute/` (13) | Per-stack overrides. Drop them into a repo's `.claude/agents/` and they override the generic ones of the same name with platform-brained versions. | | `hooks/guard-commit.sh` | A Claude Code Bash hook (PreToolUse) that blocks the _agent_ from force-pushing, skipping git hooks with `--no-verify`, committing as a non-human, writing AI attribution into a commit message, or staging obvious secrets. Every segment of a chained command is checked on its own. Quoted text, heredoc bodies, substitutions, and arithmetic are stripped as data first, so a commit message or a file body that merely mentions a blocked flag never trips it; a shape the check cannot classify (a shell wrapper of any form, a substitution inside `${...}`, an unquoted pattern in a commit or push, git config passed through the environment) is refused rather than guessed. It guards Claude's git commands — not a human typing `git` directly in their own terminal. | | `hooks/format.sh` | Auto-formats edited files by extension across every stack. Missing formatter is a silent no-op, never an error. | -| `tests/hooks/` | Behavioral tests for both hooks: a JSON payload on stdin, an exit code out, one case per rule and per past regression. Plain bash 3.2 + `jq`. CI (`.github/workflows/gate.yml`) runs them with the rest of the quality gate on every PR. | +| `hooks/bootstrap-claude-md.sh` | A Claude Code SessionStart hook that, when you open a git repo with no root `CLAUDE.md`, nudges Claude to _offer_ to create one from the standard template (created only on your yes; §19 filled from what's detectable, the rest completed over time). Silent in the global config dir, in non-git directories, and once the file exists. It only inspects and nudges — it never writes a file itself. | +| `tests/hooks/` | Behavioral tests for all three hooks: a JSON payload on stdin, an exit code out, one case per rule and per past regression. Plain bash 3.2 + `jq`. CI (`.github/workflows/gate.yml`) runs them with the rest of the quality gate on every PR. | | `skills/new-repo/` | A scaffolder skill: spins up a new repo with the right `CLAUDE.md`, `.gitignore`, quality gate, and release workflow. Scaffolds **web + Android**; iOS and compute ship as rule + agent packs (no scaffolder for them yet). | | `docs/` | On-demand reference the spine points to (full roster tables, the Phase-3 review checklist, the error-recovery table, PR template, scaling notes). Installed to `~/.claude/docs/`; loaded only when a stub references it. | | `templates/` | Blank project `CLAUDE.md` templates (generic + compute) to copy into a new repo and fill in. | diff --git a/STRUCTURE.md b/STRUCTURE.md index 3c62faa..b25c5b1 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -13,7 +13,8 @@ ├── agents/ # 15 GENERIC (global default) ├── hooks/ │ ├── guard-commit.sh # PreToolUse(Bash): block AI attribution, secrets, force-push, --no-verify, non-human committer -│ └── format.sh # PostToolUse(Edit|Write): auto-format by extension, all stacks +│ ├── format.sh # PostToolUse(Edit|Write): auto-format by extension, all stacks +│ └── bootstrap-claude-md.sh # SessionStart: offer a project CLAUDE.md when a git repo has none ├── skills/new-repo/ # scaffolder └── docs/ # on-demand reference (roster tables, §4 review checklist, error-recovery table, PR template, scaling) — the spine points here @@ -28,7 +29,7 @@ Per-repo CLAUDE.md (§19 only; inherits spine + whichever rule pack your files p filled example → examples/CLAUDE.example-web.md (fictional web SaaS, shows §19 filled in) Repo-only, not installed: - tests/hooks/ # behavioral tests for both hooks (stdin JSON → exit code); run by the §19.3 gate + tests/hooks/ # behavioral tests for all three hooks (stdin JSON → exit code); run by the §19.3 gate .github/workflows/gate.yml # CI: the §19.3 gate on every PR and on push to main ``` @@ -46,6 +47,7 @@ cp -R agents-android agents-ios agents-compute ~/.claude/ # per-stack packs (t cp hooks/*.sh ~/.claude/hooks/ && chmod +x ~/.claude/hooks/*.sh cp -R skills/new-repo ~/.claude/skills/ mkdir -p ~/.claude/docs && cp -R docs/* ~/.claude/docs/ # on-demand reference the spine's ~/.claude/docs/* pointers resolve to +mkdir -p ~/.claude/templates && cp templates/*.md ~/.claude/templates/ # the §4C bootstrap policy instantiates ~/.claude/templates/CLAUDE.project.md # per repo: cp templates/CLAUDE.project.md /path/to/repo/CLAUDE.md # then fill §19 (or templates/CLAUDE.project.compute.md for C++/CUDA) diff --git a/docs/bootstrap.md b/docs/bootstrap.md new file mode 100644 index 0000000..a086587 --- /dev/null +++ b/docs/bootstrap.md @@ -0,0 +1,32 @@ +# Project Bootstrap (§4C detail) + +How to create a project `CLAUDE.md` when a repo has none. The spine (§4C) carries the rule; this is the detection and fill detail. The `hooks/bootstrap-claude-md.sh` SessionStart hook is only the trigger — it emits a one-line nudge and never writes a file. You do the offering and the writing, only after the user agrees. + +## When the hook nudges + +It stays silent unless all three hold: the session is inside a git work tree, the work-tree root is not your home directory (so it never bootstraps a dotfiles-in-`$HOME` repo), and there is no `CLAUDE.md` at the work-tree root. So it never fires in a git-tracked home, in throwaway non-git directories, or in a repo that already has guidance — including the global `~/.claude` config, which ships its own `CLAUDE.md` and so is caught by the third condition. Once the file exists, every later session is silent. + +## The offer + +Offer; don't auto-create. A repo you were told to open may be one you're only reading, a clone, or someone else's code. Ask something like: "This repo has no `CLAUDE.md` — want me to create one from your standard and fill §19 from what's here?" Create only on a yes. If the user declines, drop it for the session; the nudge is stateless and may return next session, which is fine. + +## Bare vs populated + +- **Bare** — a fresh `git init` with no package manifest and no source (nothing but `.git`, maybe a `README` or `LICENSE`). Use the `new-repo` skill: an empty repo benefits from the whole hygiene set (`.gitignore`, CI gate + release workflow, `docs/` stubs, and the `CLAUDE.md`). +- **Populated** — anything with real structure (a manifest, a source tree, existing CI). Create **only** `./CLAUDE.md` from `~/.claude/templates/CLAUDE.project.md` (or `CLAUDE.project.compute.md` for a C++/CUDA repo). Do **not** run the full `new-repo` scaffolding — a mature repo has its own `.gitignore`, CI, and docs layout, and dropping the standard ones on top is intrusive and out of scope. The one canonical template is shared with `new-repo`, so there is no second copy to drift. + +## Filling §19 from what's detectable + +Fill only what the repo actually shows; leave everything else as the template's `TODO`. Confident signals: + +- **19.2 Stack** — `package.json` (Node/TS; read `engines`, `packageManager`), `pyproject.toml`/`requirements.txt` (Python), `go.mod` (Go, with its version line), `Cargo.toml` (Rust), `build.gradle*`/`*.kt` (Android/Kotlin), `*.xcodeproj`/`Package.swift` (Swift/iOS), `CMakeLists.txt`/`*.cu` (compute). Pin versions you can read; never guess a version — a wrong pin is worse than a `TODO`. +- **19.3 Quality gate** — the scripts a contributor already runs: `package.json` `scripts` (lint/test/build/typecheck), a `Makefile`'s targets, `.github/workflows/*` steps, `pyproject`/`tox`/`noxfile` sections. Copy the real commands, in fail-fast order. +- **19.1 Description** — the `README` opening, if it states the product plainly. If the README is thin or marketing, leave the `TODO`. + +Leave `TODO` for anything not on disk: **19.4** release pointers, **19.5** compliance scope, and any stack/gate field you cannot read. These are the fields you complete over time — when a PRD, an ADR, a spec, or a fuller README later states one, fill that `TODO` then. Don't fabricate a version, a distribution channel, or a compliance scope to make the file look finished. + +## Rules + +- **Never overwrite** an existing `CLAUDE.md`; if one appears, stop. +- **Fill, don't fabricate** — a `TODO` is the correct value for an unknown fact. +- **Scope is the `CLAUDE.md`** — nothing else in the repo changes on this path (the bare-repo case delegates the rest to `new-repo`). diff --git a/hooks/bootstrap-claude-md.sh b/hooks/bootstrap-claude-md.sh new file mode 100755 index 0000000..ba56387 --- /dev/null +++ b/hooks/bootstrap-claude-md.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# SessionStart hook -- offer to bootstrap a project CLAUDE.md when the repo has none. +# +# Emits a one-line nudge (as additionalContext) telling Claude to act on the +# project-bootstrap policy (CLAUDE.md section 4C). Stays completely silent unless +# ALL of these hold: +# - the session's directory is inside a git work tree, +# - the work-tree root is not the home directory (never bootstrap a dotfiles-in-$HOME +# repo; the global ~/.claude config stays silent via the CLAUDE.md-exists check below), +# - there is no CLAUDE.md at the work-tree root. +# It never writes a file and never blocks the session: any failure just means no +# nudge (fail-open). Claude, not this hook, creates the file -- only after the user +# agrees -- so a hook that inspects but never writes keeps the "a hook touches only +# the tool's own target" rule (STRUCTURE.md). +# +# Contract: reads the SessionStart JSON payload on stdin (uses .cwd), prints either +# nothing or one hookSpecificOutput JSON object, and exits 0. Requires jq and git, +# already hard dependencies of this config's hooks. + +set -u + +# Read the payload; fall back to the current directory if it is missing or unusable. +payload=$(cat 2>/dev/null) || payload="" +cwd=$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null) || cwd="" +[ -n "$cwd" ] && [ -d "$cwd" ] || cwd=$PWD + +# Inside a git work tree? Silent no-op otherwise (throwaway dirs, non-repos). +root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 +[ -n "$root" ] || exit 0 + +# Never fire on a git-tracked home directory (project guidance does not belong at $HOME). +# $root is canonical (rev-parse resolved symlinks), so compare against both the raw and +# the canonicalized $HOME; a symlinked home path then still matches. ${HOME:-} and the cd +# fallback keep the fail-open contract: an unset or unreadable HOME just does not match. +home=${HOME:-} +canon=$(cd "$home" 2>/dev/null && pwd -P 2>/dev/null) +[ -n "$home" ] && [ "$root" = "$home" ] && exit 0 +[ -n "$canon" ] && [ "$root" = "$canon" ] && exit 0 + +# Already has project guidance? Silent. +[ -e "$root/CLAUDE.md" ] && exit 0 + +msg="This repository has no CLAUDE.md at its root. Follow the project-bootstrap policy (CLAUDE.md section 4C): offer to create one before doing other work, and create it only if the user agrees. On yes, if the repo is essentially empty use the new-repo skill; otherwise create ./CLAUDE.md from the standard template at ~/.claude/templates/CLAUDE.project.md, fill the section 19 fields you can confidently detect now (stack, quality-gate commands), and leave the rest as TODO to complete as specs appear. Never overwrite an existing CLAUDE.md." + +jq -cn --arg c "$msg" '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$c}}' +exit 0 diff --git a/settings.json b/settings.json index f5a5eb2..00c0b90 100644 --- a/settings.json +++ b/settings.json @@ -18,6 +18,9 @@ ] }, "hooks": { + "SessionStart": [ + { "matcher": "startup|resume|clear", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/bootstrap-claude-md.sh" } ] } + ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/guard-commit.sh" } ] } ], diff --git a/settings2.json b/settings2.json index 6526d96..4db4f8c 100644 --- a/settings2.json +++ b/settings2.json @@ -21,6 +21,9 @@ ] }, "hooks": { + "SessionStart": [ + { "matcher": "startup|resume|clear", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/bootstrap-claude-md.sh" } ] } + ], "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/guard-commit.sh" } ] } ], diff --git a/tests/hooks/test-bootstrap-claude-md.sh b/tests/hooks/test-bootstrap-claude-md.sh new file mode 100755 index 0000000..101350a --- /dev/null +++ b/tests/hooks/test-bootstrap-claude-md.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Behavioral tests for hooks/bootstrap-claude-md.sh: a SessionStart payload on stdin, +# always exit 0, a nudge emitted ONLY when the repo is a git work tree (root != $HOME) +# with no root CLAUDE.md. Usage: bash tests/hooks/test-bootstrap-claude-md.sh +set -u + +HERE=$(cd "$(dirname "$0")" && pwd) +HOOK="$HERE/../../hooks/bootstrap-claude-md.sh" +PASS=0 +FAIL=0 +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# check +check() { + if [ "$2" -eq 0 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); echo "FAIL: $1"; fi +} + +# feed : run the hook with a SessionStart payload naming that cwd +feed() { + jq -n --arg c "$1" '{hook_event_name:"SessionStart",source:"startup",cwd:$c}' | bash "$HOOK" 2>/dev/null +} + +# emitted : 0 when it is a valid SessionStart nudge with non-empty context +emitted() { + [ "$2" -eq 0 ] || return 1 + printf '%s' "$1" | jq -e '.hookSpecificOutput | .hookEventName == "SessionStart" and (.additionalContext | length > 0)' >/dev/null 2>&1 +} + +# silent : 0 when nothing was emitted and exit was clean +silent() { + [ "$2" -eq 0 ] && [ -z "$1" ] +} + +# Fixtures: a git repo without CLAUDE.md, one with it, and a plain (non-git) dir. +REPO="$TMP/repo"; mkdir -p "$REPO"; git init -q "$REPO" +ROOT=$(git -C "$REPO" rev-parse --show-toplevel) # canonical path (matches what the hook computes) +mkdir -p "$ROOT/sub" +REPO_OK="$TMP/repo_ok"; mkdir -p "$REPO_OK"; git init -q "$REPO_OK" +ROOT_OK=$(git -C "$REPO_OK" rev-parse --show-toplevel) +printf '# CLAUDE.md\n' >"$ROOT_OK/CLAUDE.md" +PLAIN="$TMP/plain"; mkdir -p "$PLAIN" + +# 1. git repo, no CLAUDE.md -> emits the nudge. +out=$(feed "$ROOT"); rc=$? +emitted "$out" "$rc"; check "missing CLAUDE.md in a git repo emits a nudge" $? + +# 2. CLAUDE.md present at root -> silent. +out=$(feed "$ROOT_OK"); rc=$? +silent "$out" "$rc"; check "existing CLAUDE.md is silent" $? + +# 3. non-git directory -> silent. +out=$(feed "$PLAIN"); rc=$? +silent "$out" "$rc"; check "non-git directory is silent" $? + +# 4. work-tree root is $HOME -> silent even with no CLAUDE.md (never bootstrap the home dir). +out=$(jq -n --arg c "$ROOT" '{cwd:$c}' | HOME="$ROOT" bash "$HOOK" 2>/dev/null); rc=$? +silent "$out" "$rc"; check "repo root == \$HOME is silent" $? + +# 5. cwd is a subdirectory -> resolves to the work-tree root and emits. +out=$(feed "$ROOT/sub"); rc=$? +emitted "$out" "$rc"; check "subdirectory resolves to root and emits" $? + +# 6. empty payload -> falls back to \$PWD; a git repo without CLAUDE.md still emits. +out=$( cd "$ROOT" && printf '' | bash "$HOOK" 2>/dev/null ); rc=$? +emitted "$out" "$rc"; check "empty payload falls back to \$PWD and emits" $? + +# 7. empty payload in a non-git dir -> silent (no crash on the fallback path). +out=$( cd "$PLAIN" && printf '' | bash "$HOOK" 2>/dev/null ); rc=$? +silent "$out" "$rc"; check "empty payload in a non-git dir is silent" $? + +# 8. malformed JSON payload -> falls back to \$PWD, never crashes; non-git dir stays silent. +out=$( cd "$PLAIN" && printf 'not json at all' | bash "$HOOK" 2>/dev/null ); rc=$? +silent "$out" "$rc"; check "malformed payload does not crash and stays silent" $? + +# 9. HOME unset -> fail-open (exit 0, still emits), never a set -u crash on the \$HOME guard. +out=$(jq -n --arg c "$ROOT" '{cwd:$c}' | env -u HOME bash "$HOOK" 2>/dev/null); rc=$? +emitted "$out" "$rc"; check "unset HOME fails open (no set -u crash)" $? + +echo "bootstrap: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]