diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa6d9c..c32b3be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha ## Unreleased +- **fix(commands): scope the microbit-enforcer SessionStart marker-clear to fresh-slate sources so a long session keeps its freezes (dogfood F1).** The `freeze`/`guard`/`careful` micro-behaviors drop `.claude/.frozen` / `.guarded` / `.careful` markers that the `microbit-enforcer.sh` PreToolUse hook reads to reject Write/Edit. The companion SessionStart hook that clears those markers shipped with **no `matcher`**, so it fired on *every* SessionStart source — including `resume` and `compact`, not just `startup`. Net effect: in a long session you `/freeze` a file, context compacts (a SessionStart with `source: compact`) or you `--resume`, and the marker is silently deleted mid-task — the enforcer stops protecting the file exactly when you'd most want it. **Fix:** the SessionStart entry in `templates/commands/microbit-enforcer/settings-patch.json` now carries `"matcher": "startup|clear"` (sources verified against `code.claude.com/docs/en/hooks-guide` — SessionStart sources are `startup`, `resume`, `clear`, `compact`, and the matcher filters on source), so markers clear only on a brand-new session or `/clear` and persist across `--resume`/`--continue` and compaction. The freeze/guard/careful `SKILL.md` lifecycle notes, the `microbit-enforcer.sh` header, and the `templates/core/CLAUDE.md` gitignore-block note are corrected to match — across both the templates and the `python-uv-fastapi` example mirror. New `test/microbit-enforcer/test-sessionstart-matcher.sh` asserts the merged settings carry the matcher. **Retrofit migration:** existing installs carry a *matcherless* SessionStart marker-clear group, and `_merge_hook_groups` keys by matcher — so without handling, `cc-configure --retrofit` would append the new `startup|clear` group beside the old one and the stale matcherless group would keep wiping markers on resume/compact, re-negating the fix on every upgrade (the same class as PR #73's standalone→bundled hook bug, for a matcher *change*). The merge now drops a stale different-matcher group whose commands are a subset of the new group's (a pure configurator group, no user additions), so the command migrates once to the new matcher; a group that also holds user hooks isn't a subset and is left untouched. New `test/retrofit-hooks/test-sessionstart-matcher-migration.sh`. Surfaced by dogfooding the configurator into a containerized project. + - **feat(preflight): `[ STACK WARNINGS ]` — flag a configured check stack that doesn't exist at the repo root (dogfood F2).** A user who keeps the default Node/pnpm intake answers (or otherwise configures `pnpm`/`uv`/`cargo`/… check commands) on a project whose manifest lives in a subdir or behind a container got **zero feedback** that the scaffolded Stop hook would never run: `stop-run-checks.sh` skips any check whose stack manifest is absent at the root (skip rule 3), so the whole typecheck/lint/test loop self-disables silently. New `check_stack_reality(target_dir, form_values)` preflight maps each configured check command's (typecheck/lint/test) first-binary — via the shlex-guarded `extract_first_binaries` — to its stack manifest (new `BINARY_TO_MANIFEST`, mirroring `manifest_for()` in `stop-run-checks.sh`) and warns when that manifest is missing from the project root, naming the subdir when it's one level down (`frontend/package.json`). When a warned binary is **also absent from the host PATH** and a `docker-compose.yml`/`Dockerfile` is present, it appends a note that the host-side format/check hooks will no-op because the toolchain lives in the container — host-PATH-gated, so a monorepo whose compose file only backs services (host toolchain present) doesn't get a false "no-op" warning. Warning-only; never blocks scaffolding; stays silent when the stack matches the root or no command maps to a known manifest (bare `tsc`/`pytest`/`ruff`). Rendered as a `[ STACK WARNINGS ]` block alongside the existing preflights. New `test/cc-manifest/test-stack-reality-preflight.sh` (matched-silent, unmappable-silent, monorepo-names-subdir, container-note-when-toolchain-off-host, container-note-suppressed-when-on-host, absent-everywhere, install-only-silent, malformed-quote-safe) + a malformed-quote case in `test-check-commands-extracted.sh`. Split out of the originally-bundled #87 per `CONTRIBUTING.md` (one logical change per PR); F1 (the SessionStart matcher fix) ships separately. Surfaced by dogfooding the configurator into a Python + FastAPI + Vite project that runs entirely via Docker. - **chore(skills): sync discipline-skills v5.1.0 → v6.0.2 (obra/superpowers).** First sync since the module landed (PR #59, 2026-05-24); upstream shipped **v6.0.0** on 2026-06-16 (major), then **v6.0.1** + **v6.0.2** on 2026-06-17 as fast-follow patch releases. Skill inventory unchanged (14/14 upstream; we still fork the same 7). **Structural change in `subagent-driven-development`**: the two per-task reviewer prompts upstream removed (`spec-reviewer-prompt.md` + `code-quality-reviewer-prompt.md`) are replaced by a single `task-reviewer-prompt.md` that returns spec-compliance + quality verdicts in one pass; two new bash scripts (`scripts/review-package` + `scripts/task-brief`) move the review diff and task text to files so they don't park in the controller's context (file-handoff, not paste-handoff). Our v5-era "embed code-reviewer.md inline in code-quality-reviewer-prompt.md" SYNC.md edit is RETIRED — the new `task-reviewer-prompt.md` has no external dependency. **Configurator change**: `configure.py`'s `executable` heuristic widened from `rel.endswith(".sh")` to also honor the source file's user-execute bit, so the new `scripts/review-package` and `scripts/task-brief` (no `.sh` extension upstream) arrive at user installs with `+x` set. Other ports are mostly clean re-applies: `using-git-worktrees` + `finishing-a-development-branch` inherit the v6 drop of the global `~/.config/superpowers/worktrees/` path (project-local `.worktrees/` only) and `finishing-a-development-branch`'s forge-neutral rewrite (no hardcoded `gh pr create`); `writing-plans` gains v6's Global Constraints + per-task Interfaces template sections; `brainstorming`'s checklist drops the "offer visual companion just-in-time" step (the v6 process-flow digraph no longer carries the visual-companion nodes either); `executing-plans` re-applies the "This skill works much better" reframe (project-neutral). `superpowers:` prefix re-stripped across all SKILL.md and `*-prompt.md` files — `test-no-superpowers-prefix.sh` clean. Path list in `config_schema.py` updated (removed 2, added 3 → 15 total); test fixtures (`test-module-files-exist.sh` + `test-scaffold-installs-skills.sh`) updated to match. SYNC.md pin bumped to v6.0.2 with a fresh delta paragraph and revised canonical-edit list (5 items → 4; the retired embed-template item documented as removed). README's discipline-skills paragraphs refreshed (v5.1.0 → v6.0.2; carve-out section + module description). Known papercut documented: `subagent-driven-development/SKILL.md` retains one "final whole-branch review" line referencing `../requesting-code-review/code-reviewer.md` (a skill we don't ship) — pre-existing in v5.1.0, not addressed in this sync to keep port scope tight. diff --git a/configure.py b/configure.py index c462da3..75deae3 100755 --- a/configure.py +++ b/configure.py @@ -1348,6 +1348,32 @@ def _clone(x): if gc not in out: # collapse exact whole-group dups (self-heal) out.append(gc) + # Matcher migration (F1 dogfood, 2026-06-26): a configurator command may + # change matcher between releases — e.g. the SessionStart marker-clear that + # shipped matcherless is now `startup|clear`. The matcher-keyed dedup below + # would then append a 2nd group and leave the stale one firing, re-negating + # the fix on `cc-configure --retrofit`. For each configurator command, + # collect the matcher(s) the NEW template places it under, then strip that + # command from any existing group whose matcher is NOT one of them (a stale + # placement). This preserves the deliberate same-command-under-multiple- + # matchers pattern (the mcp drift-check ships under both `startup` and + # `resume`) and never touches a user's own command (absent from new_groups). + new_matchers = {} + for ng in new_groups: + for c in _hook_commands(ng): + new_matchers.setdefault(c, set()).add(ng.get("matcher")) + for g in out: + gm = g.get("matcher") + hooks = g.get("hooks") + if isinstance(hooks, list): + g["hooks"] = [ + h for h in hooks + if not (isinstance(h, dict) + and h.get("command") in new_matchers + and gm not in new_matchers[h["command"]]) + ] + out = [g for g in out if g.get("hooks")] + groups_added = 0 commands_added = 0 for ng in new_groups: diff --git a/examples/python-uv-fastapi/.claude/hooks/microbit-enforcer.sh b/examples/python-uv-fastapi/.claude/hooks/microbit-enforcer.sh index 72bd02e..111bc1f 100755 --- a/examples/python-uv-fastapi/.claude/hooks/microbit-enforcer.sh +++ b/examples/python-uv-fastapi/.claude/hooks/microbit-enforcer.sh @@ -12,8 +12,9 @@ # .claude/.careful — newline-separated globs; prompt before match # # Lifecycle: a SessionStart hook (registered alongside this hook by the -# configurator's settings-patch) clears all three files. Markers are -# session-scoped, not persistent. +# configurator's settings-patch, matcher startup|clear) clears all three +# files on a fresh session or /clear. Markers are session-scoped but +# survive --resume and compaction, so a long session keeps its markers. set -euo pipefail diff --git a/examples/python-uv-fastapi/.claude/settings.json b/examples/python-uv-fastapi/.claude/settings.json index c9f8291..04d7b9c 100644 --- a/examples/python-uv-fastapi/.claude/settings.json +++ b/examples/python-uv-fastapi/.claude/settings.json @@ -130,6 +130,7 @@ ], "SessionStart": [ { + "matcher": "startup|clear", "hooks": [ { "type": "command", diff --git a/examples/python-uv-fastapi/.claude/skills/careful/SKILL.md b/examples/python-uv-fastapi/.claude/skills/careful/SKILL.md index e3150fa..5f947a8 100644 --- a/examples/python-uv-fastapi/.claude/skills/careful/SKILL.md +++ b/examples/python-uv-fastapi/.claude/skills/careful/SKILL.md @@ -42,4 +42,4 @@ Currently careful: ## Lifecycle -Careful patterns persist for the session, cleared on session start. +Careful patterns persist for the session — cleared on a fresh session (`startup`) or `/clear`, but preserved across `--resume`/`--continue` and compaction. diff --git a/examples/python-uv-fastapi/.claude/skills/freeze/SKILL.md b/examples/python-uv-fastapi/.claude/skills/freeze/SKILL.md index 55c2dbc..d374660 100644 --- a/examples/python-uv-fastapi/.claude/skills/freeze/SKILL.md +++ b/examples/python-uv-fastapi/.claude/skills/freeze/SKILL.md @@ -34,8 +34,9 @@ plan, or investigate without code changes happening. ## Notes - Frozen state is per-project (the marker is in `.claude/`). -- Frozen state does **not** persist across Claude Code session - restarts: the `SessionStart` hook clears all microbit markers - (.frozen / .guarded / .careful). +- Frozen state is session-scoped: a fresh session (`startup`) or + `/clear` clears all microbit markers (.frozen / .guarded / .careful), + but it **survives** `--resume`/`--continue` and compaction — so a long + session keeps its freeze. Run `/unfreeze` to lift it deliberately. - Read tools (Read, Grep, Glob, Bash with read-only commands) are unaffected. diff --git a/examples/python-uv-fastapi/.claude/skills/guard/SKILL.md b/examples/python-uv-fastapi/.claude/skills/guard/SKILL.md index 1acd2d3..1d1ef89 100644 --- a/examples/python-uv-fastapi/.claude/skills/guard/SKILL.md +++ b/examples/python-uv-fastapi/.claude/skills/guard/SKILL.md @@ -38,8 +38,9 @@ Currently guarded: ## Lifecycle -Guards persist for the session. The configurator clears `.guarded` on -session start (SessionStart hook). Removing a single guarded pattern +Guards persist for the session. A fresh session (`startup`) or `/clear` +clears `.guarded`; it survives `--resume`/`--continue` and compaction. +Removing a single guarded pattern mid-session isn't supported in v2.1 — re-create the guard list by clearing it (delete `.claude/.guarded`) and re-running `/guard` for the patterns you still want. diff --git a/examples/python-uv-fastapi/CLAUDE.md b/examples/python-uv-fastapi/CLAUDE.md index 498401d..44023b8 100644 --- a/examples/python-uv-fastapi/CLAUDE.md +++ b/examples/python-uv-fastapi/CLAUDE.md @@ -70,7 +70,7 @@ over pasted code. Put path-scoped detail in .claude/rules/*.md instead. The cc-configure scaffold makes assumptions about what to track vs. ignore. Honor them when first-committing a new project (`git init` + first `git add`) or when restructuring `.gitignore`: - **Commit:** `CLAUDE.md`, `.claude/` (agents, hooks, skills, rules, settings.json, output-styles), `.mcp*.json`, `claude-ctx` if present, project source. -- **Gitignore by default:** `.claude/settings.local.json` (machine-local), `.claude/logs/`, `.claude/.frozen` / `.guarded` / `.careful` (transient state cleared by SessionStart), `.claude-config.json` (configurator working-state). cc-configure appends the `# --- Claude Code ---` block to `.gitignore` automatically — if you rewrite or replace `.gitignore`, preserve that block (or rerun `cc-configure --retrofit` to re-append it). +- **Gitignore by default:** `.claude/settings.local.json` (machine-local), `.claude/logs/`, `.claude/.frozen` / `.guarded` / `.careful` (transient session state), `.claude-config.json` (configurator working-state). cc-configure appends the `# --- Claude Code ---` block to `.gitignore` automatically — if you rewrite or replace `.gitignore`, preserve that block (or rerun `cc-configure --retrofit` to re-append it). - **Nested upstream clones** (vendored deps, fork wrappers) should be gitignored and keep their own `.git/` — don't try to nest two repos in one tree. List the subdir in `.gitignore` *above* the Claude Code block. ## Design features diff --git a/templates/commands/careful/SKILL.md b/templates/commands/careful/SKILL.md index e3150fa..5f947a8 100644 --- a/templates/commands/careful/SKILL.md +++ b/templates/commands/careful/SKILL.md @@ -42,4 +42,4 @@ Currently careful: ## Lifecycle -Careful patterns persist for the session, cleared on session start. +Careful patterns persist for the session — cleared on a fresh session (`startup`) or `/clear`, but preserved across `--resume`/`--continue` and compaction. diff --git a/templates/commands/freeze/SKILL.md b/templates/commands/freeze/SKILL.md index 55c2dbc..d374660 100644 --- a/templates/commands/freeze/SKILL.md +++ b/templates/commands/freeze/SKILL.md @@ -34,8 +34,9 @@ plan, or investigate without code changes happening. ## Notes - Frozen state is per-project (the marker is in `.claude/`). -- Frozen state does **not** persist across Claude Code session - restarts: the `SessionStart` hook clears all microbit markers - (.frozen / .guarded / .careful). +- Frozen state is session-scoped: a fresh session (`startup`) or + `/clear` clears all microbit markers (.frozen / .guarded / .careful), + but it **survives** `--resume`/`--continue` and compaction — so a long + session keeps its freeze. Run `/unfreeze` to lift it deliberately. - Read tools (Read, Grep, Glob, Bash with read-only commands) are unaffected. diff --git a/templates/commands/guard/SKILL.md b/templates/commands/guard/SKILL.md index 1acd2d3..1d1ef89 100644 --- a/templates/commands/guard/SKILL.md +++ b/templates/commands/guard/SKILL.md @@ -38,8 +38,9 @@ Currently guarded: ## Lifecycle -Guards persist for the session. The configurator clears `.guarded` on -session start (SessionStart hook). Removing a single guarded pattern +Guards persist for the session. A fresh session (`startup`) or `/clear` +clears `.guarded`; it survives `--resume`/`--continue` and compaction. +Removing a single guarded pattern mid-session isn't supported in v2.1 — re-create the guard list by clearing it (delete `.claude/.guarded`) and re-running `/guard` for the patterns you still want. diff --git a/templates/commands/microbit-enforcer/microbit-enforcer.sh b/templates/commands/microbit-enforcer/microbit-enforcer.sh index 72bd02e..111bc1f 100755 --- a/templates/commands/microbit-enforcer/microbit-enforcer.sh +++ b/templates/commands/microbit-enforcer/microbit-enforcer.sh @@ -12,8 +12,9 @@ # .claude/.careful — newline-separated globs; prompt before match # # Lifecycle: a SessionStart hook (registered alongside this hook by the -# configurator's settings-patch) clears all three files. Markers are -# session-scoped, not persistent. +# configurator's settings-patch, matcher startup|clear) clears all three +# files on a fresh session or /clear. Markers are session-scoped but +# survive --resume and compaction, so a long session keeps its markers. set -euo pipefail diff --git a/templates/commands/microbit-enforcer/settings-patch.json b/templates/commands/microbit-enforcer/settings-patch.json index c325b48..e99277d 100644 --- a/templates/commands/microbit-enforcer/settings-patch.json +++ b/templates/commands/microbit-enforcer/settings-patch.json @@ -1,5 +1,5 @@ { - "//": "Auto-installed alongside the freeze/unfreeze/guard/careful microbits when commands.subset is 'full' or 'rigorous'. PreToolUse rejects Write/Edit/NotebookEdit when a marker is present; SessionStart clears markers (session-scoped lifecycle).", + "//": "Auto-installed alongside the freeze/unfreeze/guard/careful microbits when commands.subset is 'full' or 'rigorous'. PreToolUse rejects Write/Edit/NotebookEdit when a marker is present; SessionStart (matcher startup|clear) clears markers only on a fresh session or /clear — resume and compaction preserve them so a long session keeps its freezes.", "hooks": { "PreToolUse": [ { @@ -15,6 +15,7 @@ ], "SessionStart": [ { + "matcher": "startup|clear", "hooks": [ { "type": "command", diff --git a/templates/core/CLAUDE.md b/templates/core/CLAUDE.md index 9887e15..e77b5ba 100644 --- a/templates/core/CLAUDE.md +++ b/templates/core/CLAUDE.md @@ -66,7 +66,7 @@ over pasted code. Put path-scoped detail in .claude/rules/*.md instead. The cc-configure scaffold makes assumptions about what to track vs. ignore. Honor them when first-committing a new project (`git init` + first `git add`) or when restructuring `.gitignore`: - **Commit:** `CLAUDE.md`, `.claude/` (agents, hooks, skills, rules, settings.json, output-styles), `.mcp*.json`, `claude-ctx` if present, project source. -- **Gitignore by default:** `.claude/settings.local.json` (machine-local), `.claude/logs/`, `.claude/.frozen` / `.guarded` / `.careful` (transient state cleared by SessionStart), `.claude-config.json` (configurator working-state). cc-configure appends the `# --- Claude Code ---` block to `.gitignore` automatically — if you rewrite or replace `.gitignore`, preserve that block (or rerun `cc-configure --retrofit` to re-append it). +- **Gitignore by default:** `.claude/settings.local.json` (machine-local), `.claude/logs/`, `.claude/.frozen` / `.guarded` / `.careful` (transient session state), `.claude-config.json` (configurator working-state). cc-configure appends the `# --- Claude Code ---` block to `.gitignore` automatically — if you rewrite or replace `.gitignore`, preserve that block (or rerun `cc-configure --retrofit` to re-append it). - **Nested upstream clones** (vendored deps, fork wrappers) should be gitignored and keep their own `.git/` — don't try to nest two repos in one tree. List the subdir in `.gitignore` *above* the Claude Code block. ## Design features diff --git a/test/microbit-enforcer/test-sessionstart-matcher.sh b/test/microbit-enforcer/test-sessionstart-matcher.sh new file mode 100755 index 0000000..fdfcbb9 --- /dev/null +++ b/test/microbit-enforcer/test-sessionstart-matcher.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Regression (dogfood F1): the microbit-enforcer SessionStart marker-clear must +# be scoped to the fresh-slate sources (startup|clear) so that --resume and +# post-compaction SessionStart events do NOT wipe .frozen/.guarded/.careful +# mid-session. With no matcher it fires on every source incl. resume/compact, +# silently un-freezing files in long sessions (exactly when you'd want the +# freeze to hold). SessionStart sources per code.claude.com/docs/en/hooks-guide +# are: startup, resume, clear, compact — the matcher filters on source. +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import compute_merged_settings + +s = compute_merged_settings({}, {"core", "commands"}, {"commands": {"subset": "full"}}) +ss = s.get("hooks", {}).get("SessionStart", []) +# The entry that clears the discipline markers (identified by the .frozen path). +entry = next((e for e in ss + if any(".frozen" in h.get("command", "") for h in e.get("hooks", []))), None) + +assert entry is not None, f"no SessionStart marker-clear entry in commands(subset=full): {ss}" +assert entry.get("matcher") == "startup|clear", ( + "SessionStart marker-clear matcher must be 'startup|clear' (fresh-slate only, " + f"so resume/compact preserve markers); got {entry.get('matcher')!r}") + +print("PASS: microbit-enforcer SessionStart marker-clear is scoped to startup|clear (resume/compact preserve markers)") +EOF diff --git a/test/retrofit-hooks/test-sessionstart-matcher-migration.sh b/test/retrofit-hooks/test-sessionstart-matcher-migration.sh new file mode 100755 index 0000000..83347dc --- /dev/null +++ b/test/retrofit-hooks/test-sessionstart-matcher-migration.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# F1 (dogfood 2026-06-26): a prior scaffold shipped the microbit-enforcer +# SessionStart marker-clear with NO matcher; the new release ships it with +# matcher "startup|clear". _merge_hook_groups keys by matcher, so without a +# migration None != "startup|clear" appends a 2nd group and leaves the old +# matcherless one firing on every source — re-negating the freeze-loss fix on +# every `cc-configure --retrofit`. deep_merge_settings must MIGRATE the matcher +# in place (collapse to one group), while preserving any user-added hook. +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import deep_merge_settings + +CLEAR = ('rm -f "$CLAUDE_PROJECT_DIR"/.claude/.frozen ' + '"$CLAUDE_PROJECT_DIR"/.claude/.guarded ' + '"$CLAUDE_PROJECT_DIR"/.claude/.careful || true') + +# existing = prior scaffold: SessionStart marker-clear with NO matcher +existing = {"hooks": {"SessionStart": [ + {"hooks": [{"type": "command", "command": CLEAR, "timeout": 5}]}, +]}} +# new = current release: same command, now scoped to startup|clear +new = {"hooks": {"SessionStart": [ + {"matcher": "startup|clear", "hooks": [{"type": "command", "command": CLEAR, "timeout": 5}]}, +]}} + +merged, _ = deep_merge_settings(existing, new) +ss = merged["hooks"]["SessionStart"] + +assert len(ss) == 1, f"matcher migration should collapse to one SessionStart group, got {len(ss)}: {ss}" +assert ss[0].get("matcher") == "startup|clear", \ + f"surviving group must carry the startup|clear matcher; got {ss[0].get('matcher')!r}: {ss}" +n_clear = sum(1 for g in ss for h in g.get("hooks", []) if ".frozen" in h.get("command", "")) +assert n_clear == 1, f"marker-clear command should appear exactly once, got {n_clear}: {ss}" + +# A matcherless group that ALSO holds a user command is not a pure configurator +# group (not a subset), so it must be left untouched — user hook preserved. +existing2 = {"hooks": {"SessionStart": [ + {"hooks": [ + {"type": "command", "command": CLEAR, "timeout": 5}, + {"type": "command", "command": "echo my-own-hook", "timeout": 5}, + ]}, +]}} +merged2, _ = deep_merge_settings(existing2, new) +ss2 = merged2["hooks"]["SessionStart"] +assert any("my-own-hook" in h.get("command", "") for g in ss2 for h in g.get("hooks", [])), \ + f"a user's own SessionStart hook must be preserved across retrofit: {ss2}" + +# Deliberate same-command-under-multiple-matchers (the mcp drift-check ships +# under both startup and resume) must survive — both are valid placements in +# the new template, so neither is treated as stale. +DRIFT = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/sessionstart-drift-check.sh' +two = {"hooks": {"SessionStart": [ + {"matcher": "startup", "hooks": [{"type": "command", "command": DRIFT}]}, + {"matcher": "resume", "hooks": [{"type": "command", "command": DRIFT}]}, +]}} +merged3, _ = deep_merge_settings(two, two) +matchers3 = sorted(g.get("matcher") for g in merged3["hooks"]["SessionStart"]) +assert matchers3 == ["resume", "startup"], \ + f"multi-matcher drift-check must keep both placements: {merged3['hooks']['SessionStart']}" + +print("PASS: matcherless SessionStart marker-clear migrates to startup|clear on retrofit (no ghost group); user hooks + deliberate multi-matcher groups preserved") +EOF