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/.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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6213af4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + 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" + 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 + # 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/.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..730059d --- /dev/null +++ b/plugins/meridian/hooks/lib.mjs @@ -0,0 +1,68 @@ +// 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 { 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. +export function readHookInput() { + let raw = ""; + try { + raw = readFileSync(0, "utf8"); + } catch { + return {}; + } + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +// 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 (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 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..01f3f27 --- /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 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 new file mode 100644 index 0000000..708e0bf --- /dev/null +++ b/plugins/meridian/hooks/session-start.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +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 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(root, { withFileTypes: true }); +} catch { + // 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(root, 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. +emitContextFile("SessionStart", "orientation.md"); 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..d6af3e5 --- /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 { join } from "node:path"; +import { readHookInput, safeSessionId, sessionDir, emitContextFile } from "./lib.mjs"; + +const AUDIT_EVERY = 8; + +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. +try { + 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 { + // No tick file yet, or unreadable -- start from 0; a lost tick only delays the audit. +} +tick += 1; +try { + writeFileSync(tickFile, tick + "\n"); +} 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) { + emitContextFile("UserPromptSubmit", "routing-audit.md"); +} diff --git a/test/meridian-hooks.test.mjs b/test/meridian-hooks.test.mjs new file mode 100644 index 0000000..253570f --- /dev/null +++ b/test/meridian-hooks.test.mjs @@ -0,0 +1,178 @@ +// 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("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, ".."); + 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]}`); + } + } + } +});