diff --git a/scripts/claude-mem-heal.ps1 b/scripts/claude-mem-heal.ps1 index 4cbe875..68f393b 100644 --- a/scripts/claude-mem-heal.ps1 +++ b/scripts/claude-mem-heal.ps1 @@ -207,16 +207,39 @@ function Repair-HooksJson { } $content = Get-Content $Target -Raw -ErrorAction SilentlyContinue if (-not $content) { return } - $broken = 'break; }; done' - $fixed = '}; done | head -n1' - if (-not $content.Contains($broken)) { + + $broken017 = 'break; }; done' + $fixed017 = '}; done | head -n1' + # BUG-018: ALL hooks that terminate with `hook claude-code "` lack + # Claude Code's {"continue":true,"suppressOutput":true} directive. The + # bun-runner empty-stdin diagnostic (upstream claude-mem#2188) goes to + # stdout and Claude Code blocks the operation. User empirically hit + # UserPromptSubmit first and Stop minutes later -- regex captures all + # 5 (session-init / context / observation / file-context / summarize). + # Setup hook's version-check.js terminator is left untouched -- not on + # the user hot path (fires only on plugin install/update). + $pattern018 = 'hook claude-code ([a-z][a-z-]*)"' + $replacement018 = 'hook claude-code $1 2>/dev/null; echo ''{\"continue\":true,\"suppressOutput\":true}''"' + + $has017 = $content.Contains($broken017) + $has018 = $content -match $pattern018 + if (-not $has017 -and -not $has018) { Write-HealVerbose "hooks.json already healthy: $Target" return } - $count = ([regex]::Matches($content, [regex]::Escape($broken))).Count - $patched = $content.Replace($broken, $fixed) + + $patched = $content + if ($has017) { + $count017 = ([regex]::Matches($patched, [regex]::Escape($broken017))).Count + $patched = $patched.Replace($broken017, $fixed017) + Write-HealLog "patched hooks.json (BUG-017, $count017 hook(s) -> head -n1 race-free form): $Target" + } + if ($has018) { + $count018 = ([regex]::Matches($patched, $pattern018)).Count + $patched = $patched -replace $pattern018, $replacement018 + Write-HealLog "patched hooks.json ($count018 hook(s) -> BUG-018 continue directive): $Target" + } Set-Content -Path $Target -Value $patched -Encoding UTF8 -NoNewline - Write-HealLog "patched hooks.json (BUG-017, $count hook(s) -> head -n1 race-free form): $Target" } function Repair-PluginDir { diff --git a/scripts/claude-mem-heal.sh b/scripts/claude-mem-heal.sh index de59bf3..34f9dc7 100755 --- a/scripts/claude-mem-heal.sh +++ b/scripts/claude-mem-heal.sh @@ -148,17 +148,43 @@ heal_zod() { # breaks early, unconsumed producer writes EPIPE on Git Bash Windows. # Minimal substitution: `break; }; done` -> `}; done | head -n1` keeps the # loop running to completion, then head takes the first printed match. +# +# BUG-018 (2026-05-21): after BUG-017 closed the EPIPE race, the +# UserPromptSubmit hook STILL blocked because its command terminates with +# `node ... hook claude-code session-init` and does NOT emit Claude Code's +# `{"continue":true,"suppressOutput":true}` directive. bun-runner.js's +# empty-stdin diagnostic (upstream claude-mem#2188) goes to stdout, Claude +# Code reads it as non-continue, blocks the prompt. Heal appends the +# directive in the same pass. heal_hooks_json() { target="$1" [ -f "$target" ] || { verbose "no hooks.json at $target"; return 0; } - if ! grep -qF 'break; }; done' "$target" 2>/dev/null; then + # Detect either BUG-017 signature (break; }; done) or BUG-018 signature + # (UserPromptSubmit terminator without the continue directive). + has_017=$(grep -cF 'break; }; done' "$target" 2>/dev/null || echo 0) + has_018=$(grep -cE 'session-init"$|session-init"[^}]*$' "$target" 2>/dev/null | head -1) + has_018=${has_018:-0} + # Simpler: also check explicitly for the un-patched session-init terminator. + if ! grep -qF 'break; }; done' "$target" && \ + ! grep -qF 'session-init"' "$target"; then verbose "hooks.json already healthy: $target" return 0 fi - # Use a temp file -- sed -i with portable escaping for the pipe delim. + # Use `#` as sed delimiter -- `|` appears literally in the replacement + # (`done | head -n1`) and would confuse `s|...|...|g`. + # + # BUG-018 substitution is GENERIC across all 5 `hook claude-code "` + # terminators (UserPromptSubmit/session-init, SessionStart/context, + # PostToolUse/observation, PreToolUse/file-context, Stop/summarize). + # The user empirically hit UserPromptSubmit first (BUG-018 narrow scope) + # then Stop minutes later (originally deferred as BUG-018b -- now folded + # in via regex capture). Setup hook (`version-check.js`) is left untouched: + # it only fires on plugin install/update, not user hot path. tmp="$target.tmp.$$" - sed 's|break; }; done|}; done | head -n1|g' "$target" > "$tmp" && mv "$tmp" "$target" - log "patched hooks.json (BUG-017, head -n1 race-free form): $target" + sed -e 's#break; }; done#}; done | head -n1#g' \ + -e 's#hook claude-code \([a-z][a-z-]*\)"#hook claude-code \1 2>/dev/null; echo '\''{\\"continue\\":true,\\"suppressOutput\\":true}'\''"#g' \ + "$target" > "$tmp" && mv "$tmp" "$target" + log "patched hooks.json (BUG-017 head-n1 + BUG-018 continue-directive on all 5 hooks): $target" } heal_dir() { diff --git a/specs/BUG-018-userpromptsubmit-continue-directive/proposal.md b/specs/BUG-018-userpromptsubmit-continue-directive/proposal.md new file mode 100644 index 0000000..6d32d7b --- /dev/null +++ b/specs/BUG-018-userpromptsubmit-continue-directive/proposal.md @@ -0,0 +1,46 @@ +--- +id: "BUG-018-userpromptsubmit-continue-directive" +type: spec +status: implementing +created: "2026-05-21" +tags: [spec, proposal, claude-mem, heal, cross-os-parity, hook-protocol] +template_version: "1.0" +--- + +# BUG-018-userpromptsubmit-continue-directive + +## Why + +After BUG-017 (PR #84) closed the EPIPE race for claude-mem hooks, the user immediately hit a SECOND blocker. Error message changed (no more `printf: write error`), proving BUG-017 landed, but `UserPromptSubmit operation blocked by hook ... No stderr output`. Minutes later, after a narrow BUG-018 manual patch (only session-init), the Stop hook also failed 9 consecutive times forcing Claude Code's `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` to override. + +Root cause: claude-mem ships 6 hooks. 5 terminate with `node ... hook claude-code ` and lack Claude Code's `{"continue":true,"suppressOutput":true}` directive. bun-runner.js writes diagnostic stdout when stdin is empty (upstream claude-mem#2188); Claude Code reads non-JSON-directive stdout as "do not continue" -> blocks. SessionStart-start is the only hook already emitting the directive. + +## What + +Extend `heal_hooks_json` / `Repair-HooksJson` with a regex-based substitution that finds any `hook claude-code "` terminator and appends ` 2>/dev/null; echo '{"continue":true,"suppressOutput":true}'` before the closing JSON quote. Same heal pass as BUG-017 (one sed/Replace per pattern). All 5 affected hooks get the directive in one pass. + +Idempotent: subsequent runs see no broken terminator left -> silent skip. + +## Out of scope + +- **Setup hook (`node version-check.js`).** Different terminator pattern; fires only on plugin install/update, not user hot path. Future BUG-018b if it surfaces. +- **AST-level JSON manipulation.** Pure string substitution is sufficient. + +## Risks / open questions + +- **Risk: regex over-matches in future hooks.json variants.** Mitigation: regex constrained to `[a-z][a-z-]*` for event name -- excludes Setup's `version-check.js` and capitalized identifiers. +- **Risk: `2>/dev/null` silences legitimate stderr.** Mitigation: bun-runner writes to stdout (Claude Code's complaint is "No stderr output"); silencing stderr is harmless. + +## Acceptance criteria + +- [ ] Both heal scripts contain regex substitution covering all 5 hook terminators. +- [ ] Both heal scripts log `BUG-018` and reference `continue` in the replacement. +- [ ] `tests/setup-linux.bats`: 1 new parity assert. +- [ ] `bash -n` + PowerShell AST + PSScriptAnalyzer clean; ASCII-only. +- [ ] Empirical: user's hooks.json patched on all 5 hooks; prompts complete without loop. + +## References + +- Vault: `10_projects/dotfiles/11-tasks.md` BUG-018 entry. +- Predecessor: BUG-017 (PR #84) -- EPIPE race fix. +- Upstream: [thedotmack/claude-mem#2607](https://github.com/thedotmack/claude-mem/issues/2607) (cascade race) + claude-mem#2188 (hook protocol mismatch). diff --git a/specs/BUG-018-userpromptsubmit-continue-directive/tasks.md b/specs/BUG-018-userpromptsubmit-continue-directive/tasks.md new file mode 100644 index 0000000..538bb8e --- /dev/null +++ b/specs/BUG-018-userpromptsubmit-continue-directive/tasks.md @@ -0,0 +1,24 @@ +--- +tags: [spec, tasks, claude-mem, heal] +created: "2026-05-21" +--- + +# Tasks - BUG-018-userpromptsubmit-continue-directive + +## Setup +- [x] Branch off main (post BUG-017 merge). +- [x] Spec scaffolded (used -ForceNoVault; vault entry uses BUG-018-claude-mem-userpromptsubmit-continue-directive slug). + +## Implementation +- [x] Regex substitution in claude-mem-heal.sh (sed -e). +- [x] Regex replace in claude-mem-heal.ps1 (-replace). +- [x] tests/setup-linux.bats parity assert. + +## Lint +- [x] bash -n OK; PowerShell AST clean; PSSA clean; ASCII-only. +- [x] Empirical: 5 hooks patched on user's Windows; loop resolved. + +## Closing +- [x] verification.md filled. +- [ ] CI green on PR #85 after spec scaffold. +- [ ] Post-merge: archive, tick vault, append lesson. diff --git a/specs/BUG-018-userpromptsubmit-continue-directive/verification.md b/specs/BUG-018-userpromptsubmit-continue-directive/verification.md new file mode 100644 index 0000000..709ad82 --- /dev/null +++ b/specs/BUG-018-userpromptsubmit-continue-directive/verification.md @@ -0,0 +1,46 @@ +--- +tags: [spec, verification, claude-mem, heal] +created: "2026-05-21" +--- + +# Verification - BUG-018-userpromptsubmit-continue-directive + +## Evidence + +- claude-mem-heal.sh::heal_hooks_json -- sed with regex capture for any `hook claude-code "`. +- claude-mem-heal.ps1::Repair-HooksJson -- PowerShell `-replace` with equivalent regex; reports counts. +- tests/setup-linux.bats BUG-018 parity block. + +## Empirical (2026-05-21) + +1. BUG-016 merged: .mcp.json fixed. +2. BUG-017 merged: hooks.json cascade race fixed; error changed from `printf: write error` to `No stderr output`. +3. Narrow BUG-018 manual patch (session-init only): UserPromptSubmit works (ping/pong) but Stop fails 9 times. +4. Regex BUG-018 patch (this commit): all 5 hooks patched; loop resolved. + +Post-patch grep on user's hooks.json: +- `continue":true` count: 6 (5 patched + 1 pre-existing in SessionStart-start) +- Unpatched `hook claude-code "` terminators: 0 + +## Lint + +- bash -n OK +- PowerShell AST parse clean +- PSScriptAnalyzer clean +- ASCII-only zero non-ASCII + +## Decisions + +- **Regex capture over per-event substitutions:** maintainable if upstream adds a 6th event. +- **Setup hook left untouched:** different terminator, not on user hot path. +- **`2>/dev/null` to silence stderr:** harmless since bun-runner writes to stdout. + +## Lesson candidate + +"When a bug class affects N callsites of an upstream system, the heal must patch ALL N in the same PR. Each deferral cost ~30 min of user pain today: BUG-016 (.mcp.json) -> deferred hooks.json (BUG-017) -> deferred continue-directive (BUG-018 narrow) -> all 5 hooks (BUG-018 extended). Be proactive, not reactive." + +## Archive checklist +- [ ] status: archived post-merge +- [ ] move to specs/archive/ +- [ ] tick vault entry with PR link +- [ ] append lesson to 90-lessons.md diff --git a/tests/setup-linux.bats b/tests/setup-linux.bats index 67ad864..d317eeb 100644 --- a/tests/setup-linux.bats +++ b/tests/setup-linux.bats @@ -230,6 +230,28 @@ setup() { grep -qF 'BUG-017' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" } +# --- BUG-018: ALL 5 `hook claude-code ` hooks missing continue directive --- +# After BUG-017 closed the EPIPE race, every claude-mem hook that terminates +# with `node ... hook claude-code "` blocks Claude Code because the +# stdout output is not a {"continue":true} directive (upstream bun-runner +# stdout vs Claude Code hook protocol mismatch, claude-mem#2188). +# The regex-based substitution catches all 5 in one pass: session-init +# (UserPromptSubmit), context (SessionStart), observation (PostToolUse), +# file-context (PreToolUse), summarize (Stop). + +@test "parity: both heal scripts append continue directive to ALL hook claude-code terminators (BUG-018)" { + # The substitution must use a regex capture so all 5 event terminators get + # the same treatment in one pass. + grep -q 'hook claude-code' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -q 'hook claude-code' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" + # The replacement must contain the {"continue":true} directive (literal in + # both heal scripts, with JSON-escaped quotes \"). + grep -qF 'continue' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -qF 'continue' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" + grep -qF 'BUG-018' "$DOTFILES_DIR/scripts/claude-mem-heal.sh" + grep -qF 'BUG-018' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1" +} + # --- BUG-012: legacy marketplace junction/symlink for plugin discovery --- # Claude Code clones the claude-mem marketplace under the GitHub repo name # `thedotmack-claude-mem/`, but the plugin's bundled hooks.json hardcodes