Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions scripts/claude-mem-heal.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,41 @@ function Repair-ZodDep {
}
}

# BUG-017 (2026-05-21): patch hooks.json against the same EPIPE race that
# BUG-016 closed for .mcp.json. The 6 upstream hooks (Setup, SessionStart x2,
# UserPromptSubmit, PostToolUse, PreToolUse, Stop) all use the
# `{ printf; ls; printf; } | while ... break` pipe cascade. When the consumer
# 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.
function Repair-HooksJson {
param([string]$Target)

if (-not (Test-Path $Target)) {
Write-HealVerbose "no hooks.json at $Target"
return
}
$content = Get-Content $Target -Raw -ErrorAction SilentlyContinue
if (-not $content) { return }
$broken = 'break; }; done'
$fixed = '}; done | head -n1'
if (-not $content.Contains($broken)) {
Write-HealVerbose "hooks.json already healthy: $Target"
return
}
$count = ([regex]::Matches($content, [regex]::Escape($broken))).Count
$patched = $content.Replace($broken, $fixed)
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 {
param([string]$Dir)

if (-not (Test-Path $Dir -PathType Container)) { return }
Repair-McpJson -Target (Join-Path $Dir '.mcp.json')
Repair-HooksJson -Target (Join-Path $Dir 'hooks\hooks.json')
Repair-HooksJson -Target (Join-Path $Dir 'plugin\hooks\hooks.json')
Repair-ZodDep -PluginDir $Dir
}

Expand Down
22 changes: 22 additions & 0 deletions scripts/claude-mem-heal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,32 @@ heal_zod() {
log "installed missing zod dep in $plugin_dir"
}

# BUG-017 (2026-05-21): patch hooks.json against the same EPIPE race that
# BUG-016 closed for .mcp.json. The 6 upstream hooks (Setup, SessionStart x2,
# UserPromptSubmit, PostToolUse, PreToolUse, Stop) all use the
# `{ printf; ls; printf; } | while ... break` pipe cascade. When the consumer
# 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.
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
verbose "hooks.json already healthy: $target"
return 0
fi
# Use a temp file -- sed -i with portable escaping for the pipe delim.
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"
}

heal_dir() {
dir="$1"
[ -d "$dir" ] || return 0
heal_mcp_json "$dir/.mcp.json"
heal_hooks_json "$dir/hooks/hooks.json"
heal_hooks_json "$dir/plugin/hooks/hooks.json"
heal_zod "$dir"
}

Expand Down
48 changes: 48 additions & 0 deletions specs/BUG-017-claude-mem-heal-hooks-json-race/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
id: "BUG-017-claude-mem-heal-hooks-json-race"
type: spec
status: implementing
created: "2026-05-21"
tags: [spec, proposal, claude-mem, heal, cross-os-parity, pipe-race]
template_version: "1.0"
---

# BUG-017-claude-mem-heal-hooks-json-race

## Why

