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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 3 additions & 4 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion plugins/meridian/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
42 changes: 0 additions & 42 deletions plugins/meridian/hooks/session-start → ...ins/meridian/hooks/context/orientation.md
100755 → 100644
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions plugins/meridian/hooks/context/routing-audit.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions plugins/meridian/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Expand All @@ -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
}
]
Expand All @@ -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
}
]
Expand Down
68 changes: 68 additions & 0 deletions plugins/meridian/hooks/lib.mjs
Original file line number Diff line number Diff line change
@@ -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());
}
17 changes: 0 additions & 17 deletions plugins/meridian/hooks/session-end

This file was deleted.

10 changes: 10 additions & 0 deletions plugins/meridian/hooks/session-end.mjs
Original file line number Diff line number Diff line change
@@ -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 });
35 changes: 35 additions & 0 deletions plugins/meridian/hooks/session-start.mjs
Original file line number Diff line number Diff line change
@@ -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");
40 changes: 0 additions & 40 deletions plugins/meridian/hooks/user-prompt-submit

This file was deleted.

40 changes: 40 additions & 0 deletions plugins/meridian/hooks/user-prompt-submit.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading