From c5b03032f760e098a1d9e782dae39629913e3038 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:39:00 +0000 Subject: [PATCH 1/3] fix(meridian): rewrite hooks in Node for cross-platform reliability, bump to 0.10.4 The bash hooks crashed on macOS with "unexpected EOF while looking for matching '". macOS ships bash 3.2, which misparses here-documents nested in $(...) command substitution; the apostrophes in the orientation/audit text ("user's", "doesn't") then read as an unterminated single quote. Machines with a newer Homebrew bash first on PATH were unaffected, which is why it reproduced on one laptop but not another. Rewrite all three hooks (SessionStart, UserPromptSubmit, SessionEnd) in Node and switch hooks.json to exec form (command: node, args: [path]). Claude Code already requires Node, so the hooks now run identically on macOS, Linux, and Windows with no shell involved -- eliminating the bash-version trap, the BSD-vs-GNU sed/awk differences (BSD sed treated s/\r//g as "delete every r", silently corrupting injected context), the bashism set -o pipefail, and the hand-rolled JSON escaping. JSON.parse and JSON.stringify now do all parsing and encoding. The two large prompt texts move into hooks/context/*.md and are read at runtime, so they need no shell/JSON escaping and are editable as plain Markdown. session_id validation (it feeds rm -rf / mkdir paths) is centralized in lib.mjs and still rejects anything non-UUID-shaped. --- .gitattributes | 7 ++- plugins/meridian/.claude-plugin/plugin.json | 2 +- .../{session-start => context/orientation.md} | 42 ---------------- .../meridian/hooks/context/routing-audit.md | 1 + plugins/meridian/hooks/hooks.json | 9 ++-- plugins/meridian/hooks/lib.mjs | 48 +++++++++++++++++++ plugins/meridian/hooks/session-end | 17 ------- plugins/meridian/hooks/session-end.mjs | 10 ++++ plugins/meridian/hooks/session-start.mjs | 41 ++++++++++++++++ plugins/meridian/hooks/user-prompt-submit | 40 ---------------- plugins/meridian/hooks/user-prompt-submit.mjs | 40 ++++++++++++++++ 11 files changed, 150 insertions(+), 107 deletions(-) rename plugins/meridian/hooks/{session-start => context/orientation.md} (64%) mode change 100755 => 100644 create mode 100644 plugins/meridian/hooks/context/routing-audit.md create mode 100644 plugins/meridian/hooks/lib.mjs delete mode 100755 plugins/meridian/hooks/session-end create mode 100644 plugins/meridian/hooks/session-end.mjs create mode 100644 plugins/meridian/hooks/session-start.mjs delete mode 100755 plugins/meridian/hooks/user-prompt-submit create mode 100644 plugins/meridian/hooks/user-prompt-submit.mjs diff --git a/.gitattributes b/.gitattributes index 1411ae5..1c6765b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,10 +5,9 @@ *.md text eol=lf *.json text eol=lf -# Plugin hook scripts have no extension and must stay LF — bash refuses -# to execute scripts with CRLF line endings (the \r in the shebang line -# becomes part of the interpreter path). -plugins/*/hooks/* text eol=lf +# Plugin hooks (Node scripts + their injected context) must stay LF so CRLF +# never leaks a stray \r into emitted additionalContext on Windows checkouts. +plugins/*/hooks/** text eol=lf # License LICENSE text eol=lf diff --git a/plugins/meridian/.claude-plugin/plugin.json b/plugins/meridian/.claude-plugin/plugin.json index 35c030e..2b7413c 100644 --- a/plugins/meridian/.claude-plugin/plugin.json +++ b/plugins/meridian/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "meridian", "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.10.3", + "version": "0.10.4", "author": { "name": "KodingDev" }, diff --git a/plugins/meridian/hooks/session-start b/plugins/meridian/hooks/context/orientation.md old mode 100755 new mode 100644 similarity index 64% rename from plugins/meridian/hooks/session-start rename to plugins/meridian/hooks/context/orientation.md index 36fe39a..d05d3e6 --- a/plugins/meridian/hooks/session-start +++ b/plugins/meridian/hooks/context/orientation.md @@ -1,37 +1,3 @@ -#!/usr/bin/env bash -# Note: -e omitted so a single bad subdir during prune doesn't suppress the orientation emit. -set -uo pipefail - -input=$(cat 2>/dev/null || true) -# Pure-bash JSON extraction so the hook works on Windows without jq. Collapse newlines -# to spaces so the regex sees the whole object, then require [{,] before the key to -# avoid matching keys like `previous_session_id`. Empty result is fine — falls through -# to "no current-session protection" in the prune loop, which is acceptable since the -# orientation context still emits unconditionally below. -session_id=$(printf '%s' "$input" | tr '\n' ' ' | sed -nE 's/.*[{,][[:space:]]*"session_id"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p') -# Defense in depth: if session_id is malformed, treat it as unset for the skip-current check. -case "${session_id:-}" in *[!a-zA-Z0-9_-]*) session_id="" ;; esac - -state_root="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/meridian/state" -if [ -d "$state_root" ]; then - for dir in "$state_root"/*/; do - [ -d "$dir" ] || continue - name=$(basename "$dir") - # Never prune the current session's dir - [ -n "${session_id:-}" ] && [ "$name" = "$session_id" ] && continue - # Prune if directory mtime is older than 7 days. user-prompt-submit touches - # the dir on every prompt, so mtime tracks last-activity, not creation. - if find "$dir" -maxdepth 0 -type d -mtime +7 -print 2>/dev/null | grep -q .; then - rm -rf "$dir" - fi - done -fi - -# Inject orientation via JSON additionalContext rather than plain stdout. Plain stdout -# renders as transcript output and reads like a user-issued directive ("you MUST invoke ..."). -# additionalContext is wrapped in a discreet system reminder, absorbed silently on the -# next model turn — the orientation is felt, not announced. -context=$(cat <<'CONTEXT' [Meridian orientation] Meridian is active. The principles in your system prompt apply across the conversation; this note orients you on routing decisions and active behaviors for the current session. @@ -75,11 +41,3 @@ Auto activates implicitly when the user's message contains a stepping-away signa ## When uncertain Invoke `meridian:meridian` via the Skill tool for the full routing reference and pillar text. The orientation above is the working subset. -CONTEXT -) - -# JSON-encode the context: \ -> \\, " -> \", tab -> \t, strip CR, newlines -> \n. -# Portable across BSD/GNU sed and awk; no jq dependency. Strip CR rather than escape -# so editor-introduced CRLF endings normalize to LF before the awk newline pass. -encoded=$(printf '%s' "$context" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' -e 's/\r//g' | awk 'BEGIN{ORS="\\n"} {print}') -printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}\n' "$encoded" diff --git a/plugins/meridian/hooks/context/routing-audit.md b/plugins/meridian/hooks/context/routing-audit.md new file mode 100644 index 0000000..511ed4d --- /dev/null +++ b/plugins/meridian/hooks/context/routing-audit.md @@ -0,0 +1 @@ +[Meridian routing audit] Several prompts have elapsed since the last orientation pass. Quietly verify the active skill (if any) still matches the user's most recent intent and that no new external-system claim has come into scope that should trigger the `triangulate` lens. If intent has clearly shifted, re-classify against the routing table; otherwise continue. Do not surface this audit in your reply. diff --git a/plugins/meridian/hooks/hooks.json b/plugins/meridian/hooks/hooks.json index 3f8e54a..8977a7f 100644 --- a/plugins/meridian/hooks/hooks.json +++ b/plugins/meridian/hooks/hooks.json @@ -6,7 +6,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-start\"", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"], "async": false } ] @@ -18,7 +19,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit\"", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"], "async": false } ] @@ -30,7 +32,8 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-end\"", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/session-end.mjs"], "async": false } ] diff --git a/plugins/meridian/hooks/lib.mjs b/plugins/meridian/hooks/lib.mjs new file mode 100644 index 0000000..b19b7b7 --- /dev/null +++ b/plugins/meridian/hooks/lib.mjs @@ -0,0 +1,48 @@ +// Shared helpers for Meridian's hooks. Written in Node (invoked via exec form in +// hooks.json) so the hooks behave identically on macOS, Linux, and Windows +// without depending on bash version, BSD-vs-GNU sed/awk, jq, or shell quoting. + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Read the hook event payload from stdin. Returns {} on empty or invalid input +// so a malformed payload degrades to a no-op instead of crashing the session. +export function readHookInput() { + let raw = ""; + try { + raw = readFileSync(0, "utf8"); + } catch { + return {}; + } + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +// session_id flows into rm -rf / mkdir paths, so accept only the UUID-shaped +// values Claude Code actually issues. Anything else (or a missing/non-string +// value) returns null and the caller treats the session as absent. +export function safeSessionId(input) { + const id = input?.session_id; + return typeof id === "string" && /^[A-Za-z0-9_-]+$/.test(id) ? id : null; +} + +export function stateRoot() { + const base = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + return join(base, "meridian", "state"); +} + +export function sessionDir(sessionId) { + return join(stateRoot(), sessionId); +} + +// Emit additionalContext as a JSON object on stdout. JSON.stringify handles all +// escaping, replacing the hand-rolled sed/awk encoder that silently corrupted +// content under BSD sed on macOS. +export function emitContext(hookEventName, text) { + const payload = { hookSpecificOutput: { hookEventName, additionalContext: text } }; + process.stdout.write(JSON.stringify(payload) + "\n"); +} diff --git a/plugins/meridian/hooks/session-end b/plugins/meridian/hooks/session-end deleted file mode 100755 index e8e3cd4..0000000 --- a/plugins/meridian/hooks/session-end +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -input=$(cat) -# Pure-bash JSON extraction so the hook works on Windows without jq. Collapse newlines -# to spaces so the regex sees the whole object, then require [{,] before the key to -# avoid matching keys like `previous_session_id`. The case-allowlist below validates -# the extracted value before it flows into rm -rf. -session_id=$(printf '%s' "$input" | tr '\n' ' ' | sed -nE 's/.*[{,][[:space:]]*"session_id"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p') -[ -z "${session_id:-}" ] && exit 0 -# Defense in depth: session_id is about to flow into rm -rf. Reject anything that isn't -# UUID-shaped (alphanumeric, hyphen, underscore only) to prevent path traversal. -case "$session_id" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac - -state_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/meridian/state/${session_id}" -[ -d "$state_dir" ] && rm -rf "$state_dir" -exit 0 diff --git a/plugins/meridian/hooks/session-end.mjs b/plugins/meridian/hooks/session-end.mjs new file mode 100644 index 0000000..41dbf27 --- /dev/null +++ b/plugins/meridian/hooks/session-end.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { rmSync } from "node:fs"; +import { readHookInput, safeSessionId, sessionDir } from "./lib.mjs"; + +const sessionId = safeSessionId(readHookInput()); +if (!sessionId) process.exit(0); + +// safeSessionId guarantees a UUID-shaped name, so this rm -rf can only ever +// target a dir directly under the Meridian state root. +rmSync(sessionDir(sessionId), { recursive: true, force: true }); diff --git a/plugins/meridian/hooks/session-start.mjs b/plugins/meridian/hooks/session-start.mjs new file mode 100644 index 0000000..4585760 --- /dev/null +++ b/plugins/meridian/hooks/session-start.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import { readdirSync, statSync, rmSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readHookInput, safeSessionId, stateRoot, emitContext } from "./lib.mjs"; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const here = dirname(fileURLToPath(import.meta.url)); + +const currentSession = safeSessionId(readHookInput()); + +// Prune session-state dirs untouched for 7+ days. user-prompt-submit bumps each +// dir's mtime on every prompt, so mtime tracks last-activity, not creation. A +// failure on any single dir must not block the orientation emit below. +let entries = []; +try { + entries = readdirSync(stateRoot(), { withFileTypes: true }); +} catch { + entries = []; +} +for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (currentSession && entry.name === currentSession) continue; + const dir = join(stateRoot(), entry.name); + try { + if (Date.now() - statSync(dir).mtimeMs > SEVEN_DAYS_MS) { + rmSync(dir, { recursive: true, force: true }); + } + } catch { + // Skip a single unreadable/locked dir; keep pruning the rest. + } +} + +// Inject orientation as additionalContext (a discreet system reminder) rather +// than stdout transcript text, which reads like a user-issued directive. +try { + const orientation = readFileSync(join(here, "context", "orientation.md"), "utf8"); + emitContext("SessionStart", orientation.trimEnd()); +} catch { + // If the content file is missing, stay silent rather than crash the session. +} diff --git a/plugins/meridian/hooks/user-prompt-submit b/plugins/meridian/hooks/user-prompt-submit deleted file mode 100755 index 959d891..0000000 --- a/plugins/meridian/hooks/user-prompt-submit +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -input=$(cat) -# Pure-bash JSON extraction so the hook works on Windows without jq. Collapse newlines -# to spaces so the regex sees the whole object, then require [{,] before the key to -# avoid matching keys like `previous_session_id`. The case-allowlist below validates -# the extracted value before it flows into any path operation. -session_id=$(printf '%s' "$input" | tr '\n' ' ' | sed -nE 's/.*[{,][[:space:]]*"session_id"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p') -[ -z "${session_id:-}" ] && exit 0 -# Defense in depth: session_id flows into rm -rf paths in session-end and into mkdir -p here. -# Reject anything that isn't UUID-shaped (alphanumeric, hyphen, underscore only). -case "$session_id" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac - -state_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/meridian/state/${session_id}" -tick_file="${state_dir}/router-tick" -mkdir -p "$state_dir" -# Touch the dir on every prompt so SessionStart's mtime-based prune reflects last-activity, -# not session-creation. Without this, a long-running session could be pruned at the 7-day mark. -touch "$state_dir" - -tick=0 -[ -f "$tick_file" ] && tick=$(cat "$tick_file" 2>/dev/null || echo 0) -case "$tick" in ''|*[!0-9]*) tick=0 ;; esac -tick=$((tick + 1)) -printf '%d\n' "$tick" > "$tick_file" - -if [ "$tick" -gt 0 ] && [ $((tick % 8)) -eq 0 ]; then - # Discreet routing audit via additionalContext. Previous versions emitted this on plain - # stdout where it rendered as transcript output and read like a fresh user directive - # ("re-invoke meridian:meridian"), prompting the model to defensively explain why it - # was already routed correctly. As additionalContext it lands as a passive system - # reminder — the model absorbs it and only acts if intent has actually shifted. - context=$(cat <<'CONTEXT' -[Meridian routing audit] Several prompts have elapsed since the last orientation pass. Quietly verify the active skill (if any) still matches the user's most recent intent and that no new external-system claim has come into scope that should trigger the `triangulate` lens. If intent has clearly shifted, re-classify against the routing table; otherwise continue. Do not surface this audit in your reply. -CONTEXT -) - encoded=$(printf '%s' "$context" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' -e 's/\r//g' | awk 'BEGIN{ORS="\\n"} {print}') - printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$encoded" -fi diff --git a/plugins/meridian/hooks/user-prompt-submit.mjs b/plugins/meridian/hooks/user-prompt-submit.mjs new file mode 100644 index 0000000..a8b9978 --- /dev/null +++ b/plugins/meridian/hooks/user-prompt-submit.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { mkdirSync, utimesSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readHookInput, safeSessionId, sessionDir, emitContext } from "./lib.mjs"; + +const AUDIT_EVERY = 8; +const here = dirname(fileURLToPath(import.meta.url)); + +const sessionId = safeSessionId(readHookInput()); +if (!sessionId) process.exit(0); + +const dir = sessionDir(sessionId); +const tickFile = join(dir, "router-tick"); + +mkdirSync(dir, { recursive: true }); +// Bump dir mtime on every prompt so SessionStart's prune reflects last-activity. +const now = new Date(); +try { + utimesSync(dir, now, now); +} catch {} + +let tick = 0; +try { + const raw = readFileSync(tickFile, "utf8").trim(); + if (/^\d+$/.test(raw)) tick = parseInt(raw, 10); +} catch {} +tick += 1; +try { + writeFileSync(tickFile, tick + "\n"); +} catch {} + +// Every Nth prompt, emit a discreet routing audit so the model re-checks that +// the active skill still matches intent. Absorbed silently as additionalContext. +if (tick % AUDIT_EVERY === 0) { + try { + const audit = readFileSync(join(here, "context", "routing-audit.md"), "utf8"); + emitContext("UserPromptSubmit", audit.trimEnd()); + } catch {} +} From 348391e5635761785a597543df36f5c485c0f386 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 00:45:00 +0000 Subject: [PATCH 2/3] ci: test hooks across Linux/macOS/Windows and validate manifests Add a GitHub Actions matrix (ubuntu, macos, windows) that runs the hooks the way Claude Code's exec form does -- node with the event JSON on stdin -- via a zero-dependency node:test harness, and runs `claude plugin validate --strict` on the marketplace and both plugin manifests. This is the regression guard for the bash 3.2 crash: the original failure was macOS-only, so the OS matrix is the point. The harness asserts the behaviors that were fragile before the Node rewrite: orientation/audit emit as valid JSON, the em-dash and the "user's" apostrophe survive the round trip, the every-8th-prompt audit cadence, 7-day prune, session cleanup, and rejection of unsafe session_id values before they reach the filesystem. --strict validation immediately caught real drift: marketplace.json still pinned meridian at 0.10.3 while plugin.json had moved to 0.10.4. Sync the marketplace entry to 0.10.4. --- .claude-plugin/marketplace.json | 2 +- .github/workflows/ci.yml | 44 +++++++++ test/meridian-hooks.test.mjs | 159 ++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/meridian-hooks.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5d61a54..cf02a40 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "meridian", "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.10.3", + "version": "0.10.4", "source": "./plugins/meridian", "category": "development", "homepage": "https://github.com/KodingDev/claude-plugins" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bac28ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + env: + # Keep the CLI offline-friendly in CI: no autoupdate / telemetry / error reporting. + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" + defaults: + run: + # Windows runners ship Git Bash; using it everywhere gives -eo pipefail + # semantics on all three OS so a failing command reliably fails the step. + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Validate manifests + run: | + claude plugin validate --strict . + claude plugin validate --strict plugins/meridian + claude plugin validate --strict plugins/almanac + + - name: Test hooks + run: node --test test/meridian-hooks.test.mjs diff --git a/test/meridian-hooks.test.mjs b/test/meridian-hooks.test.mjs new file mode 100644 index 0000000..ba317e5 --- /dev/null +++ b/test/meridian-hooks.test.mjs @@ -0,0 +1,159 @@ +// Cross-platform regression tests for Meridian's hooks. These run the hook +// scripts exactly as Claude Code's exec form does -- `node ` with the +// event payload on stdin -- and assert on stdout/exit code/filesystem effects. +// Built on node:test so they need no dependencies and run identically on +// Linux, macOS, and Windows (the OS matrix that the original bash 3.2 crash +// would have been caught by). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + existsSync, + readFileSync, + rmSync, + utimesSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HOOKS = join(dirname(fileURLToPath(import.meta.url)), "..", "plugins", "meridian", "hooks"); +const SID = "11111111-2222-3333-4444-555555555555"; + +function tmpConfig() { + return mkdtempSync(join(tmpdir(), "meridian-")); +} + +// Spawn a hook the way Claude Code's exec form does: node binary + script path, +// payload piped to stdin, no shell involved. +function runHook(name, payload, env = {}) { + const input = typeof payload === "string" ? payload : JSON.stringify(payload); + try { + const stdout = execFileSync(process.execPath, [join(HOOKS, name)], { + input, + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { code: 0, stdout }; + } catch (err) { + return { code: err.status ?? 1, stdout: String(err.stdout ?? ""), stderr: String(err.stderr ?? "") }; + } +} + +test("session-start emits valid orientation JSON", () => { + const cfg = tmpConfig(); + const { code, stdout } = runHook( + "session-start.mjs", + { session_id: SID, hook_event_name: "SessionStart" }, + { CLAUDE_CONFIG_DIR: cfg }, + ); + assert.equal(code, 0); + const out = JSON.parse(stdout); + assert.equal(out.hookSpecificOutput.hookEventName, "SessionStart"); + const ctx = out.hookSpecificOutput.additionalContext; + assert.match(ctx, /\[Meridian orientation\]/); + assert.match(ctx, /`meridian:sketch`/); + // Em-dash survives the round trip: BSD sed on macOS used to mangle this. + assert.ok(ctx.includes("—"), "em-dash preserved in injected context"); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("session-start still emits orientation with empty stdin", () => { + const cfg = tmpConfig(); + const { code, stdout } = runHook("session-start.mjs", "", { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0); + assert.ok(JSON.parse(stdout).hookSpecificOutput.additionalContext.length > 0); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("user-prompt-submit emits the routing audit only on the 8th prompt", () => { + const cfg = tmpConfig(); + for (let i = 1; i <= 7; i++) { + const { code, stdout } = runHook("user-prompt-submit.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0); + assert.equal(stdout.trim(), "", `tick ${i} should be silent`); + } + const { stdout } = runHook("user-prompt-submit.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + const out = JSON.parse(stdout); + assert.equal(out.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + assert.match(out.hookSpecificOutput.additionalContext, /routing audit/i); + // The apostrophe in "user's" is what bash 3.2 misread as an unterminated quote. + assert.ok(out.hookSpecificOutput.additionalContext.includes("user's"), "apostrophe preserved"); + const tick = readFileSync(join(cfg, "meridian", "state", SID, "router-tick"), "utf8").trim(); + assert.equal(tick, "8"); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("user-prompt-submit rejects unsafe session_id without touching the filesystem", () => { + for (const bad of ["../../../etc/evil", "a/b", "", "has space", "$(touch pwned)"]) { + const cfg = tmpConfig(); + const { code, stdout } = runHook("user-prompt-submit.mjs", { session_id: bad }, { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0, `exit 0 for ${JSON.stringify(bad)}`); + assert.equal(stdout.trim(), ""); + assert.ok(!existsSync(join(cfg, "meridian", "state")), `no state created for ${JSON.stringify(bad)}`); + rmSync(cfg, { recursive: true, force: true }); + } +}); + +test("session-end removes its own state dir", () => { + const cfg = tmpConfig(); + const dir = join(cfg, "meridian", "state", SID); + mkdirSync(dir, { recursive: true }); + const { code } = runHook("session-end.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0); + assert.ok(!existsSync(dir), "state dir removed"); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("session-end is a no-op for an unsafe session_id", () => { + const cfg = tmpConfig(); + const root = join(cfg, "meridian", "state"); + mkdirSync(root, { recursive: true }); + const { code } = runHook("session-end.mjs", { session_id: "../../../../etc" }, { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0); + assert.ok(existsSync(root), "state root left untouched"); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("hooks survive malformed JSON on stdin", () => { + const cfg = tmpConfig(); + for (const name of ["session-start.mjs", "user-prompt-submit.mjs", "session-end.mjs"]) { + const { code } = runHook(name, "not json {{{", { CLAUDE_CONFIG_DIR: cfg }); + assert.equal(code, 0, `${name} should exit 0 on garbage input`); + } + rmSync(cfg, { recursive: true, force: true }); +}); + +test("session-start prunes stale state but keeps current and fresh", () => { + const cfg = tmpConfig(); + const root = join(cfg, "meridian", "state"); + const stale = join(root, "stale-session"); + const fresh = join(root, "fresh-session"); + const current = join(root, SID); + for (const d of [stale, fresh, current]) mkdirSync(d, { recursive: true }); + const tenDaysAgo = Date.now() / 1000 - 10 * 24 * 60 * 60; + utimesSync(stale, tenDaysAgo, tenDaysAgo); + runHook("session-start.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + assert.ok(!existsSync(stale), "stale dir pruned"); + assert.ok(existsSync(fresh), "fresh dir kept"); + assert.ok(existsSync(current), "current session dir kept"); + rmSync(cfg, { recursive: true, force: true }); +}); + +test("hooks.json uses node exec form and references existing scripts", () => { + const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); + const pluginRoot = join(HOOKS, ".."); + for (const matchers of Object.values(config.hooks)) { + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + assert.equal(hook.command, "node", "exec form must invoke node"); + assert.ok(Array.isArray(hook.args) && hook.args.length === 1, "expects a single script arg"); + const resolved = hook.args[0].replace("${CLAUDE_PLUGIN_ROOT}", pluginRoot); + assert.ok(existsSync(resolved), `referenced hook script exists: ${hook.args[0]}`); + } + } + } +}); From 06c3146e9b235616dc93cc293dc5891fabd51f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 01:05:04 +0000 Subject: [PATCH 3/3] refactor(meridian): address review findings on the hook rewrite - Add an emitContextFile() helper to lib.mjs and route both hooks through it, removing the duplicated "resolve context/, read, trimEnd, emit" block and the node:url imports it required. - Hoist stateRoot() out of session-start's prune loop so it isn't recomputed per directory entry. - Lock the single-line-JSON invariant the rewrite exists to protect: a test now fails if the payload ever becomes multi-line (e.g. pretty-printed), which JSON.parse alone would not catch. - Reword the safeSessionId comment (it validates a single path segment, not a UUID) and give the remaining empty catches a one-line rationale to match the file's WHY-only convention. - Split CI: manifest validation runs once on ubuntu (it's OS-independent), while the OS matrix runs only the Node hook tests -- the real regression guard -- without a per-runner CLI install. --- .github/workflows/ci.yml | 36 ++++++++++--------- plugins/meridian/hooks/lib.mjs | 34 ++++++++++++++---- plugins/meridian/hooks/session-end.mjs | 4 +-- plugins/meridian/hooks/session-start.mjs | 22 +++++------- plugins/meridian/hooks/user-prompt-submit.mjs | 26 +++++++------- test/meridian-hooks.test.mjs | 19 ++++++++++ 6 files changed, 89 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac28ad..6213af4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,36 +9,40 @@ permissions: contents: read jobs: - test: - name: ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + validate: + name: Validate manifests + runs-on: ubuntu-latest env: # Keep the CLI offline-friendly in CI: no autoupdate / telemetry / error reporting. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" - defaults: - run: - # Windows runners ship Git Bash; using it everywhere gives -eo pipefail - # semantics on all three OS so a failing command reliably fails the step. - shell: bash steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 with: node-version: 22 - - name: Install Claude Code run: npm install -g @anthropic-ai/claude-code - - - name: Validate manifests + # Manifest validation is OS-independent, so it runs once here rather than + # paying a global CLI install on every runner in the matrix below. + - name: Validate marketplace and plugin manifests run: | claude plugin validate --strict . claude plugin validate --strict plugins/meridian claude plugin validate --strict plugins/almanac + test-hooks: + name: Test hooks (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + # The OS matrix is the regression guard: the original crash was macOS-only + # (system bash 3.2). The hooks run on Node alone, so no CLI install is needed. + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 - name: Test hooks run: node --test test/meridian-hooks.test.mjs diff --git a/plugins/meridian/hooks/lib.mjs b/plugins/meridian/hooks/lib.mjs index b19b7b7..730059d 100644 --- a/plugins/meridian/hooks/lib.mjs +++ b/plugins/meridian/hooks/lib.mjs @@ -4,7 +4,10 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CONTEXT_DIR = join(dirname(fileURLToPath(import.meta.url)), "context"); // Read the hook event payload from stdin. Returns {} on empty or invalid input // so a malformed payload degrades to a no-op instead of crashing the session. @@ -22,27 +25,44 @@ export function readHookInput() { } } -// session_id flows into rm -rf / mkdir paths, so accept only the UUID-shaped -// values Claude Code actually issues. Anything else (or a missing/non-string -// value) returns null and the caller treats the session as absent. +// Accept only a single path segment of [A-Za-z0-9_-]; this blocks separators, +// "..", and shell metacharacters before the value reaches the rm -rf / mkdir +// paths below. Returns null for a missing/non-string/unsafe id so the caller +// treats the session as absent. (Not a UUID check -- any such segment passes.) export function safeSessionId(input) { const id = input?.session_id; return typeof id === "string" && /^[A-Za-z0-9_-]+$/.test(id) ? id : null; } +// Root dir for all Meridian session state; honors CLAUDE_CONFIG_DIR, else +// ~/.claude. This is the directory rmSync(recursive) ultimately operates under. export function stateRoot() { const base = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); return join(base, "meridian", "state"); } +// State dir for one session. sessionId must come from safeSessionId. export function sessionDir(sessionId) { return join(stateRoot(), sessionId); } // Emit additionalContext as a JSON object on stdout. JSON.stringify handles all -// escaping, replacing the hand-rolled sed/awk encoder that silently corrupted -// content under BSD sed on macOS. -export function emitContext(hookEventName, text) { +// escaping (and keeps the payload a single line), replacing the hand-rolled +// sed/awk encoder that silently corrupted content under BSD sed on macOS. +function emitContext(hookEventName, text) { const payload = { hookSpecificOutput: { hookEventName, additionalContext: text } }; process.stdout.write(JSON.stringify(payload) + "\n"); } + +// Read a prompt-text file from hooks/context/ and emit it as additionalContext. +// Resolved relative to this module so it works regardless of cwd; a missing file +// degrades to a no-op rather than crashing the hook. +export function emitContextFile(hookEventName, filename) { + let text; + try { + text = readFileSync(join(CONTEXT_DIR, filename), "utf8"); + } catch { + return; + } + emitContext(hookEventName, text.trimEnd()); +} diff --git a/plugins/meridian/hooks/session-end.mjs b/plugins/meridian/hooks/session-end.mjs index 41dbf27..01f3f27 100644 --- a/plugins/meridian/hooks/session-end.mjs +++ b/plugins/meridian/hooks/session-end.mjs @@ -5,6 +5,6 @@ import { readHookInput, safeSessionId, sessionDir } from "./lib.mjs"; const sessionId = safeSessionId(readHookInput()); if (!sessionId) process.exit(0); -// safeSessionId guarantees a UUID-shaped name, so this rm -rf can only ever -// target a dir directly under the Meridian state root. +// safeSessionId validated this to a single path segment, so rmSync can only +// ever target a dir directly under the Meridian state root. rmSync(sessionDir(sessionId), { recursive: true, force: true }); diff --git a/plugins/meridian/hooks/session-start.mjs b/plugins/meridian/hooks/session-start.mjs index 4585760..708e0bf 100644 --- a/plugins/meridian/hooks/session-start.mjs +++ b/plugins/meridian/hooks/session-start.mjs @@ -1,27 +1,26 @@ #!/usr/bin/env node -import { readdirSync, statSync, rmSync, readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { readHookInput, safeSessionId, stateRoot, emitContext } from "./lib.mjs"; +import { readdirSync, statSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { readHookInput, safeSessionId, stateRoot, emitContextFile } from "./lib.mjs"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -const here = dirname(fileURLToPath(import.meta.url)); const currentSession = safeSessionId(readHookInput()); // Prune session-state dirs untouched for 7+ days. user-prompt-submit bumps each // dir's mtime on every prompt, so mtime tracks last-activity, not creation. A // failure on any single dir must not block the orientation emit below. +const root = stateRoot(); let entries = []; try { - entries = readdirSync(stateRoot(), { withFileTypes: true }); + entries = readdirSync(root, { withFileTypes: true }); } catch { - entries = []; + // No state root yet (first run) -- nothing to prune. } for (const entry of entries) { if (!entry.isDirectory()) continue; if (currentSession && entry.name === currentSession) continue; - const dir = join(stateRoot(), entry.name); + const dir = join(root, entry.name); try { if (Date.now() - statSync(dir).mtimeMs > SEVEN_DAYS_MS) { rmSync(dir, { recursive: true, force: true }); @@ -33,9 +32,4 @@ for (const entry of entries) { // Inject orientation as additionalContext (a discreet system reminder) rather // than stdout transcript text, which reads like a user-issued directive. -try { - const orientation = readFileSync(join(here, "context", "orientation.md"), "utf8"); - emitContext("SessionStart", orientation.trimEnd()); -} catch { - // If the content file is missing, stay silent rather than crash the session. -} +emitContextFile("SessionStart", "orientation.md"); diff --git a/plugins/meridian/hooks/user-prompt-submit.mjs b/plugins/meridian/hooks/user-prompt-submit.mjs index a8b9978..d6af3e5 100644 --- a/plugins/meridian/hooks/user-prompt-submit.mjs +++ b/plugins/meridian/hooks/user-prompt-submit.mjs @@ -1,11 +1,9 @@ #!/usr/bin/env node import { mkdirSync, utimesSync, readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { readHookInput, safeSessionId, sessionDir, emitContext } from "./lib.mjs"; +import { join } from "node:path"; +import { readHookInput, safeSessionId, sessionDir, emitContextFile } from "./lib.mjs"; const AUDIT_EVERY = 8; -const here = dirname(fileURLToPath(import.meta.url)); const sessionId = safeSessionId(readHookInput()); if (!sessionId) process.exit(0); @@ -15,26 +13,28 @@ const tickFile = join(dir, "router-tick"); mkdirSync(dir, { recursive: true }); // Bump dir mtime on every prompt so SessionStart's prune reflects last-activity. -const now = new Date(); try { - utimesSync(dir, now, now); -} catch {} + utimesSync(dir, new Date(), new Date()); +} catch { + // Best-effort; a failed touch only risks an early prune of an idle session. +} let tick = 0; try { const raw = readFileSync(tickFile, "utf8").trim(); if (/^\d+$/.test(raw)) tick = parseInt(raw, 10); -} catch {} +} catch { + // No tick file yet, or unreadable -- start from 0; a lost tick only delays the audit. +} tick += 1; try { writeFileSync(tickFile, tick + "\n"); -} catch {} +} catch { + // A failed write just replays this tick next prompt; never block submission. +} // Every Nth prompt, emit a discreet routing audit so the model re-checks that // the active skill still matches intent. Absorbed silently as additionalContext. if (tick % AUDIT_EVERY === 0) { - try { - const audit = readFileSync(join(here, "context", "routing-audit.md"), "utf8"); - emitContext("UserPromptSubmit", audit.trimEnd()); - } catch {} + emitContextFile("UserPromptSubmit", "routing-audit.md"); } diff --git a/test/meridian-hooks.test.mjs b/test/meridian-hooks.test.mjs index ba317e5..253570f 100644 --- a/test/meridian-hooks.test.mjs +++ b/test/meridian-hooks.test.mjs @@ -143,6 +143,25 @@ test("session-start prunes stale state but keeps current and fresh", () => { rmSync(cfg, { recursive: true, force: true }); }); +test("emitted additionalContext is a single-line JSON payload", () => { + // The rewrite exists to stop multi-line/multibyte content corrupting the + // protocol. JSON.parse succeeds on pretty-printed JSON too, so assert the + // payload is exactly one line + a trailing newline -- a switch to + // JSON.stringify(x, null, 2) would fail here, not slip through. + const cfg = tmpConfig(); + const start = runHook("session-start.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + assert.ok(start.stdout.endsWith("\n"), "session-start ends with a trailing newline"); + assert.ok(!start.stdout.trimEnd().includes("\n"), "session-start payload is one line"); + + let audit = { stdout: "" }; + for (let i = 1; i <= 8; i++) { + audit = runHook("user-prompt-submit.mjs", { session_id: SID }, { CLAUDE_CONFIG_DIR: cfg }); + } + assert.ok(audit.stdout.endsWith("\n"), "audit ends with a trailing newline"); + assert.ok(!audit.stdout.trimEnd().includes("\n"), "audit payload is one line"); + rmSync(cfg, { recursive: true, force: true }); +}); + test("hooks.json uses node exec form and references existing scripts", () => { const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); const pluginRoot = join(HOOKS, "..");