BUG-016 (PR #83 merged 2026-05-21) closed the EPIPE pipe-race for claude-mem's `.mcp.json`. The exact same race pattern (`{ printf; ls; printf; } | while ... break`) exists in `plugin/hooks/hooks.json` across all 6 hooks (Setup, SessionStart x2, UserPromptSubmit, PostToolUse, PreToolUse, Stop). BUG-016 explicitly deferred hooks.json with "future BUG-017 could mirror this if upstream stays unfixed". Empirically the user hit `UserPromptSubmit operation blocked by hook: printf: write error: Permission denied` again minutes after BUG-016 merged — proving the deferral was wrong: the user-visible symptom is identical, only the affected surface differs. Open this same session.

## What

Extend `claude-mem-heal.{sh,ps1}` with a new `heal_hooks_json` / `Repair-HooksJson` function that walks both `cache/<version>/hooks/hooks.json` and `marketplace/.../plugin/hooks/hooks.json` (paths Claude Code's plugin loader honours). For each, apply a minimal literal substitution: `break; }; done` -> `}; done | head -n1`. This keeps the loop running to completion, then `head -n1` consumes the first output. Loops no longer break early; producers no longer EPIPE.

Idempotent: subsequent runs skip cleanly when the broken pattern is absent (already patched).

## Out of scope

- **Full hooks.json rewrite to a canonical template.** Six distinct command tails (start, context, session-init, observation, file-context, summarize) would need to be reproduced exactly. The minimal substitution preserves each hook's tail bit-for-bit.
- **Upstream PR to thedotmack/claude-mem.** Already filed as #2607. This local heal is defense-in-depth; when upstream lands a real fix, the heal becomes a no-op (no broken pattern to detect).
- **Replacing Repair-McpJson with a similar minimal patcher.** BUG-016 already shipped the full-rewrite approach for .mcp.json; not refactoring here.

## Risks / open questions

- **Risk: idempotent re-runs still trigger heal on `/plugin update` reverts.** Mitigation: heal runs at every SessionStart via claude-session-start.{sh,ps1}; if upstream re-writes hooks.json with the broken pattern, next session re-patches it. Expected and desired behaviour.
- **Risk: substitution matches an unrelated `break; }; done` in some future hooks.json variant.** Mitigation: the literal `break; }; done` sequence is unique to the cascade-pipe pattern; doesn't appear in normal JSON content. Low collision risk.
- **Risk: PSScriptAnalyzer em-dash regression (BUG-014's CI fail).** Mitigation: ASCII-only check pre-commit; same lint rule applies.

## Acceptance criteria

- [ ] `scripts/claude-mem-heal.sh::heal_hooks_json` defined; walks `<dir>/hooks/hooks.json` and `<dir>/plugin/hooks/hooks.json`; substitutes literal `break; }; done` -> `}; done | head -n1`; idempotent.
- [ ] `scripts/claude-mem-heal.ps1::Repair-HooksJson` equivalent on Windows.
- [ ] Both heal scripts log a single message per patched file, including the count of hooks transformed (`7 hook(s)` for the canonical v13.x file).
- [ ] `tests/setup-linux.bats`: 3 new parity asserts (function defined cross-OS, literal substitution present, hooks.json walk paths present, BUG-017 reference).
- [ ] `bash -n` clean; PowerShell AST + PSScriptAnalyzer clean; ASCII-only on the `.ps1`.
- [ ] Empirical on user's Windows: 2 distinct hooks.json files (cache + marketplace-via-junction) patched on first heal run; second run silent (idempotent).

## References

- Vault: `10_projects/dotfiles/11-tasks.md` § BUG-017 entry.
- Predecessor: BUG-016 (PR #83) — same pattern fix applied to `.mcp.json`.
- Upstream: [thedotmack/claude-mem#2607](https://github.com/thedotmack/claude-mem/issues/2607) — root cause documentation; Option A (`head -n1`) is what this PR applies locally.
- Pattern: BUG-016's lesson generalised — heal scripts must patch ALL surfaces affected by an upstream bug class, not just the first surface discovered.
25 changes: 25 additions & 0 deletions specs/BUG-017-claude-mem-heal-hooks-json-race/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
tags: [spec, tasks, claude-mem, heal, cross-os-parity]
created: "2026-05-21"
---

# Tasks - BUG-017-claude-mem-heal-hooks-json-race

## Setup
- [x] Branch `fix/BUG-017-claude-mem-heal-hooks-json-race` (off main).
- [x] Spec scaffolded.
- [x] Empirical: 14 broken hook commands across 2 hooks.json files (7 cache + 7 marketplace-via-junction).

## Implementation
- [x] `heal_hooks_json` function in claude-mem-heal.sh + walk both `<dir>/hooks/` and `<dir>/plugin/hooks/`.
- [x] `Repair-HooksJson` function in claude-mem-heal.ps1; same logic.
- [x] 3 new bats parity asserts.

## Lint
- [x] `bash -n` OK; PowerShell AST + PSScriptAnalyzer clean; ASCII-only.
- [x] Empirical: first run patches 14 hooks across 2 files; second run silent.

## Closing
- [x] verification.md filled.
- [ ] PR opened referencing this spec.
- [ ] Post-merge: archive, tick vault, append lesson.
46 changes: 46 additions & 0 deletions specs/BUG-017-claude-mem-heal-hooks-json-race/verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
tags: [spec, verification, claude-mem, heal]
created: "2026-05-21"
---

# Verification - BUG-017-claude-mem-heal-hooks-json-race

## Evidence

- `scripts/claude-mem-heal.sh::heal_hooks_json` defined (lines ~127-145), walked from `heal_dir`.
- `scripts/claude-mem-heal.ps1::Repair-HooksJson` defined (lines ~173-195), walked from `Repair-PluginDir`.
- Literal substitution `break; }; done` -> `}; done | head -n1` present in both heal scripts.
- `tests/setup-linux.bats`: 3 new asserts in the BUG-017 block.

## Empirical (2026-05-21, user's Windows daily-driver, post-BUG-016 merge)

```
PS> pwsh -NoProfile -File scripts/claude-mem-heal.ps1 -VerboseOutput
[claude-mem-heal] .mcp.json already healthy: ...cache/.../13.3.0/.mcp.json
[claude-mem-heal] patched hooks.json (BUG-017, 7 hook(s) -> head -n1 race-free form): ...cache/.../13.3.0/hooks/hooks.json
[claude-mem-heal] zod present in ...
[claude-mem-heal] legacy marketplace path already present: ...
[claude-mem-heal] .mcp.json already healthy: ...marketplaces/thedotmack/plugin/.mcp.json
[claude-mem-heal] patched hooks.json (BUG-017, 7 hook(s) -> head -n1 race-free form): ...marketplaces/thedotmack/plugin/hooks/hooks.json
[claude-mem-heal] .mcp.json already healthy: ...marketplaces/thedotmack-claude-mem/plugin/.mcp.json
[claude-mem-heal] hooks.json already healthy: ...marketplaces/thedotmack-claude-mem/plugin/hooks/hooks.json (same content as thedotmack/plugin via junction)
```

- 14 hooks (7 cache + 7 marketplace) patched.
- Idempotent: second run silent.
- Post-patch `grep -c 'break; }; done' <file>` -> 0; `grep -c 'head -n1' <file>` -> 7.

## Decisions

- **Minimal substitution** over full rewrite: 6 hooks have different command tails (start, context, session-init, etc.) -- preserving them bit-for-bit is cleaner.
- **Walk both `hooks/` AND `plugin/hooks/`**: cache layout omits the `plugin/` subdir; marketplace layout includes it. Try both.
- **Same `break; }; done` literal in both .sh and .ps1**: cross-OS parity, identical patch outcome.

## Lesson candidate

"When a bug class spans multiple surfaces of an upstream system, the heal must patch ALL surfaces in the same PR. BUG-016 deferred hooks.json; BUG-017 was needed minutes later because the same user hit the same race on a different surface."

## Archive checklist
- [ ] Set status: archived post-merge.
- [ ] Move to specs/archive/.
- [ ] Tick vault entry with PR link.
28 changes: 28 additions & 0 deletions tests/setup-linux.bats
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,34 @@ setup() {
grep -qF 'claude-mem#2607' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1"
}

# --- BUG-017: hooks.json EPIPE race patch (mirror of BUG-016 for hooks) ---
# claude-mem v13.x ships plugin/hooks/hooks.json with 6 hooks all using the
# same `break; }; done` cascade-pipe pattern as the .mcp.json that BUG-016
# fixed. heal_hooks_json / Repair-HooksJson applies the minimal `head -n1`
# substitution to all hook commands in one pass.

@test "parity: both heal scripts define a hooks.json repair function (BUG-017)" {
grep -q 'heal_hooks_json' "$DOTFILES_DIR/scripts/claude-mem-heal.sh"
grep -q 'Repair-HooksJson' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1"
}

@test "parity: both heal scripts substitute break; }; done -> head -n1 (BUG-017)" {
# The substitution literal must appear in both heal scripts' source.
grep -qF 'break; }; done' "$DOTFILES_DIR/scripts/claude-mem-heal.sh"
grep -qF 'break; }; done' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1"
grep -qF 'head -n1' "$DOTFILES_DIR/scripts/claude-mem-heal.sh"
grep -qF 'head -n1' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1"
}

@test "parity: both heal scripts walk hooks.json AND plugin/hooks/hooks.json (BUG-017)" {
grep -q 'hooks/hooks\.json' "$DOTFILES_DIR/scripts/claude-mem-heal.sh"
# PowerShell uses single-backslash path separator inside single-quoted strings.
# In bash single-quote -> grep BRE, two backslashes match one literal backslash.
grep -q 'hooks\\hooks\.json' "$DOTFILES_DIR/scripts/claude-mem-heal.ps1"
grep -qF 'BUG-017' "$DOTFILES_DIR/scripts/claude-mem-heal.sh"
grep -qF 'BUG-017' "$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
Expand Down
Loading