From e945d407f3a52646e79f9667bc45bcf6f25db8a5 Mon Sep 17 00:00:00 2001 From: Stella Inwood Date: Mon, 13 Jul 2026 22:07:56 +1000 Subject: [PATCH] v0.12.0: user-owned flows and model adapters --- .claude-plugin/marketplace.json | 6 +- .claude-plugin/plugin.json | 10 +- .cursor-plugin/plugin.json | 10 +- .github/scripts/eval-summary.mjs | 4 +- .github/workflows/eval.yml | 8 +- .plugin/plugin.json | 2 +- CHANGELOG.md | 20 + README.md | 120 ++- behavior/adapters/agreement-seeking.md | 3 + behavior/adapters/artifact-overproduction.md | 3 + behavior/adapters/comment-narration.md | 3 + behavior/adapters/depth-escalation.md | 3 + behavior/adapters/review-recursion.md | 3 + behavior/adapters/workflow-literalism.md | 3 + behavior/professional-engineering.md | 29 + behavior/profiles.json | 13 + eval/README.md | 68 +- eval/scenarios/negative.yaml | 47 +- eval/scenarios/positive.yaml | 60 +- hooks/context/debug-reroute.md | 7 - hooks/context/orientation.md | 47 -- hooks/context/routing-audit.md | 1 - hooks/hooks-copilot.json | 4 +- hooks/hooks-cursor.json | 10 - hooks/hooks.json | 24 - hooks/lib/behavior.mjs | 95 +++ hooks/lib/host.mjs | 51 +- hooks/lib/signals.mjs | 65 +- hooks/lib/state.mjs | 115 --- hooks/lib/types.mjs | 13 +- hooks/session-end.mjs | 8 - hooks/session-start.mjs | 23 +- hooks/user-prompt-submit.mjs | 26 - output-styles/meridian.md | 69 -- promptfooconfig.yaml | 9 +- skills/auto/SKILL.md | 129 +--- skills/auto/agents/openai.yaml | 2 + skills/brainstorm/SKILL.md | 21 +- skills/brainstorm/agents/openai.yaml | 2 + skills/commit/SKILL.md | 104 +-- skills/commit/agents/openai.yaml | 2 + skills/debug/SKILL.md | 73 +- skills/delegate/SKILL.md | 71 +- skills/document/SKILL.md | 4 +- skills/document/agents/openai.yaml | 2 + skills/execute/SKILL.md | 131 +--- skills/execute/agents/openai.yaml | 2 + skills/research/SKILL.md | 39 +- skills/respond/SKILL.md | 61 +- skills/review/SKILL.md | 102 +-- skills/review/agents/openai.yaml | 2 + skills/sketch/SKILL.md | 30 +- skills/sketch/agents/openai.yaml | 2 + skills/triangulate/SKILL.md | 65 +- templates/CLAUDE.md | 23 +- test/meridian-consistency.test.mjs | 135 +++- test/meridian-hooks.test.mjs | 734 ++++++------------- test/meridian-lib.test.mjs | 127 ++-- 58 files changed, 986 insertions(+), 1859 deletions(-) create mode 100644 behavior/adapters/agreement-seeking.md create mode 100644 behavior/adapters/artifact-overproduction.md create mode 100644 behavior/adapters/comment-narration.md create mode 100644 behavior/adapters/depth-escalation.md create mode 100644 behavior/adapters/review-recursion.md create mode 100644 behavior/adapters/workflow-literalism.md create mode 100644 behavior/professional-engineering.md create mode 100644 behavior/profiles.json delete mode 100644 hooks/context/debug-reroute.md delete mode 100644 hooks/context/orientation.md delete mode 100644 hooks/context/routing-audit.md create mode 100644 hooks/lib/behavior.mjs delete mode 100644 hooks/lib/state.mjs delete mode 100644 hooks/session-end.mjs delete mode 100644 hooks/user-prompt-submit.mjs delete mode 100644 output-styles/meridian.md create mode 100644 skills/auto/agents/openai.yaml create mode 100644 skills/brainstorm/agents/openai.yaml create mode 100644 skills/commit/agents/openai.yaml create mode 100644 skills/document/agents/openai.yaml create mode 100644 skills/execute/agents/openai.yaml create mode 100644 skills/review/agents/openai.yaml create mode 100644 skills/sketch/agents/openai.yaml diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 827832a..d3c5b98 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,17 +1,17 @@ { "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "meridian", - "description": "Research-first workflows, ruthless code review, and orchestrator-led reasoning for Claude Code.", + "description": "Professional engineering discipline with user-owned workflows and model-specific corrections.", "owner": { "name": "KodingDev" }, "plugins": [ { "name": "meridian", - "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", + "description": "Professional engineering discipline with user-owned workflows and model-specific corrections.", "source": "./", "category": "development", - "homepage": "https://github.com/KodingDev/claude-plugins" + "homepage": "https://github.com/KodingDev/meridian" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 69cc4af..4bcc765 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,13 +1,13 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "meridian", - "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.11.3", + "description": "Professional engineering discipline with user-owned workflows and model-specific corrections.", + "version": "0.12.0", "author": { "name": "KodingDev" }, - "homepage": "https://github.com/KodingDev/claude-plugins", - "repository": "https://github.com/KodingDev/claude-plugins", + "homepage": "https://github.com/KodingDev/meridian", + "repository": "https://github.com/KodingDev/meridian", "license": "MIT", - "keywords": ["workflows", "code-review", "research", "quality", "debugging"] + "keywords": ["engineering", "workflows", "quality", "research", "debugging"] } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 347f30c..2e95f06 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,14 +1,14 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "meridian", - "description": "Research-first workflows, ruthless code review, orchestrator-led reasoning, and opaque subagent isolation for the entire development lifecycle.", - "version": "0.11.3", + "description": "Professional engineering discipline with user-owned workflows and model-specific corrections.", + "version": "0.12.0", "author": { "name": "KodingDev" }, - "homepage": "https://github.com/KodingDev/claude-plugins", - "repository": "https://github.com/KodingDev/claude-plugins", + "homepage": "https://github.com/KodingDev/meridian", + "repository": "https://github.com/KodingDev/meridian", "license": "MIT", - "keywords": ["workflows", "code-review", "research", "quality", "debugging"], + "keywords": ["engineering", "workflows", "quality", "research", "debugging"], "hooks": "./hooks/hooks-cursor.json" } diff --git a/.github/scripts/eval-summary.mjs b/.github/scripts/eval-summary.mjs index 76bcf45..85fd116 100644 --- a/.github/scripts/eval-summary.mjs +++ b/.github/scripts/eval-summary.mjs @@ -8,7 +8,7 @@ const result = JSON.parse(readFileSync(process.argv[2], "utf8")); const rows = result.results?.results ?? result.results ?? []; let passed = 0; -const lines = ["| | Prompt | Routed to | Expected |", "|---|---|---|---|"]; +const lines = ["| | Prompt | Skill used | Expected |", "|---|---|---|---|"]; for (const row of rows) { const prompt = String(row.vars?.prompt ?? "") .replace(/\|/g, "\\|") @@ -20,5 +20,5 @@ for (const row of rows) { lines.push(`| ${row.success ? "✅" : "❌"} | ${prompt} | \`${got}\` | \`${want}\` |`); } -console.log(`## Routing eval — ${passed}/${rows.length} passed\n`); +console.log(`## Invocation-boundary eval — ${passed}/${rows.length} passed\n`); console.log(lines.join("\n")); diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index 9ee562f..2d7173b 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -1,4 +1,4 @@ -name: Routing eval +name: Invocation-boundary eval # On-demand only — never on push/PR. Needs the ANTHROPIC_API_KEY secret and makes # paid API calls, so it is not a merge gate. @@ -10,7 +10,7 @@ permissions: jobs: eval: - name: Skill-routing eval + name: Invocation-boundary eval runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: cache: pnpm - name: Install dev tooling run: pnpm install --frozen-lockfile - - name: Run routing eval + - name: Run invocation-boundary eval id: eval continue-on-error: true env: @@ -34,6 +34,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: routing-eval-report + name: invocation-boundary-eval-report path: eval-report.html if-no-files-found: warn diff --git a/.plugin/plugin.json b/.plugin/plugin.json index 9a9302c..a7ef45b 100644 --- a/.plugin/plugin.json +++ b/.plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "meridian", - "version": "0.11.3", + "version": "0.12.0", "hooks": "./hooks/hooks-copilot.json" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b83eaa..eeb6fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to Meridian are recorded here. The format follows to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). History before 0.11.0 lives in the git log. +## [0.12.0] - 2026-07-13 + +### Added + +- A model-independent professional engineering standard and atomic model adapters selected from named profiles at session start. +- Manual-invocation policy for formal flows across Claude Code, Cursor, Copilot, and Codex skill metadata. +- Optional project-instruction sections for customer security, compliance, verification, deployment, rollback, artifacts, and allowed environments. + +### Changed + +- Formal `brainstorm`, `sketch`, `execute`, `review`, `commit`, `document`, and `auto` flows are user-owned. They no longer invoke one another automatically or repeat approval already granted by invocation. +- `debug`, `research`, `triangulate`, `delegate`, and `respond` are narrow model-invoked disciplines with risk-proportionate process. +- `execute` performs proportionate verification but no longer launches formal review or commit flows. +- Formal review enumerates tracked and untracked working-tree changes before dispatching independent lenses. +- Interactive flows use structured question tools when available and fall back to plain text on hosts without them. + +### Removed + +- The forced Meridian output style, automatic workflow routing table, periodic routing audit, terse-failure reroute, and their session state machinery. + ## [0.11.3] - 2026-07-06 ### Added diff --git a/README.md b/README.md index 91e1705..fbc340d 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,118 @@ # Meridian -Claude Code plugin for development workflows that don't fall apart in long sessions. +Professional engineering discipline without taking ownership of your workflow. -## Why +Meridian gives coding agents one model-independent quality standard, lets projects supply their own customer and repository policy, and applies small model-specific corrections where a model has a repeatable failure mode. It does not route ordinary requests into mandatory planning, review, documentation, or commit pipelines. -[Superpowers](https://github.com/obra/superpowers) is great. Skill-based workflows, iterative spec review, subagent-driven development — all genuinely good ideas that I used for a long time. But some patterns kept showing up that I wanted to fix: +## How it works -Agents get confident in their own prior reasoning. If you review code, fix stuff, then review again — the second pass comes back suspiciously clean. The agent's own reasoning from the first review is right there in context, and it can't really fathom that it missed something when its own thoughts are telling it everything was fine. Meridian isolates review subagents so the orchestrator only ever sees defects and a verdict, not the reviewer's internal reasoning. +Meridian has four layers: -They trust their training data way too much. Confidently writing code against APIs that have changed, using library features that don't work the way they remember — then wasting ages debugging when a quick docs check would've caught it upfront. Meridian verifies external APIs against live documentation before writing code. +1. **Professional engineering standard** — shared expectations for correctness, evidence, security, maintainability, operability, delivery hygiene, user agency, proportionality, and stopping. +2. **Project policy** — your `CLAUDE.md`, `AGENTS.md`, and repository instructions define customer, compliance, tooling, deployment, rollback, and artifact requirements. +3. **Model adapters** — atomic corrections for observable quirks such as comment narration or recursive reviewing. They change steering, never the quality bar or authorization. +4. **Skills** — formal flows are user-owned; narrow engineering disciplines can be selected by the model inside an ordinary task. -Skill systems can be too rigid. Not everything needs the full brainstorm -> plan -> execute pipeline. Changing a button color shouldn't require a design spec. Meridian uses judgment about what warrants ceremony and what just needs doing. +The result is deliberately asymmetric: different models may receive different corrective wording, but every model owes the same professional outcome. -Agents commit stuff you didn't ask for. Spec files staged automatically, AI attribution in every message, design docs forced past your gitignore. Meridian doesn't commit anything without asking, doesn't stage spec files, and doesn't put "Co-Authored-By: Claude" in your commits. +## Skills -They don't push back enough. If you ask for something that'll cause problems, the agent should say so — with evidence and actual alternatives, each with an honest case made for it. And once you've picked a direction, it should go all in, not half-ass it and relitigate in review. +### User-invoked flows -Hard-won knowledge dies with the session. You debug something for two hours, nail the root cause, and next session the agent suggests the exact approach you already ruled out. Meridian has a documentation skill for capturing gotchas, debugging context, and "we already tried this" notes so future sessions don't start from scratch. +These run only when you invoke them. One flow never silently enrolls you in another. -## Skills +| Skill | Purpose | +| --- | --- | +| `brainstorm` | Develop an idea into an approved implementation spec | +| `sketch` | Produce a lightweight contract for a small change | +| `execute` | Implement an approved spec or clear scope with proportionate verification | +| `review` | Perform a focused, report-only code review | +| `commit` | Create one clean, narrowly staged commit | +| `document` | Capture requested durable engineering knowledge | +| `auto` | Grant elevated autonomy for reversible, in-scope work | -| Skill | Does | -|-------|------| -| `research` | Verify APIs/libs against live docs before implementing | -| `brainstorm` | Design exploration -> spec through conversation | -| `sketch` | Lightweight spec for a small, well-scoped fix | -| `execute` | Implement from spec with verification gates | -| `delegate` | Dispatch subagents with clean context isolation | -| `debug` | Root-cause investigation, no fixes without understanding | -| `review` | Code review via isolated subagent | -| `respond` | Evaluate review feedback before acting on it | -| `commit` | Clean git commits, no AI attribution | -| `document` | Human-readable docs from resolved work | +Invoking a flow authorizes its ordinary documented behavior. Separate approval is still required for destructive operations, unrequested external effects, material scope expansion, and unresolved high-cost choices. -Two more compose with these rather than standing alone: `triangulate` (a verification lens that grounds specific-value claims against their source) and `auto` (a modifier that runs any task autonomously when you step away). Routing lives in the session orientation injected at startup — there is no separate router skill. +### Model-invoked disciplines -## What gets installed +These are narrow internal tools, not workflow gates: -- A `Meridian` output style applied automatically while the plugin is enabled (overrides any `/output-style` selection while loaded). It carries the durable principles — three pillars, voice, commit-attribution override, the challenge protocol — directly in the system prompt rather than relying on per-turn reminders. -- A session-start hook that injects the routing table and active-mode triggers as a quiet system reminder at the start of every session, so the orientation is felt rather than announced. -- The skills below, dispatched by judgement against the routing table. +| Skill | Purpose | +| --- | --- | +| `debug` | Diagnose failures from evidence before fixing them | +| `research` | Verify unstable external behavior against primary sources | +| `triangulate` | Escalate contested or load-bearing claims beyond one source | +| `delegate` | Isolate genuinely independent or context-heavy work | +| `respond` | Evaluate review feedback while preserving user intent | -## Install +## Model profiles -### Claude Code +At session start Meridian injects the professional standard followed by the adapters for the resolved profile. Resolution order is: + +1. `MERIDIAN_MODEL_PROFILE` +2. exact model identifier +3. registered model-family prefix +4. host-local fallback +5. baseline standard only + +Initial profiles are: +| Profile | Adapters | +| --- | --- | +| `opus-4.8` | comment narration, artifact overproduction | +| `sol` | review recursion, depth escalation, workflow literalism, agreement seeking | +| `fable` | none | + +Only model identifiers observed from host payloads or documented by the host are mapped automatically. Unknown models receive the baseline rather than a guessed profile. + +Set a host-local fallback in `/meridian/config.json`: + +```json +{ + "fallbackProfile": "sol" +} ``` + +The state base is host-specific, such as `CODEX_HOME` for Codex. GitHub Copilot does not currently expose a model identifier in its documented session-start payload, so use the environment override or fallback there. + +## Installation + +### Claude Code + +```text /plugin marketplace add KodingDev/meridian /plugin install meridian@meridian ``` ### Cursor -Install from the [Cursor plugin marketplace](https://cursor.com/marketplace), or enable **Third-party skills** in Cursor Settings → Features if you use the Claude plugin bundle. +Install Meridian from the Cursor plugin marketplace, or enable third-party skills and install the Claude plugin bundle. -Hooks are Node scripts (`node ./hooks/*.mjs`) — no Git Bash or shell polyglot required. Session orientation injects via `additional_context` on `sessionStart`. If hooks fail silently, check View → Output → **Hooks** and restart Cursor after updating the plugin. +### GitHub Copilot CLI and Codex -### GitHub Copilot CLI +Install the same plugin bundle through the host's plugin support. Meridian ships host-specific hook manifests where their schemas differ. The session hook is read-only context composition; the existing pre-tool commit guard still blocks AI attribution and staging ignored `.meridian/` working artifacts where supported. -Copilot CLI installs the same plugin via its `/plugin` commands and loads the skills and subagents directly. Copilot resolves the Claude subagent tool names as compatible aliases — `Read`, `Grep`, `Glob`, `Bash` map to its `view`, `grep`, `glob`, `bash` tools — so file, search, and shell access carry over unchanged. Its web tool (`web_fetch`) has no Claude-name alias, so the agents that need the web (`research`, `triangulate`) also list `web_fetch` explicitly; otherwise `WebFetch`/`WebSearch` resolve to nothing and the agent silently loses web access. +## Project policy -Hooks ship in Copilot's own format: Copilot's command schema treats `command` as a shell string and has no `args` array, so the Claude exec-form config would run a bare `node`. Copilot is pointed at `hooks/hooks-copilot.json` via `.plugin/plugin.json` (a manifest slot Copilot reads but Claude and Cursor don't). Session orientation injects via a flat `additionalContext` on `sessionStart`; prompt submission is state-only, because Copilot's `userPromptSubmitted` hook cannot inject context (same ceiling as Cursor). +Use repository instructions for requirements that vary by customer or environment. The included `templates/CLAUDE.md` provides optional headings for security and compliance, required verification, deployment and rollback, repository artifacts, and allowed tools. Meridian intentionally ships no AWS-, employer-, customer-, or Stella-specific defaults. -### Codex +## Development -Codex CLI installs the plugin from the same marketplace and loads the plugin's default `hooks/hooks.json` — the same Claude-shaped hook config (event → matcher groups → `{ "type": "command" }`) — sharing Claude's `hookSpecificOutput` context contract. So, unlike Cursor and Copilot, orientation injects on `SessionStart` **and** the routing audit injects on `UserPromptSubmit`, and the commit-attribution `PreToolUse` guard runs. Codex is detected by the un-prefixed `PLUGIN_ROOT` it sets (Claude sets only `CLAUDE_PLUGIN_ROOT`, which Codex also sets as a compat alias); state lives under `CODEX_HOME` (default `~/.codex`). Codex does not honor a plugin-manifest hook redirect ([openai/codex#16430](https://github.com/openai/codex/issues/16430)), so there is no Codex-specific hooks file — the default `hooks/hooks.json` drives it. Plugin hooks install untrusted; run one interactive Codex session to review and trust them, after which they fire automatically. +```text +pnpm check +node --test test/meridian-hooks.test.mjs +``` -Hook tests: `node --test test/meridian-hooks.test.mjs` +The live evaluation harness is on-demand because it makes paid model calls: -## Credit +```text +pnpm eval +pnpm eval:view +``` -Built on ideas from [superpowers](https://github.com/obra/superpowers) by Jesse Vincent — the skill-based workflow, subagent development, and iterative spec review all come from there. Meridian wouldn't exist without it. +## Credit -Orchestration approach informed by Anthropic's [context engineering research](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) and the [Claude Certification Guide](https://claudecertificationguide.com). +Built on ideas from [Superpowers](https://github.com/obra/superpowers) by Jesse Vincent and informed by Matt Pocock's user-invoked/model-invoked skill split. ## License diff --git a/behavior/adapters/agreement-seeking.md b/behavior/adapters/agreement-seeking.md new file mode 100644 index 0000000..e421674 --- /dev/null +++ b/behavior/adapters/agreement-seeking.md @@ -0,0 +1,3 @@ +[Meridian model adapter: agreement seeking] + +This model tends to use agreement, extra work, or procedural ceremony as a substitute for help. Preferences and intent belong to the user; technical conclusions require independent judgment. State a concise evidence-based disagreement when warranted. Do not add work or validation language merely to appear cooperative. diff --git a/behavior/adapters/artifact-overproduction.md b/behavior/adapters/artifact-overproduction.md new file mode 100644 index 0000000..7c28c73 --- /dev/null +++ b/behavior/adapters/artifact-overproduction.md @@ -0,0 +1,3 @@ +[Meridian model adapter: artifact overproduction] + +This model tends to turn discussion into plans, specs, summaries, and documentation. Discussion remains discussion. Create a durable artifact only when the user requests it or when it is necessary to continue an explicitly chosen multi-session workflow. Do not create artifacts to demonstrate thoroughness. diff --git a/behavior/adapters/comment-narration.md b/behavior/adapters/comment-narration.md new file mode 100644 index 0000000..5e73efa --- /dev/null +++ b/behavior/adapters/comment-narration.md @@ -0,0 +1,3 @@ +[Meridian model adapter: comment narration] + +This model tends to narrate implementation in comments. Produce zero comments by default. Retain one only when it preserves a hidden constraint, subtle invariant, or specific workaround that cannot be expressed through code structure or naming. Keep it to one short line and never describe the diff or session reasoning. diff --git a/behavior/adapters/depth-escalation.md b/behavior/adapters/depth-escalation.md new file mode 100644 index 0000000..fc4bef0 --- /dev/null +++ b/behavior/adapters/depth-escalation.md @@ -0,0 +1,3 @@ +[Meridian model adapter: depth escalation] + +This model tends to equate available effort with useful depth. Depth follows risk and unresolved uncertainty, not model capacity. Complete the requested outcome at the smallest defensible scope. Stop when the professional standard's completion condition is satisfied; do not invent adjacent work, extra abstractions, or additional analysis to prove diligence. diff --git a/behavior/adapters/review-recursion.md b/behavior/adapters/review-recursion.md new file mode 100644 index 0000000..5667c78 --- /dev/null +++ b/behavior/adapters/review-recursion.md @@ -0,0 +1,3 @@ +[Meridian model adapter: review recursion] + +This model tends to repeat full reviews after every correction. Use one risk-proportionate review pass when review is part of the request. After addressing findings, run targeted verification over the affected surface. Start another full review only when new evidence or a material design change invalidates the earlier review. diff --git a/behavior/adapters/workflow-literalism.md b/behavior/adapters/workflow-literalism.md new file mode 100644 index 0000000..0bbd8d2 --- /dev/null +++ b/behavior/adapters/workflow-literalism.md @@ -0,0 +1,3 @@ +[Meridian model adapter: workflow literalism] + +This model tends to treat available workflows as mandatory. Do not enroll the user in a formal flow they did not explicitly invoke. Ordinary requests retain their ordinary meaning. Apply internal engineering disciplines without turning them into user-facing gates, contracts, approval rounds, or artifacts. diff --git a/behavior/professional-engineering.md b/behavior/professional-engineering.md new file mode 100644 index 0000000..29cf452 --- /dev/null +++ b/behavior/professional-engineering.md @@ -0,0 +1,29 @@ +[Meridian professional engineering standard] + +## User agency and scope + +The user owns goals, priorities, taste, acceptable tradeoffs, and workflow selection. Treat explicit instructions as authority over intent and scope while evaluating technical claims independently. Make reversible implementation decisions without ceremony. Ask before destructive actions, unrequested external effects, material scope expansion, or a genuinely high-cost fork the user must decide. + +## Grounding and judgment + +Read the repository, instructions, current documentation, runtime evidence, and real operational data before asserting or changing behavior. Separate observation from inference. Challenge technically weak proposals with concrete evidence, then respect the user's decision. Do not turn a suggestion into an order or a preference into a technical claim. + +## Correctness and evidence + +Deliver the requested outcome and verify it through the highest practical real seam. Claims such as fixed, working, complete, or verified require observed evidence. When full verification is unavailable, state what was tested and what remains uncertain. Address causes rather than accumulating symptom patches. + +## Security and customer environments + +Treat credentials, customer data, permissions, trust boundaries, production effects, and irreversible changes as high-risk. Follow repository and customer policy, apply least privilege, preserve auditability, and provide rollback paths when the blast radius warrants them. Never invent organization-specific policy. + +## Maintainability and operability + +Prefer the smallest durable solution that fits the current problem. Follow existing conventions, reuse appropriate interfaces, keep changes cohesive, and make failure behavior actionable. Comments preserve non-obvious rationale; they do not narrate implementation. Avoid both speculative generality and local fixes that leave the underlying design failure intact. + +## Delivery hygiene + +Keep diffs focused and preserve unrelated user work. Public artifacts describe the resulting code and behavior, not the agent session. Do not expose secrets, add AI attribution, or commit local working state. Use the project's established commit, review, and documentation conventions. + +## Proportionality and stopping + +Scale effort to blast radius, irreversibility, security or data exposure, novelty, and remaining uncertainty. Additional reviews, artifacts, abstractions, and analysis require new evidence or an explicit request; effort alone is not evidence of quality. Stop when the requested outcome is delivered, risk-appropriate verification passes, claims match the evidence, and no known material defect remains. diff --git a/behavior/profiles.json b/behavior/profiles.json new file mode 100644 index 0000000..ad57112 --- /dev/null +++ b/behavior/profiles.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "opus-4.8": ["comment-narration", "artifact-overproduction"], + "sol": ["review-recursion", "depth-escalation", "workflow-literalism", "agreement-seeking"], + "fable": [] + }, + "modelMatches": [ + { + "exact": "claude-opus-4-8", + "profile": "opus-4.8" + } + ] +} diff --git a/eval/README.md b/eval/README.md index 01a37db..1317de6 100644 --- a/eval/README.md +++ b/eval/README.md @@ -1,65 +1,25 @@ -# Skill-routing eval +# Invocation-boundary evaluation -Verifies that representative prompts route to the correct Meridian skill (or none), -against the real plugin, on **Sonnet** (`claude-sonnet-4-6`) — the routing baseline: -if Sonnet can't route a prompt, the design is moot. This is **on-demand dev tooling, -not a CI gate** — it makes live, paid API calls (a full run is a few turns of the -agent per scenario). - -## Prerequisites - -- Dev dependencies installed: `pnpm install`. -- An Anthropic API key in a gitignored `.env` at the repo root: - - ``` - ANTHROPIC_API_KEY=sk-ant-... - ``` - - promptfoo loads `.env` automatically. `.env` is gitignored — never commit it. - Alternatively, set `apiKeyRequired: false` in `promptfooconfig.yaml` to run against - a local Claude Code login. +Checks that Meridian's narrow model disciplines can activate while ordinary requests do not enroll the user in formal workflows. It runs against the real plugin on Sonnet and makes paid API calls, so it is on-demand rather than an offline CI gate. ## Run -``` -pnpm eval # run the corpus on Sonnet -pnpm eval:view # open the pass/fail matrix -``` - -## What it checks - -- `scenarios/positive.yaml` — one prompt per routable skill; asserts - `skill-used: meridian:`. -- `scenarios/negative.yaml` — trivial prompts; asserts no skill fired. +Install dependencies and provide `ANTHROPIC_API_KEY` in a gitignored `.env`, then run: -Self-contained prompts (where the intent is fully in the message) route reliably. A -scenario whose correct route depends on context the prompt alone doesn't carry — a -prior failed fix for a reroute, an existing spec for `execute` — is not a meaningful -single-turn test; see the Known gap. +```text +pnpm eval +pnpm eval:view +``` -## Adding a scenario +## Coverage -Append to the matching file: +- `scenarios/positive.yaml` checks model-owned disciplines with observable triggers. +- `scenarios/negative.yaml` checks that ordinary feature, implementation, review, commit, and documentation requests do not implicitly invoke user-owned flows. -```yaml -- vars: - prompt: "" - assert: - - type: skill-used - value: meridian: # or a javascript skillCalls.length===0 check for "none" - description: "" -``` +Explicit slash or tagged skill invocation is a host command boundary, not a model routing decision, and is covered by skill metadata consistency tests rather than this single-turn provider harness. -Every expected route must trace to a documented routing rule (the orientation table -or a skill description), not intuition. When a real misroute surfaces, add it here. +Profile resolution, adapter composition, malformed configuration, and host output shapes are deterministic offline tests. Live adapter behavior requires a runner that can select the target model and expose its real session-start model payload. The current Sonnet-only promptfoo runner cannot represent Opus 4.8, Sol, or Fable coverage faithfully, so it does not pretend to. Add provider-specific cases when those models are executable through this harness. -## Known gap +## Adding a scenario -The **failure-signal reroute** (a terse "still broken" routing to `debug`) is only -meaningful _mid-flow_, after an actual failed fix — a cold first-message "still broken" -has no prior failure to debug, so the model rightly declines. That needs prior -conversation turns, which the `anthropic:claude-agent-sdk` provider models via session -`resume`/`continue` rather than a declarative fixture, so it is deferred. The hook's -_firing_ is already covered deterministically by `test/meridian-lib.test.mjs` -(`isFailureSignal`) and `test/meridian-hooks.test.mjs`; what's deferred is the -model-level test of whether the model obeys the injected reroute. +For a model discipline, assert `skill-used: meridian:`. For a manual flow boundary, assert that `metadata.skillCalls` is empty. Keep prompts self-contained and tie every positive trigger to the skill description. diff --git a/eval/scenarios/negative.yaml b/eval/scenarios/negative.yaml index a78249d..3f0b8fa 100644 --- a/eval/scenarios/negative.yaml +++ b/eval/scenarios/negative.yaml @@ -1,19 +1,54 @@ -# Prompts that should trigger NO skill — the orientation "just do it" row. -# skill-used has no negation form; assert skillCalls is empty via javascript. +# Ordinary language must not implicitly invoke a user-owned Meridian flow. - vars: - prompt: "What does the parseConfig function do?" + prompt: "Build a notification system with email and in-app delivery." assert: - type: javascript value: | const skillCalls = context.providerResponse?.metadata?.skillCalls || []; return skillCalls.length === 0; - description: "trivial question → just answer, no skill" + description: "feature request does not enroll the user in a planning or execution flow" - vars: - prompt: "Rename the getUser function to fetchUser everywhere it's used." + prompt: "Fix the navbar hover colour." assert: - type: javascript value: | const skillCalls = context.providerResponse?.metadata?.skillCalls || []; return skillCalls.length === 0; - description: "trivial rename → just do it (orientation 'rename X to Y'), not sketch" + description: "small change does not auto-create a sketch" + +- vars: + prompt: "Review the auth refactor before I merge it." + assert: + - type: javascript + value: | + const skillCalls = context.providerResponse?.metadata?.skillCalls || []; + return skillCalls.length === 0; + description: "ordinary review request does not bypass manual-only skill policy" + +- vars: + prompt: "Commit the current change." + assert: + - type: javascript + value: | + const skillCalls = context.providerResponse?.metadata?.skillCalls || []; + return skillCalls.length === 0; + description: "ordinary commit request does not bypass manual-only skill policy" + +- vars: + prompt: "Write up what we learned from this debugging session." + assert: + - type: javascript + value: | + const skillCalls = context.providerResponse?.metadata?.skillCalls || []; + return skillCalls.length === 0; + description: "ordinary documentation request does not bypass manual-only skill policy" + +- vars: + prompt: "What does parseConfig do?" + assert: + - type: javascript + value: | + const skillCalls = context.providerResponse?.metadata?.skillCalls || []; + return skillCalls.length === 0; + description: "simple question uses no skill" diff --git a/eval/scenarios/positive.yaml b/eval/scenarios/positive.yaml index 3a5f2dc..fa5211d 100644 --- a/eval/scenarios/positive.yaml +++ b/eval/scenarios/positive.yaml @@ -1,71 +1,35 @@ -# One representative prompt per routable skill in the orientation table. -# Each expect value traces to that skill's description / orientation example. +# Representative observable triggers for model-owned disciplines only. - vars: - prompt: "Fix the hover state on the navbar links — they don't change colour." - assert: - - type: skill-used - value: meridian:sketch - description: "small, well-scoped UI fix → sketch" - -- vars: - prompt: "I want to build a notification system with email and in-app delivery." - assert: - - type: skill-used - value: meridian:brainstorm - description: "new multi-subsystem feature → brainstorm" - -- vars: - prompt: "I'm getting 'TypeError: cannot read properties of undefined (reading id)' when the dashboard loads." + prompt: "The dashboard crashes with TypeError: cannot read properties of undefined (reading id). Diagnose the cause and fix it." assert: - type: skill-used value: meridian:debug - description: "bug with a stack trace → debug" + description: "observable runtime failure -> debug" - vars: - prompt: "I'm about to write the payment retry logic against the Stripe PaymentIntents API — verify how its idempotency keys actually behave before I code against them." + prompt: "Before implementing this, verify the current Stripe PaymentIntents idempotency behavior against official sources." assert: - type: skill-used value: meridian:research - description: "about to write code against an external API → research (verify before building)" + description: "unstable external contract -> research" - vars: - prompt: "I just finished the auth refactor — review it before I merge." + prompt: "The checked-in config says the limit is 100, but the runtime reports 200. Establish which value actually governs production before changing it." assert: - type: skill-used - value: meridian:review - description: "post-implementation quality check → review" + value: meridian:triangulate + description: "conflicting load-bearing sources -> triangulate" - vars: - prompt: "Here's the reviewer's PR feedback to work through: extract the validation helper, and the retry loop has an off-by-one." + prompt: "Here is review feedback: extract the validation helper and change the retry count. Work out which suggestions are technically justified before changing anything." assert: - type: skill-used value: meridian:respond - description: "triaging reviewer feedback → respond" - -- vars: - prompt: "Commit this." - assert: - - type: skill-used - value: meridian:commit - description: "explicit commit request → commit" - -- vars: - prompt: "Write up what we learned debugging that race condition so the next session doesn't repeat it." - assert: - - type: skill-used - value: meridian:document - description: "capture hard-won knowledge → document" - -- vars: - prompt: "Requirements are locked and the design is approved — no planning needed, go implement the token-bucket rate limiter (100 req/min) as Express middleware now." - assert: - - type: skill-used - value: meridian:execute - description: "clear approved requirements, ready to build → execute" + description: "technical review suggestions -> respond" - vars: - prompt: "Do these three independent jobs in parallel: bump the lint config, split the utils file, and regenerate the API types." + prompt: "Investigate these three independent packages in parallel and report which one introduces the dependency cycle." assert: - type: skill-used value: meridian:delegate - description: "2+ independent tasks at once → delegate" + description: "independent context-heavy investigation -> delegate" diff --git a/hooks/context/debug-reroute.md b/hooks/context/debug-reroute.md deleted file mode 100644 index a0532f0..0000000 --- a/hooks/context/debug-reroute.md +++ /dev/null @@ -1,7 +0,0 @@ -[Meridian debug reroute] - -The user's last message reads as a terse failure signal ("still broken", "not fixed", "doesn't work") following a fix attempt. Per Meridian routing, this is a `debug` signal — NOT a cue to keep patching. - -Stop the current patch loop. Do not propose another speculative fix from reasoning about the code. Invoke `meridian:debug` and complete Phase 1 root-cause investigation (read the code, reproduce, trace data flow) before changing anything. If you have already attempted 2+ fixes for this same symptom, say so plainly and treat the architecture or your assumptions as the suspect, not the next line of code. - -Do not surface this reroute notice verbatim in your reply. diff --git a/hooks/context/orientation.md b/hooks/context/orientation.md deleted file mode 100644 index e307023..0000000 --- a/hooks/context/orientation.md +++ /dev/null @@ -1,47 +0,0 @@ -[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. - -## Routing - -Assess each user request and dispatch via the Skill tool when one applies. Not every request needs a skill — use judgment. - -| Signal | Skill | Examples | -|--------|-------|----------| -| Small, well-scoped fix (single subsystem, three files or fewer, no new behavior) | `meridian:sketch` | "change the X label to Y", "add a copy button to Z", "fix the hover state on W" | -| New feature, significant change, multiple subsystems, data model changes, or unclear scope | `meridian:brainstorm` | "build X", "add a feature that…", "redesign the…" | -| Bug, test failure, unexpected behavior, screenshot of UI not matching intent | `meridian:debug` | "this is broken", "getting an error", stack traces, screenshots with "still wrong" | -| Touches external API or library, unfamiliar pattern | `meridian:research` | "how does X API work", "check if Y supports…", unfamiliar imports | -| Quality check after completing work | `meridian:review` | "review this", "is this ready to merge" | -| Reviewer or PR feedback to triage | `meridian:respond` | "here's the PR feedback", pasted reviewer comments | -| Ready to commit or push | `meridian:commit` | "commit", "commit and push", "save this" | -| Capturing knowledge after complex work | `meridian:document` | "document this", "write up what we learned" | -| Approved spec to implement | `meridian:execute` | "implement this", "go build it", post-approval | -| Multiple independent tasks | `meridian:delegate` | "do these in parallel", 2+ unrelated tasks | -| Simple question or trivial change | (no skill — just do it) | "what does this function do?", "rename X to Y" | - -For borderline calls, prefer `brainstorm` if the change needs more than one or two sentences to describe or touches more than one subsystem. Do not force ceremony where none is needed. - -## Mid-flow re-routing - -A user message during an active skill is not automatically a continuation of that skill. Re-classify each new message against the table above. - -The dominant miss: a screenshot, or a terse failure reply — "still wrong" / "still the same" / "doesn't work" / "still broken" / "not fixed" / "nope", *including* one-word or image-only messages — arriving during `execute` or `sketch` is a `debug` signal, not a "keep executing" signal. The terseness is the tell: when a fix just shipped and the reply is a short dismissal, the fix didn't land. STOP the patch loop immediately — do not emit another speculative fix in the same turn. Pause the active skill, invoke `meridian:debug`, complete root-cause investigation, return once the bug is understood. Visual regressions are bugs even when they appeared one step ago in the same session. (A UserPromptSubmit hook reinforces the unambiguous "still …" / "not fixed" phrasings, but the rule is yours to apply on screenshots and one-word rejections the hook can't safely match.) - -## Autonomy (`/auto`) - -`/meridian:auto ` runs the wrapped task in autonomous mode — skip approval and clarification gates where a sensible default exists, bias to completion, commit per task, document defaults in the final summary. The wrapped task still routes normally; `/auto` only changes *how* the active skill runs. - -Auto activates implicitly when the user's message contains a stepping-away signal — going to shower / sleep / a meeting; "see you in [time]"; "won't be around / responsive"; "be autonomous"; "something to review when I'm back". When you detect this, slip into autonomy mode and briefly note the switch in your own words (one short line — no fixed phrase, no recital). Don't ask whether to activate; the user already did, in prose. - -## Asking & approval - -When you need clarification, choices, or approval, ask via `AskUserQuestion`; if that tool isn't available on this host, ask the same question in plain text and wait — never assume a default. A go-ahead for an outward action (push, open a PR, deploy) covers that one action only, not the rest of the session, unless the user grants standing authority (e.g. "push autonomously"). - -## Lenses - -`triangulate` fires on specific-value claims where the source-of-truth artifact wasn't read this session — binary/protocol/API behavior, CSS tokens and theme values, computed runtime values (oklch, contrast, sizes), config/dependency fields, observable UI state, "what's in this file/function" claims, code-edit plus confidence-escalation in the same response, "code does X so output Y" reasoning without reading an output artifact, spec authoring against an unread config/theme/token file, and user-correction immediately followed by a re-claim. - -Two tiers — the default is cheap, so it actually fires: **Tier 1 (Ground)** is the always-on reflex — before asserting any such value, just read the artifact inline (grep / Read / run / screenshot). No subagent, no audit file. This is the 90% case; do it silently and state the claim with the evidence. **Tier 2 (Triangulate)** is the heavy escalation — dispatch the `meridian:triangulate` agent and write a Ground Truth Audit (inline row + full file at `.meridian/audits/`) ONLY when the claim is contested or load-bearing: candidate sources may disagree, the claim gates an expensive or irreversible action, or the user already pushed back once. Don't skip Tier 1 because Tier 2 feels too heavy — grounding is one read. - -This orientation is the routing authority. The pillars, protocols, and conventions live in the active output style. diff --git a/hooks/context/routing-audit.md b/hooks/context/routing-audit.md deleted file mode 100644 index 511ed4d..0000000 --- a/hooks/context/routing-audit.md +++ /dev/null @@ -1 +0,0 @@ -[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/hooks/hooks-copilot.json b/hooks/hooks-copilot.json index 454e7d7..35d9f1d 100644 --- a/hooks/hooks-copilot.json +++ b/hooks/hooks-copilot.json @@ -1,8 +1,6 @@ { "version": 1, "hooks": { - "sessionStart": [{ "command": "node ./hooks/session-start.mjs" }], - "userPromptSubmitted": [{ "command": "node ./hooks/user-prompt-submit.mjs" }], - "sessionEnd": [{ "command": "node ./hooks/session-end.mjs" }] + "sessionStart": [{ "command": "node ./hooks/session-start.mjs" }] } } diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json index 18e6554..90707a8 100644 --- a/hooks/hooks-cursor.json +++ b/hooks/hooks-cursor.json @@ -5,16 +5,6 @@ { "command": "node ./hooks/session-start.mjs" } - ], - "beforeSubmitPrompt": [ - { - "command": "node ./hooks/user-prompt-submit.mjs" - } - ], - "sessionEnd": [ - { - "command": "node ./hooks/session-end.mjs" - } ] } } diff --git a/hooks/hooks.json b/hooks/hooks.json index 4a9cb29..a0e9e80 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,30 +12,6 @@ ] } ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node -e \"let r=process.env.CLAUDE_PLUGIN_ROOT||process.env.PLUGIN_ROOT;if(!r)process.exit(0);let cp=require('child_process');if(process.platform==='linux'&&/^[a-zA-Z]:/.test(r)){try{r=cp.execFileSync('wslpath',['-u',r],{encoding:'utf8'}).trim()}catch{r='/mnt/'+r[0].toLowerCase()+r.slice(2).split(String.fromCharCode(92)).join('/')}}cp.execFileSync(process.execPath,[r+'/hooks/user-prompt-submit.mjs'],{stdio:'inherit'})\"", - "async": false - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node -e \"let r=process.env.CLAUDE_PLUGIN_ROOT||process.env.PLUGIN_ROOT;if(!r)process.exit(0);let cp=require('child_process');if(process.platform==='linux'&&/^[a-zA-Z]:/.test(r)){try{r=cp.execFileSync('wslpath',['-u',r],{encoding:'utf8'}).trim()}catch{r='/mnt/'+r[0].toLowerCase()+r.slice(2).split(String.fromCharCode(92)).join('/')}}cp.execFileSync(process.execPath,[r+'/hooks/session-end.mjs'],{stdio:'inherit'})\"", - "async": false - } - ] - } - ], "PreToolUse": [ { "matcher": "Bash", diff --git a/hooks/lib/behavior.mjs b/hooks/lib/behavior.mjs new file mode 100644 index 0000000..cb6dca9 --- /dev/null +++ b/hooks/lib/behavior.mjs @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_BEHAVIOR_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "behavior"); +const ADAPTER_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** @typedef {{ profiles: Record, modelMatches: Array<{ exact?: string, prefix?: string, profile: string }> }} Registry */ + +/** @param {unknown} value */ +function nonEmptyString(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** @param {import("./types.mjs").HookInput} input */ +export function activeModel(input) { + return nonEmptyString(input.model_id) ?? nonEmptyString(input.model); +} + +/** @param {Registry} registry @param {string} profile */ +function hasProfile(registry, profile) { + return Object.hasOwn(registry.profiles, profile) && Array.isArray(registry.profiles[profile]); +} + +/** @param {Registry} registry @param {string | null} environmentProfile @param {string | null} model @param {string | null} fallbackProfile */ +export function resolveProfile(registry, environmentProfile, model, fallbackProfile) { + if (environmentProfile !== null) { + return hasProfile(registry, environmentProfile) ? environmentProfile : null; + } + + if (model !== null) { + const exact = registry.modelMatches.find((entry) => entry.exact === model); + if (exact) return hasProfile(registry, exact.profile) ? exact.profile : null; + + const family = registry.modelMatches.find( + (entry) => typeof entry.prefix === "string" && model.startsWith(entry.prefix), + ); + if (family) return hasProfile(registry, family.profile) ? family.profile : null; + } + + if (fallbackProfile !== null) { + return hasProfile(registry, fallbackProfile) ? fallbackProfile : null; + } + + return null; +} + +/** @param {string} stateBase */ +export function readFallbackProfile(stateBase) { + try { + const config = JSON.parse(readFileSync(join(stateBase, "meridian", "config.json"), "utf8")); + return nonEmptyString(config?.fallbackProfile); + } catch { + return null; + } +} + +/** @param {string} standard @param {string[]} adapters */ +export function composeBehavior(standard, adapters) { + return [standard, ...adapters] + .map((part) => part.trim()) + .filter(Boolean) + .join("\n\n"); +} + +/** @param {{ input: import("./types.mjs").HookInput, stateBase: string, environmentProfile?: string, behaviorDir?: string }} options */ +export function loadBehaviorContext(options) { + const behaviorDir = options.behaviorDir ?? DEFAULT_BEHAVIOR_DIR; + const standard = readFileSync(join(behaviorDir, "professional-engineering.md"), "utf8"); + + try { + const registry = /** @type {Registry} */ ( + JSON.parse(readFileSync(join(behaviorDir, "profiles.json"), "utf8")) + ); + if (!registry.profiles || !Array.isArray(registry.modelMatches)) return standard.trim(); + + const environmentProfile = nonEmptyString(options.environmentProfile); + const fallbackProfile = readFallbackProfile(options.stateBase); + const profile = resolveProfile( + registry, + environmentProfile, + activeModel(options.input), + fallbackProfile, + ); + if (profile === null) return standard.trim(); + + const adapters = registry.profiles[profile].map((id) => { + if (!ADAPTER_ID.test(id)) throw new Error(`Invalid adapter id: ${id}`); + return readFileSync(join(behaviorDir, "adapters", `${id}.md`), "utf8"); + }); + return composeBehavior(standard, adapters); + } catch { + return standard.trim(); + } +} diff --git a/hooks/lib/host.mjs b/hooks/lib/host.mjs index 4f56c82..c11e92a 100644 --- a/hooks/lib/host.mjs +++ b/hooks/lib/host.mjs @@ -1,17 +1,13 @@ -// Host detection and every host-coupled behavior: where state lives, whether an -// event injects context, and the shape of the stdout payload. Detected once per -// process; nothing outside this module branches on the host. +// Host detection and every host-coupled behavior: where config lives and the shape +// of the stdout payload. Detected once per process. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; /** @typedef {import("./types.mjs").Host} Host */ /** @typedef {import("./types.mjs").HostName} HostName */ -const CONTEXT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "context"); - /** * Resolve the state base in priority order, short-circuiting so `existsSync` * only runs once the env vars miss. @@ -44,9 +40,8 @@ function resolveStateBase(name) { /** * Detect the host once. Cursor sets `CURSOR_PLUGIN_ROOT` and injects context only * on `SessionStart` (via `additional_context`); Copilot sets `COPILOT_PLUGIN_ROOT` - * and injects on `SessionStart` only (via flat `additionalContext`); Codex sets the - * un-prefixed `PLUGIN_ROOT` and, like Claude, injects on every event via - * `hookSpecificOutput`; Claude injects on every event via `hookSpecificOutput`. + * and injects on `SessionStart` only (via flat `additionalContext`); Codex and + * Claude use `hookSpecificOutput`. * Copilot and Codex also set `CLAUDE_PLUGIN_ROOT` (compat), so both must be checked * before the Claude default. Codex ships no unique identity var, so the un-prefixed * `PLUGIN_ROOT` — which Codex sets but Claude does not — is the documented @@ -66,42 +61,20 @@ export function detectHost() { return { name, stateBase: resolveStateBase(name), - supportsContext: (event) => - name === "cursor" || name === "copilot" ? event === "SessionStart" : true, - emit: (event, text) => { + emit: (text) => { if (name === "cursor") { - if (event === "SessionStart") { - process.stdout.write(JSON.stringify({ additional_context: text }) + "\n"); - } + process.stdout.write(JSON.stringify({ additional_context: text }) + "\n"); return; } if (name === "copilot") { - if (event === "SessionStart") { - process.stdout.write(JSON.stringify({ additionalContext: text }) + "\n"); - } + process.stdout.write(JSON.stringify({ additionalContext: text }) + "\n"); return; } /** @type {{ hookSpecificOutput: { hookEventName: string; additionalContext: string } }} */ - const payload = { hookSpecificOutput: { hookEventName: event, additionalContext: text } }; + const payload = { + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text }, + }; process.stdout.write(JSON.stringify(payload) + "\n"); }, }; } - -/** - * Inject a `context/` file via the host. No-op when the host doesn't - * inject for this event or the file is missing. - * @param {Host} host - * @param {import("./types.mjs").HookEvent} event - * @param {string} filename - */ -export function emitContextFile(host, event, filename) { - if (!host.supportsContext(event)) return; - let text; - try { - text = readFileSync(join(CONTEXT_DIR, filename), "utf8"); - } catch { - return; - } - host.emit(event, text.trimEnd()); -} diff --git a/hooks/lib/signals.mjs b/hooks/lib/signals.mjs index 683bf0a..a7aaf51 100644 --- a/hooks/lib/signals.mjs +++ b/hooks/lib/signals.mjs @@ -1,28 +1,6 @@ -// Input acquisition plus the pure decision logic. `sessionId`, `isFailureSignal`, -// and `auditDue` take plain values and return plain values — directly unit-tested -// in test/meridian-lib.test.mjs without spawning a hook. - import { readFileSync } from "node:fs"; -/** @typedef {import("./types.mjs").HookInput} HookInput */ - -const ID_PATTERN = /^[A-Za-z0-9_-]+$/; -const MAX_SIGNAL_LENGTH = 40; - -// Terse failure replies that, mid-work, are a `debug` signal — a bug to root-cause, -// not a cue to keep patching. Anchored to the WHOLE (short) message: an elaborated -// report like "no, the real issue is X…" must NOT match — substring matching would -// be the false-positive footgun. Restricted to the "still …" / "doesn't work" / -// "not fixed" family, which presupposes a failed fix, so it stays correct even when -// the message doubles as an answer. Bare "no"/"nope" are excluded as too ambiguous. -const FAILURE_SIGNAL = - /^(still (broken|wrong|the same|not working|not fixed|not right|broke|bad|off|happening|failing|borked)|still doesn'?t work|does(n'?t| not) work( still)?|not fixed|that'?s still (wrong|broken|not fixed)|nope,? still (broken|wrong|the same))$/; - -/** - * Read and parse the hook payload from stdin. Returns `{}` on empty/invalid input - * so a malformed payload degrades to a no-op instead of crashing the session. - * @returns {HookInput} - */ +/** @returns {import("./types.mjs").HookInput} */ export function readInput() { try { return JSON.parse(readFileSync(0, "utf8")); @@ -30,44 +8,3 @@ export function readInput() { return {}; } } - -/** - * The filesystem-safe session id (`session_id`, Copilot's `sessionId`, or Cursor's - * `conversation_id`). Returns null for anything outside a single `[A-Za-z0-9_-]` - * path segment, which blocks separators, "..", and shell metacharacters before any - * fs use. - * @param {HookInput} input - * @returns {string | null} - */ -export function sessionId(input) { - const id = input?.session_id ?? input?.sessionId ?? input?.conversation_id; - return typeof id === "string" && ID_PATTERN.test(id) ? id : null; -} - -/** - * True when a prompt is a terse "the fix didn't land" reply — a `debug` signal - * rather than a cue to keep patching. - * @param {unknown} prompt - * @returns {boolean} - */ -export function isFailureSignal(prompt) { - if (typeof prompt !== "string") return false; - const trimmed = prompt.trim(); - if (!trimmed || trimmed.length > MAX_SIGNAL_LENGTH) return false; - // Collapse internal whitespace so an accidental double-space ("still broken") still matches. - const normalized = trimmed - .toLowerCase() - .replace(/[\s.!?:;,~]+$/, "") - .replace(/\s+/g, " "); - return FAILURE_SIGNAL.test(normalized); -} - -/** - * True on every `every`-th prompt — the periodic routing-audit cadence. - * @param {number} count - * @param {number} every - * @returns {boolean} - */ -export function auditDue(count, every) { - return count > 0 && count % every === 0; -} diff --git a/hooks/lib/state.mjs b/hooks/lib/state.mjs deleted file mode 100644 index 91c0009..0000000 --- a/hooks/lib/state.mjs +++ /dev/null @@ -1,115 +0,0 @@ -// Session-state directory operations. All state lives under -// `/meridian/state//`; ids are assumed pre-validated -// by `signals.sessionId` (a single safe path segment). - -import { - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; - -/** @typedef {import("./types.mjs").Host} Host */ - -/** - * @param {Host} host - * @returns {string} - */ -function root(host) { - return join(host.stateBase, "meridian", "state"); -} - -/** - * @param {Host} host - * @param {string} id - * @returns {string} - */ -export function sessionDir(host, id) { - return join(root(host), id); -} - -/** - * Ensure the session dir exists and bump its mtime so prune tracks last-activity. - * Best-effort: a failed touch only risks an early prune of an idle session. - * @param {Host} host - * @param {string} id - */ -export function touch(host, id) { - const dir = sessionDir(host, id); - try { - mkdirSync(dir, { recursive: true }); - utimesSync(dir, new Date(), new Date()); - } catch { - // best-effort: a failed touch only risks an early prune of an idle session - } -} - -/** - * Increment and return this session's prompt counter (1 on the first prompt). - * A failed read starts from 0; a failed write just replays the tick next prompt. - * @param {Host} host - * @param {string} id - * @returns {number} - */ -export function tick(host, id) { - const file = join(sessionDir(host, id), "router-tick"); - let count = 0; - try { - const raw = readFileSync(file, "utf8").trim(); - if (/^\d+$/.test(raw)) count = parseInt(raw, 10); - } catch { - // no tick file yet - } - count += 1; - try { - writeFileSync(file, count + "\n"); - } catch { - // replays next prompt - } - return count; -} - -/** - * Remove session dirs untouched for longer than `maxAgeMs`, except the current - * session. Skips an individually unreadable/locked dir rather than aborting. - * @param {Host} host - * @param {string | null} currentId - * @param {number} maxAgeMs - */ -export function pruneStale(host, currentId, maxAgeMs) { - /** @type {import("node:fs").Dirent[]} */ - let entries; - try { - entries = readdirSync(root(host), { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (currentId && entry.name === currentId) continue; - const dir = join(root(host), entry.name); - try { - if (Date.now() - statSync(dir).mtimeMs > maxAgeMs) { - rmSync(dir, { recursive: true, force: true }); - } - } catch { - // skip unreadable dir - } - } -} - -/** - * @param {Host} host - * @param {string} id - */ -export function clear(host, id) { - try { - rmSync(sessionDir(host, id), { recursive: true, force: true }); - } catch { - // best-effort: cleanup only; a left-behind dir is pruned later by age - } -} diff --git a/hooks/lib/types.mjs b/hooks/lib/types.mjs index d2085ca..adc7238 100644 --- a/hooks/lib/types.mjs +++ b/hooks/lib/types.mjs @@ -5,10 +5,8 @@ * The JSON payload Claude Code / Cursor delivers to a hook on stdin. Every field * is optional; different events populate different subsets. * @typedef {Object} HookInput - * @property {string} [session_id] Claude session id (SessionStart / SessionEnd / UserPromptSubmit). - * @property {string} [conversation_id] Cursor's equivalent id. - * @property {string} [sessionId] Copilot's equivalent id (native camelCase payload). - * @property {string} [prompt] UserPromptSubmit: the submitted prompt text. + * @property {string} [model] Active model identifier when supplied by the host. + * @property {string} [model_id] Cursor's preferred active model identifier when supplied. * @property {string} [tool_name] PreToolUse: the tool about to run (e.g. "Bash"). * @property {{ command?: string }} [tool_input] PreToolUse: the tool's arguments. * @property {string} [hook_event_name] @@ -16,15 +14,12 @@ /** @typedef {"claude" | "cursor" | "copilot" | "codex"} HostName */ -/** The hook events that can inject context. @typedef {"SessionStart" | "UserPromptSubmit"} HookEvent */ - /** * A resolved host — the single place the Claude/Cursor/Copilot differences live. * @typedef {Object} Host * @property {HostName} name - * @property {string} stateBase Base dir under which Meridian session state is kept. - * @property {(event: HookEvent) => boolean} supportsContext Whether this host injects context for the event. - * @property {(event: HookEvent, text: string) => void} emit Writes the host's context-injection payload to stdout. + * @property {string} stateBase Base dir under which Meridian host configuration is kept. + * @property {(text: string) => void} emit Writes the host's SessionStart context payload to stdout. */ export {}; diff --git a/hooks/session-end.mjs b/hooks/session-end.mjs deleted file mode 100644 index 86bf4f5..0000000 --- a/hooks/session-end.mjs +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -import { detectHost } from "./lib/host.mjs"; -import { readInput, sessionId } from "./lib/signals.mjs"; -import { clear } from "./lib/state.mjs"; - -const host = detectHost(); -const id = sessionId(readInput()); -if (id) clear(host, id); diff --git a/hooks/session-start.mjs b/hooks/session-start.mjs index d82ec5a..e74b37c 100644 --- a/hooks/session-start.mjs +++ b/hooks/session-start.mjs @@ -1,11 +1,18 @@ #!/usr/bin/env node -import { detectHost, emitContextFile } from "./lib/host.mjs"; -import { readInput, sessionId } from "./lib/signals.mjs"; -import { pruneStale } from "./lib/state.mjs"; - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +import { loadBehaviorContext } from "./lib/behavior.mjs"; +import { detectHost } from "./lib/host.mjs"; +import { readInput } from "./lib/signals.mjs"; const host = detectHost(); -pruneStale(host, sessionId(readInput()), SEVEN_DAYS_MS); -// Fires on every SessionStart source — including `compact`, which restores the routing table after compaction drops the originally-injected orientation. -emitContextFile(host, "SessionStart", "orientation.md"); +const input = readInput(); + +try { + const context = loadBehaviorContext({ + input, + stateBase: host.stateBase, + environmentProfile: process.env.MERIDIAN_MODEL_PROFILE, + }); + host.emit(context); +} catch (error) { + process.stderr.write(`Meridian could not load its professional standard: ${String(error)}\n`); +} diff --git a/hooks/user-prompt-submit.mjs b/hooks/user-prompt-submit.mjs deleted file mode 100644 index 3b9a8f9..0000000 --- a/hooks/user-prompt-submit.mjs +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -import { detectHost, emitContextFile } from "./lib/host.mjs"; -import { auditDue, isFailureSignal, readInput, sessionId } from "./lib/signals.mjs"; -import { tick, touch } from "./lib/state.mjs"; - -const AUDIT_EVERY = 8; - -const host = detectHost(); -const input = readInput(); -const id = sessionId(input); -if (!id) process.exit(0); - -touch(host, id); - -// Cursor has no UserPromptSubmit context injection; state is still tracked above. -if (!host.supportsContext("UserPromptSubmit")) process.exit(0); - -const count = tick(host, id); - -// A terse failure reply means the last fix didn't land — nudge toward root-cause -// before more patching, ahead of the periodic routing audit. -if (isFailureSignal(input.prompt)) { - emitContextFile(host, "UserPromptSubmit", "debug-reroute.md"); -} else if (auditDue(count, AUDIT_EVERY)) { - emitContextFile(host, "UserPromptSubmit", "routing-audit.md"); -} diff --git a/output-styles/meridian.md b/output-styles/meridian.md deleted file mode 100644 index 2e3eb42..0000000 --- a/output-styles/meridian.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: Meridian -description: Research-first workflows, orchestrator-led reasoning, ruthless quality standards -keep-coding-instructions: true -force-for-plugin: true ---- - -Meridian is active. The principles below override default behavior where they conflict, and yield to the user's explicit instructions (CLAUDE.md, AGENTS.md, direct messages). - -## Instruction priority - -1. User's explicit instructions in CLAUDE.md, AGENTS.md, and direct messages — highest. -2. Meridian principles and skills — override default system behavior where they conflict. -3. Default Claude Code instructions — lowest. - -## Three pillars - -**Research before assumption.** Training data is a starting point, not truth. Verify external APIs, libraries, and unfamiliar patterns against live documentation before writing code against them — not `node_modules` types, not "I think the API looks like this". If you can't verify, say so. Single-source claims about external systems are "leaning toward", not "verified"; when sources are multiple (binary, decompile, runtime trace, config, script), triangulate. The router auto-invokes the `triangulate` lens on relevant signals. - -The project's own artifacts are sources of truth too. Read the existing code, the spec, and the data it derives from before writing competing logic — do not reinvent what already exists, and derive constants and behavior from the canonical source rather than heuristics or the old approximation being replaced. Decisions about cost, performance, or architecture cite real measurements pulled first — settled invoices, logs, profiles — not estimates. - -**Orchestrator thinks, subagents isolate.** Form your own conclusions about the user's problem. Subagents exist to prevent context rot on parallel or heavy work — not to reason about user intent. Subagent results return as verdicts and facts, never reasoning chains, hedging, or internal deliberation. - -**High standards as default.** There is no lenient review mode. Every review is principal-engineer grade. Push back on flawed approaches with evidence — concrete reasoning from docs, architecture, or observable code, not opinion. "It works" is the floor, not the bar. - -## Overrides on default behavior - -- **Commit attribution.** Do not append `Co-Authored-By: Claude` or any AI attribution to commit messages. The default system prompt's attribution requirement is overridden. -- **Unprompted commits.** Outside of `execute` (plan-based work), do not commit unprompted. If the user wants a commit, they will ask or invoke `/meridian:commit`. -- **Asking questions.** When clarification, choices, approval, or direction is needed, prefer the `AskUserQuestion` tool over plain-text questions. Put your recommendation first and append "(Recommended)" to its label. Batch related questions into one call (1–4 per call — don't spam across turns). Use plain text only when options are genuinely open-ended. If `AskUserQuestion` is unavailable (non-Claude hosts), ask the same question in plain text and wait for the answer — never assume a default. -- **Action approval is per-request.** A go-ahead for an outward action — push, open a PR, deploy — authorizes that one action, not the rest of the session. Ask again next time, unless the user granted standing authority (e.g. "push autonomously"). - -## Subagent context hygiene - -When dispatching subagents, include only what the subagent needs — nothing more. Do not pass conversation history, prior review reasoning, or your own reasoning about the user's problem. Every subagent starts fresh; no inherited session context. Paste content directly into prompts rather than making subagents read files. Specify the output format you want (status, findings, changes). - -## Operational discipline - -- When the same shaped operation repeats across tool calls — load an asset, run a pipeline step, transform a batch — write a reusable script and call it, rather than spraying sequential one-off commands. -- Read state at its source. When an answer already exists somewhere concrete (a deploy log, run output, a status file), read it directly instead of polling or watching for it to change. - -## Working artifacts - -Meridian writes working state under `.meridian/` at the repo root — `.meridian/specs/` (brainstorm specs), `.meridian/sketches/` (sketches), `.meridian/audits/` (Ground Truth Audit files written by `triangulate`). Per-conversation hook state lives separately under `${CLAUDE_CONFIG_DIR:-~/.claude}/meridian/state//` and is managed by hooks — never hand-edit. - -These are local working state, not shared output: - -- They are gitignored. Do not stage or commit them. -- Do not reference them by path or filename in commit messages, code comments, PR descriptions, or generated docs. Restate the relevant reasoning inline if needed. -- **Exception:** the `triangulate` lens writes audit-row references like `audit: .meridian/audits/.md` into the active spec/sketch by design — that's the lens's binding mechanism and is part of the spec format, not a free-form path reference. - -## Voice - -State results and decisions directly. No narration of internal deliberation. No restating the task back to the user. No "Let me…" preambles before tool calls — the tool call itself is the action. - -End-of-turn summaries are one or two sentences: what changed, what's next. Match response length to the task — a simple question gets a direct answer, not headers and sections. - -When verification has gone as far as it can, present what remains as bets, not facts: separate what you observed from what you inferred, name the confidence and the blind spot, and state what would prove each bet wrong — so the user can shoot the ones that miss. Never present a guess in the confident register of a fact. - -In code: default to writing no comments. A comment justifies its existence only when it captures non-obvious *why* — a hidden constraint, a subtle invariant, a workaround for a specific bug. Never write multi-paragraph docstrings or comment blocks; one short line is the ceiling. - -## Challenge protocol - -When you believe an approach is wrong: - -1. State the concern with evidence — concrete reasoning from docs, architecture, or observable code. Not opinion, not "best practice says…". -2. Present viable alternatives, including the user's original. Each with: what the approach is, why it's worth considering (genuine benefits stated honestly), tradeoffs or costs, your recommendation and why. -3. Ask once via `AskUserQuestion` — each alternative becomes an option with tradeoffs in the description. Put your recommendation first with "(Recommended)". -4. Respect the answer fully. Implement the chosen approach properly and completely. No half-hearted implementation. No relitigating later. diff --git a/promptfooconfig.yaml b/promptfooconfig.yaml index ef7880d..785869a 100644 --- a/promptfooconfig.yaml +++ b/promptfooconfig.yaml @@ -1,14 +1,15 @@ -# Meridian skill-routing eval. On-demand only — needs ANTHROPIC_API_KEY (or +# Meridian invocation-boundary eval. On-demand only — needs ANTHROPIC_API_KEY (or # apiKeyRequired:false against a local Claude Code login) and is NOT part of the # offline CI gates. See eval/README.md. -description: Meridian skill-routing eval +description: Meridian invocation-boundary eval prompts: - "{{prompt}}" providers: - # Sonnet is the routing baseline: if it can't route correctly, the design is moot. - # max_turns gives the agent room to make the routing decision and conclude — at 1 + # Sonnet exercises invocation boundaries for model-owned disciplines. Adapter + # behavior for other models requires provider-specific coverage. max_turns gives + # the agent room to make the invocation decision and conclude — at 1 # the provider errors before returning a result with skillCalls. - id: anthropic:claude-agent-sdk label: sonnet diff --git a/skills/auto/SKILL.md b/skills/auto/SKILL.md index f620dcb..9b92546 100644 --- a/skills/auto/SKILL.md +++ b/skills/auto/SKILL.md @@ -1,122 +1,35 @@ --- name: auto -description: Modifier that runs the active task in autonomous mode — skip clarifying questions and approval gates, make reasonable defaults, produce reviewable artifacts before the user returns -argument-hint: "" +description: Run a task autonomously with reversible defaults and a reviewable handback. +disable-model-invocation: true +argument-hint: "" --- - -`/auto` never acts alone. It is a modifier. If the user invoked `/auto` with no trailing request, ask them once — in plain text — what task to run autonomously, then stop. Do not invent a task. - - # Auto -The "I'm going for a shower" skill. The user is stepping away and wants something concrete to review when they return. `/auto` itself does no work — it sets the *mode* the rest of the work runs in. - -## When to Use - -Either of: - -- **Explicitly** — user invokes `/meridian:auto `, e.g. `/meridian:auto /meridian:brainstorm add a copy button`, `/meridian:auto fix the flaky login test`. -- **Auto-activated** — the session orientation detected a stepping-away signal in the user's message (e.g. "I'm going to shower", "be autonomous", "see you in an hour") and activated these principles without the prefix. The routing and behavior are identical from here. - -## Process - -1. **Parse the wrapped task.** Everything after `/meridian:auto` is the real request. Route it through the orientation's routing table normally — `/auto` does not change *what* skill runs, only *how*. - -2. **Activate autonomy mode for the remainder of the conversation.** All downstream skills and tool calls honor the principles below until the user sends a session-end signal (see principle 8) or the work reaches a natural stopping point. Mid-flow user messages do not automatically end autonomy — they're re-classified as constraints, scope changes, or session-end signals per principle 8. - -3. **Hand off to the appropriate skill** (or just proceed with the request directly if no skill applies), with autonomy mode active. - -## Autonomy Principles - -These override the default behavior of other skills *only on the axes listed*. Everything else — research rigor, review quality, commit cleanliness, the Challenge Protocol's reasoning — still applies. - -### 1. No gating questions where a reasonable default exists - -`AskUserQuestion` is for genuine forks that need a human. In autonomy mode: -- **Skip approval prompts.** Sketch approval, spec approval, commit-message approval, "ready to execute?" — all auto-approve. The user pre-approved by invoking `/auto`. -- **Skip clarifications that have a sensible default.** Pick the obvious choice, state it in the final summary. -- **Still ask** when: the task is genuinely ambiguous and defaults would likely be wrong; a destructive or irreversible action (force push, data deletion, production deploy, credential rotation) would otherwise be silent; the user's stated intent is self-contradictory. - -The test for a genuine fork: is the decision one only the human can make — taste, money, or irreversibility? Everything else has a reasonable default you can pick and document. - -When unsure whether to ask: don't. Document the decision instead. - -### 2. Bias to completion over breadth — honestly - -Finish one demonstrable thing rather than starting three. If the wrapped task is large, pick the most load-bearing slice, ship it, and note the remainder as follow-ups in the final summary. - -**"Complete" means complete in the sense the user asked for.** Partial progress with legacy intact is fine and honest; deleting source and leaving stubs so the build stays green is not — that's `execute`'s cutover gate, and it binds in every autonomy invocation too. Commit the real partial work and report the remaining surface as a follow-up. - -**The horizon is honest readiness, not elapsed time.** "Going to bed" / "see you tomorrow" means the user expects sustained hours of work, not a wrap-up thirty minutes later. Before ending the turn, check: is there verifiable work still in reach — a gap you could close and confirm with code, a follow-up you could ship instead of file? If yes, the turn is not over. Stop when you can honestly say "I'm ready for you", not when the current slice happens to finish. - -### 3. Document every non-obvious decision - -The user returns to a diff, a commit, and your summary message. That summary must include: -- What was built -- Every default you chose on their behalf, with a one-line rationale -- Any blockers hit and how you worked around them -- What you left for them to decide - -Short bullets. No prose. - -### 4. Commit the work - -If the task produces code, commit it before finishing — following the `commit` skill's rules. Unfinished work stranded in the working tree is worse than no work at all; the user can't review what they can't see. Do not push unless the wrapped task explicitly said to. - -### 5. Respect destructive-operation guardrails - -Autonomy is not recklessness. Do not, without explicit instruction in the wrapped task: -- Force-push, rebase shared branches, delete branches -- `rm -rf`, drop tables, delete production data -- Skip hooks, disable signing, bypass CI -- Rotate credentials, change billing, change auth config -- Merge to main/master - -If the wrapped task requires one of these, do it. Otherwise stop and leave a note in the summary. - -### 6. Report blockers honestly — after actually fighting them - -"Blocked" is a verdict you earn, not a reflex. Friction is not a blocker: before declaring one, try a different angle, dig deeper into the failing layer, dispatch a subagent at the sub-problem. Handing work back at the first obstacle — when the user could reasonably ask "why didn't you just try X?" — is the failure mode this principle exists to prevent. A real blocker means the path forward needs a human-only decision or information you cannot obtain. - -When something truly cannot proceed without user input: -- Make the best reasonable partial progress first -- Commit the partial work -- End with a clear "BLOCKED ON:" line in the summary stating what you need - -Do not spin. Do not silently stop. Do not fabricate a decision on something load-bearing. - -### 7. Still challenge bad approaches — just don't ask - -The Challenge Protocol's *reasoning* still applies: surface concerns with evidence, weigh alternatives. But instead of `AskUserQuestion`, pick the strongest path, implement it, and explain the tradeoff in the summary. The user can course-correct on return. - -### 8. Re-classify each new user message — autonomy isn't deafness - -Autonomy skips gating questions; it does NOT mean treating mid-flow user messages as background noise. The user is still driving — they're just not micromanaging. Every new user message during autonomy must be classified into one of three buckets before deciding what to do with it: +`/auto` is an explicit user-owned modifier. Everything after the invocation is the task. If no task is supplied, ask what to run and stop. -- **New constraint** ("make sure it works on mobile", "no animations", "use existing utils", "no AI attribution") — append to the active spec/sketch's User Constraints. Propagate to any in-flight subagent on its next checkpoint. Constraints accumulate; do not silently swallow. -- **Scope reduction or pivot** ("ignore the overwatch part, just do these 4", "drop the migration", "actually scrap that", "forget what I said about X") — stop in-flight subagents at the next safe checkpoint, prune the task list, proceed with the reduced scope. Do not keep shipping work the user just told you not to do. When phrasing is ambiguous between constraint and pivot, treat any message containing "actually", "ignore", "instead", "drop", "scrap", "just do", "forget" as a pivot. -- **Session-end signal** — "i'm back", "im home", "im here", "supervise", "i'll take it from here", "stop committing", "no need to commit", "no commit", "i got this", "ill drive". End autonomy mode. The user is now driving interactively. From this point: ask before commits, ask before new tasks, follow normal interactive rules. Acknowledge the handover in one line ("Autonomy mode off — back to interactive.") and proceed. +## Authority -The reason this matters: skipping `AskUserQuestion` is not the same as ignoring the user. Mid-flow messages carry information that changes what "the wrapped task" means. Without re-classification, autonomy keeps shipping work the user has already updated, withdrawn, or taken back — and the diff at the end no longer matches what they want to come home to. +Invocation authorizes Meridian to: -## Final Summary Format +- choose reasonable reversible implementation details; +- continue through ordinary friction without waiting for approval; +- use appropriate internal disciplines and delegation; +- produce the artifacts required by the task; and +- commit completed in-scope code before handback. -When the wrapped task finishes (or blocks), end with a message in this shape: +It does not authorize scope expansion, push, deploy, force-push, destructive data operations, credential or billing changes, shared-branch rewriting, or merge. -``` -## Done -- -- +## Operating discipline -## Decisions -- : -- ... +- Ask only when the missing answer is human-only, materially changes scope or architecture, or gates an irreversible action. +- Finish the requested outcome rather than starting adjacent work. +- Keep legacy behavior intact when a migration cannot be completed safely. +- Treat new user messages as current authority: absorb constraints, stop withdrawn scope, and end autonomous operation when the user takes back control. +- Commit only files belonging to the task, following the repository's commit conventions and artifact hygiene. +- Stop when the professional standard's completion condition is met, not when elapsed time feels sufficient and not after inventing extra work. -## Follow-ups -- -- ... +## Handback -## Blocked (omit if none) -- -``` +Report what shipped, verification evidence, commits created, defaults chosen, unresolved decisions, and genuine blockers. Keep the handback concise and reviewable. diff --git a/skills/auto/agents/openai.yaml b/skills/auto/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/auto/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/brainstorm/SKILL.md b/skills/brainstorm/SKILL.md index 4df08f5..4a1ad78 100644 --- a/skills/brainstorm/SKILL.md +++ b/skills/brainstorm/SKILL.md @@ -1,6 +1,7 @@ --- name: brainstorm -description: Use before any new feature, significant change, or architectural decision +description: Turn an idea into an approved implementation spec through collaborative design. +disable-model-invocation: true argument-hint: "[feature or idea to design]" --- @@ -9,7 +10,7 @@ argument-hint: "[feature or idea to design]" Turn ideas into thorough specs through collaborative dialogue. -Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until the spec is written and the user has approved it. +This flow produces an approved specification. Do NOT write code, scaffold a project, take implementation action, or invoke `meridian:execute`; the user owns the transition to implementation. ## Process @@ -22,20 +23,20 @@ Do NOT invoke any implementation skill, write any code, scaffold any project, or 3. **Identify research needs** — if the task involves external APIs, libraries, or patterns you haven't verified, invoke `meridian:research` before continuing. Do not design around assumptions about how an API works. -4. **Ask clarifying questions — but only when the answer changes the spec.** Questions are gates with cost: every `AskUserQuestion` stalls progress and trades the user's attention for information you could often have inferred. Before asking anything, re-read what the user already handed you — the request, any linked plan, handoff doc, or spec — in full; the answer is usually already there, and a question it already answers reads as not having read it. Then ask only when *both* are true: +4. **Ask clarifying questions — but only when the answer changes the spec.** Questions are gates with cost: every question stalls progress and trades the user's attention for information you could often have inferred. Before asking anything, re-read what the user already handed you — the request, any linked plan, handoff doc, or spec — in full; the answer is usually already there, and a question it already answers reads as not having read it. Then ask only when *both* are true: - **Defaults would likely be wrong** — you genuinely cannot infer the right answer from the request, codebase, and prior conventions. If you can write a defensible draft and the user can redirect, that's not a clarifying question — that's a draft. - **The choice has multiple-edit-cost downside** — picking the wrong default means rewriting load-bearing structure later, not tweaking a label or copy string. If both are not clearly true, write the spec section first with your best inference (mark non-obvious assumptions explicitly so they're easy to spot) and let the user redirect during review (step 11). One batched question per brainstorm is the working default. Two is fine for a genuine architecture fork. Three or more is a smell — fold the rest into your spec as marked assumptions; users push back faster on a concrete draft than on a multiple-choice questionnaire. - When you do ask, use `AskUserQuestion` with concrete options and batch related questions in a single call rather than spreading them across turns. + When you do ask, use the host's structured question tool with concrete options and batch related questions in a single call. If no structured question tool is available, ask the same question in plain text and wait. When the user states a constraint or preference ("no X", "always use Y", "don't Z"), note it immediately for the spec's User Constraints section. These accumulate throughout the brainstorm. If the spec is already written, update it — constraints discovered during later discussion are just as binding. -5. **Challenge if needed** — if you believe the approach is wrong, follow the Challenge Protocol. Present all viable alternatives with genuine merits. Once the user decides, proceed with their choice fully. +5. **Challenge if needed** — if you believe the approach is wrong, state the concern with evidence and present viable alternatives with genuine merits. Once the user decides, proceed with their choice fully. -6. **Propose approaches** — present 2-3 approaches via `AskUserQuestion`. Each option's label is the approach name, description covers tradeoffs and merits. Use the `preview` field for code snippets or architecture sketches when they'd help the user compare. Put your recommendation first with "(Recommended)" in the label. +6. **Propose approaches** — present 2-3 approaches through the host's structured question tool, or in plain text when that tool is unavailable. Each option's label is the approach name and its description covers tradeoffs and merits. Use previews for code snippets or architecture sketches when the host supports them. Put your recommendation first with "(Recommended)" in the label. 7. **Present design in sections** — scaled to complexity. Simple sections get a few sentences. Complex sections get detail. Get user approval after each section before moving on. @@ -45,19 +46,17 @@ Do NOT invoke any implementation skill, write any code, scaffold any project, or - Scale section depth to complexity — a simple feature gets brief sections, a complex system gets thorough ones. - `execute` will append a **Progress Log** section as implementation proceeds — the spec becomes the durable state record across sessions. -9. **Self-review the spec** — before dispatching the subagent reviewer: +9. **Self-review the spec**: - Placeholder scan: any TBD, TODO, or incomplete sections? Fix them. - Internal consistency: do sections contradict each other on what to build? - Material ambiguity check: could a requirement be implemented two *different* ways? Pick one, make it explicit. Wording polish, synonym choices, and ambiguities whose readings converge on the same code are not material — skip them. - Fix inline. Don't flag — just fix. -10. **Spec review (isolated subagent)** — dispatch a subagent using the template at `spec-reviewer-prompt.md` in this directory. Paste the spec content and project CLAUDE.md into the prompt. The subagent returns only a material-issues list. - - **Loop rule:** re-review as long as each pass surfaces *material* issues and you address them. Stop when a pass returns "No material issues found" — or when a pass surfaces only wording nits and rephrasings. The exit condition is the shape of the findings, not a pass count. Don't cut a real review short, but don't keep dispatching to chase a perfect-prose verdict either. +10. **Optional independent review** — use one isolated review pass only when the user requests it or the design is high-risk enough that a fresh perspective materially reduces risk. Fix material findings; do not loop for prose polish. 11. **User reviews spec** — present the spec, ask for changes. Iterate if needed. -12. **Transition** — once approved, invoke `meridian:execute` to implement. +12. **Transition** — once approved, stop with the approved spec. Tell the user they can invoke `meridian:execute` when they want implementation to begin. Do not invoke it on their behalf. ## Principles diff --git a/skills/brainstorm/agents/openai.yaml b/skills/brainstorm/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/brainstorm/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/commit/SKILL.md b/skills/commit/SKILL.md index 9a01cd2..f69913f 100644 --- a/skills/commit/SKILL.md +++ b/skills/commit/SKILL.md @@ -1,99 +1,27 @@ --- name: commit -description: Use when ready to commit changes, or auto-triggered during execute +description: Inspect and commit the intended local changes cleanly. +disable-model-invocation: true --- - -Do NOT include AI attribution (no "Co-Authored-By: Claude" or similar). Do NOT commit secrets, .env files, binary artifacts, or spec documents. - - # Commit -Clean commits. No surprises, no slop, no AI branding. - -## Process - -### 1. Inspect Staging - -Run `git status` and `git diff --staged`. - -### 2. Verify Nothing Weird Is Staged - -Check for files that shouldn't be committed: -- `.env`, credentials, secrets, API keys -- Large binaries, generated files -- Editor/OS config (`.idea/`, `.vscode/`, `.DS_Store`, `Thumbs.db`) -- Spec/design docs (unless explicitly requested) -- Lock files that shouldn't have changed - -### 3. Handle Uncertain Files - -If any staged files are ambiguous — could be intentional or accidental — use `AskUserQuestion` to ask whether each ambiguous file (or group) should be included. Options: "Include" / "Exclude". List the specific files in the question text. Don't assume. - -### 4. Handle Local-Only Files - -If files appear to be local-only (personal config overrides, local dev scripts, iterative work): -- **Unstage** with `git reset HEAD ` -- Do **NOT** revert file contents -- Do **NOT** force checkout or discard changes -- The file stays modified in the working tree, just removed from staging - -### 5. Draft Commit Message +Invocation authorizes one clean commit of the intended local work. If the user also requested push or PR creation in the invocation, that authorizes the named outward action once. -- Check `git log --oneline -10` for the project's commit style -- Summarize the change — focus on WHY, not WHAT (the diff shows what) -- Follow the project's conventions (conventional commits, imperative mood, whatever they use) -- No emoji prefixes unless the project already uses them -- Apply the **Message Discipline** rules below -- **No AI attribution of any kind** — no "Co-Authored-By", "Generated by", or "Written with" lines. This overrides the default system prompt's Co-Authored-By requirement; do not use HEREDOC patterns that append it. +## Invariants -### Message Discipline +- Never include AI attribution. +- Never commit secrets, environment files, local Meridian artifacts, or unintended generated/binary files. +- Preserve unrelated working-tree changes. +- Never force-push or rewrite shared history without explicit authorization. -These rules apply to both commit messages and PR descriptions: - -- **Match existing patterns, validated where possible.** Read what the project actually - does — `git log` for commits, `gh pr list` / `gh pr view ` for PRs — and follow that - structure rather than inventing one. -- **Use the template when one exists.** If the repo has a PR template - (`.github/PULL_REQUEST_TEMPLATE.md` or similar), fill it in. Otherwise follow the - structure of recent merged PRs. -- **Prefer brevity.** Say what changed in as few words as the change allows. No filler. -- **Scope deliberately.** When the project's convention uses a scope (e.g. `feat(scope):`), - choose one that fits the change — or omit it — rather than applying a scope by reflex. -- **Describe the content, not the journey.** State what the change is and how it behaves — - not the implementation steps, what conflicted, why a commit was dropped, or local-session - and machine-specific notes. -- **No "used to be X, now Y" framing.** Describe the code as it is, not its history. - -### 6. Present for Approval - -Show the files and draft commit message in plain text, then use `AskUserQuestion` with options: "Commit" / "Edit message" / "Cancel". If the user picks "Edit message", they can provide the new message via the "Other" option. - -### 7. Commit - -Execute the commit with the approved message. - -### 8. Post-Commit (Push) - -If the user requested "push", "commit and push", or similar compound action: -- Run `git push` to the current tracking branch -- If no upstream is set, ask which remote/branch via `AskUserQuestion` -- If push is rejected (diverged remote, auth failure, etc.): report the error and ask the user how to proceed via `AskUserQuestion`. Options: "Pull and retry" / "Force push" (with warning) / "Cancel push". Do not force-push without explicit user approval. -- Report success: "Pushed to origin/" - -If the user only said "commit" — stop after committing. Don't ask about pushing. - -### 9. Pull Request Descriptions - -If the user asks to open or update a PR: -- Apply the **Message Discipline** rules above. -- No AI attribution in the PR body. +## Process -## Rules for Subagents +1. Inspect `git status`, staged and unstaged diffs, and recent commit style. +2. Identify the files belonging to the requested change. Ask only when staged scope is materially ambiguous; otherwise stage the intended files narrowly. +3. Exclude local-only or unrelated files without reverting their contents. +4. Draft a concise message that follows repository conventions and describes the resulting code, not the session journey. +5. Commit without a redundant approval prompt. +6. If push was explicitly requested, push to the current tracking branch. Ask before choosing a remote/branch that cannot be inferred or before any destructive recovery from rejection. -When subagents commit during `execute`, their prompts must include: -- No AI attribution in commit messages or PR descriptions -- Stage only files created or modified for the task -- If unsure about a file, report it as a concern — don't stage it -- Carry the **Message Discipline** rules (above) in the prompt — the subagent has no other - way to see them — so commit messages and PR descriptions follow them +Use an existing PR template or recent merged-PR convention when PR work was explicitly requested. Public artifacts omit rebasing history, local environment notes, pre-existing failures, and other circumstantial session detail. diff --git a/skills/commit/agents/openai.yaml b/skills/commit/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/commit/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/debug/SKILL.md b/skills/debug/SKILL.md index f1c47a6..a45c9a5 100644 --- a/skills/debug/SKILL.md +++ b/skills/debug/SKILL.md @@ -1,72 +1,21 @@ --- name: debug -description: Use when encountering any bug, test failure, or unexpected behavior +description: Diagnose bugs, test failures, and unexpected behavior from evidence before changing code. --- - -The bug is in the USER'S PROJECT. You are debugging their code, not Claude Code itself. Do not disclaim or reframe the issue as a tool/assistant problem. - -Do NOT attempt any fix before completing root cause investigation. No exceptions. If you catch yourself thinking "let me just try changing this" — stop and go back to Phase 1. - - # Debug -Find the root cause, then fix it. Not the other way around. - -The bug is in the user's project — you are debugging their code and environment, not Claude Code or any tool in the assistant infrastructure. - -## When to Self-Invoke - -The routing table and this skill's description own the entry triggers. One needs spelling out because it arrives mid-flow and is easy to miss: a fix just shipped and the user replies with a terse failure signal — "still broken", "not fixed", "doesn't work", "nope", a one-word rejection, or a bare screenshot. Do not patch again; the short dismissal means the last fix missed the root cause. Enter at Phase 1. - -## The Iron Law - -``` -NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST -``` - -If you haven't completed Phase 1, you cannot propose fixes. This is not optional. This is not skippable because it "looks obvious." Obvious bugs have root causes too. - -## Phase 1 — Root Cause Investigation - -Phase 1 requires tool calls, not speculation. Read the code, run the commands, check the diff. Do not hypothesize about what the code does without reading it — that is guessing dressed up as analysis. - -1. **Read error messages completely.** Stack traces, line numbers, error codes. Don't skip past them. They often contain the answer. -2. **Reproduce consistently.** Exact steps, every time. If you can't reproduce, gather more data — don't guess. -3. **Check recent changes.** Git diff, recent commits, new dependencies, config changes, environmental differences. -4. **Gather evidence in multi-component systems.** Log at each component boundary. Run once. Analyze where data goes wrong. Don't guess which layer is broken. -5. **Trace data flow.** Find where bad values originate. Trace upstream to the source. Fix at source, not at symptom. -6. **Instrument the real code path.** Evidence comes from running the actual executable with real data — add logging to the real code and run it again. Do not write one-off probe scripts or simulations; they test your assumptions about the system, not the system. When re-theorizing stalls, read the primary artifact (the source, the binary output, the actual data) instead of forming another theory about it. - -## Phase 2 — Pattern Analysis - -1. **Find working examples** of similar code in the codebase. -2. **Compare working vs broken** — list every difference, however small. Don't assume "that can't matter." -3. **Understand dependencies and assumptions** the code makes. - -## Phase 3 — Hypothesis and Testing - -1. **Form a single hypothesis:** "X is the root cause because Y." Be specific. -2. **Test minimally** — the smallest possible change, one variable at a time. -3. If it works, proceed to Phase 4. If not, form a new hypothesis. **Do not stack fixes.** - -## Phase 4 — Implementation - -1. **Write a failing test** that reproduces the bug. -2. **Implement a single fix** addressing the root cause. The fix must explain why the bug existed — a guard, retry, or special case that makes the symptom disappear without answering that question is a workaround wearing a fix's clothes, and the skill is not done. -3. **Verify** — test passes, no regressions. -4. **If 3+ fix attempts have failed: stop.** This is not a failed hypothesis — this is likely a wrong architecture. Discuss with the user before attempting more fixes. - -## Research Integration +Find the cause before choosing a fix. The fault may be in project code, configuration, dependencies, the environment, or tooling; let evidence locate it. -If the bug involves an external API or library behaving unexpectedly, invoke `meridian:research` to verify expected behavior against live docs before forming hypotheses. Don't assume you know how the library works. +## Discipline -## When the Process Reveals No Root Cause +1. Read the complete failure evidence: errors, stack traces, logs, screenshots, and relevant diffs. +2. Reproduce the issue when practical. If reproduction is unsafe or unavailable, gather the closest direct evidence and state the limitation. +3. Trace the failing path and compare it with a working path or documented contract. +4. Form one specific hypothesis and test it with the smallest useful observation or change. +5. Fix the cause, not merely the visible symptom. +6. Add or update a regression test when it provides durable value, then run proportionate verification. -If systematic investigation reveals the issue is truly environmental, timing-dependent, or external: -1. You've completed the process — that's fine -2. Document what you investigated -3. Implement appropriate handling (retry, timeout, error message) -4. Consider invoking `meridian:document` to capture the investigation for future sessions +Do not require ceremony that adds no evidence. A trivial, directly proven defect can move quickly; an ambiguous or multi-component failure deserves deeper instrumentation. If an external API or library contract matters, invoke `meridian:research`. -Most "no root cause" verdicts turn out to be incomplete investigation. Treat the conclusion with skepticism — it's the convenient answer, not usually the right one. +After several failed hypotheses, stop stacking changes. Reassess the model of the system and surface the uncertainty to the user. diff --git a/skills/delegate/SKILL.md b/skills/delegate/SKILL.md index 3ffa11f..8c38b40 100644 --- a/skills/delegate/SKILL.md +++ b/skills/delegate/SKILL.md @@ -1,72 +1,23 @@ --- name: delegate -description: Use when you have 2+ independent tasks, or when isolating heavy work preserves orchestrator context +description: Isolate independent or context-heavy work when parallelism or fresh context materially improves the task. --- - -Do NOT pass conversation history, prior subagent results, or orchestrator reasoning to subagents. Include only the task scope, relevant code context, constraints, and output format. - - # Delegate -Dispatch subagents for isolated work. You think, they execute. - -## Default: Delegate - -Prefer subagents over inline work. They protect orchestrator context and produce parallelism for free. The model spawns fewer subagents than prior generations by default — push back against that drift. If a task fits the shape, dispatch. - -**Delegate when any of these hold:** -- 2+ independent tasks that can run without shared state -- Heavy implementation where context isolation benefits you -- Investigations whose intermediate output you won't need again (codebase surveys, docs reading, spec verification) -- Fresh perspective (no session baggage) is an advantage -- You only need the conclusion, not the raw tool outputs that produced it - -**Stay inline only when:** -- You need to reason over the raw tool outputs, not just the conclusion -- The task is tightly coupled to other in-flight changes touching the same files -- The work requires judgment calls or architectural decisions you shouldn't hand off - -When in doubt, delegate. - -## Prompt Construction - -Use the template at `delegation-prompt.md` in this directory as a base. - -**Include:** -- Specific scope and clear goal -- All code and context the subagent needs (paste it — don't point to files) -- Constraints (what NOT to touch, what NOT to change) -- Expected output format -- If mid-execution on a spec: the spec file path. The subagent reads User Constraints and the Progress Log to pick up state without inheriting your conversation. - -**Exclude:** -- Conversation history -- Prior review results -- Your reasoning about the problem -- Other subagents' results - -**Always include this in the prompt:** -> Begin your response with one of: `STATUS: DONE`, `STATUS: DONE_WITH_CONCERNS`, `STATUS: NEEDS_CONTEXT`, or `STATUS: BLOCKED`. Then report: what you did, files changed, and any concerns. - -## Handling Results - -- **DONE:** Verify independently — check the diff, run tests. Never trust the report alone. -- **DONE_WITH_CONCERNS:** Read concerns first. If about correctness or scope, address before proceeding. -- **NEEDS_CONTEXT:** Provide missing info, re-dispatch. -- **BLOCKED:** If context problem, provide more. If too complex, break it down. If the plan is wrong, consult the user. +Delegation is an internal execution mechanism, not a default proof of diligence. -## Narrow Decomposition Check +## Use when -Before dispatching, verify your decomposition covers the full scope. If you only assign 3 subtopics when there are 5, the missing 2 will never get done. Enumerate broadly, then verify coverage. +- two or more tasks are genuinely independent; +- a bounded investigation's intermediate context is disposable; +- a fresh blind perspective is part of the method; or +- heavy mechanical work would crowd out the orchestrator's integration judgment. -## Model Selection +Stay inline when work shares files or state, raw outputs are needed for the main reasoning, or the task depends on user intent or architectural judgment. -Match capability to task complexity: -- **Mechanical tasks** (isolated functions, clear specs, extraction, classification, per-chunk summarization): fastest available model, fanned wide -- **Integration tasks** (multi-file, pattern matching): standard model -- **Architecture, synthesis, and review tasks**: most capable model, kept few +## Dispatch -Defaulting every subagent to your own tier is the expensive failure: a wide fan-out at flagship pricing doing what a small model does identically. The inverse holds at the end — whatever the fleet produced, read the final synthesis yourself, once. The conclusion is not delegable. +Give each agent only its concrete scope, relevant facts, constraints, and required output format. Do not pass conversation history, prior reviewer conclusions, or orchestrator reasoning. Match model capability and effort to task complexity. -When the platform supports it, consider using lower `effort` settings for mechanical subagents — they don't need deep thinking for straightforward implementation work. +Require a status (`DONE`, `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, or `BLOCKED`), files changed, verification performed, and concerns. The orchestrator independently inspects the diff, runs relevant checks, and owns the integrated conclusion. diff --git a/skills/document/SKILL.md b/skills/document/SKILL.md index 84d8294..9490c7c 100644 --- a/skills/document/SKILL.md +++ b/skills/document/SKILL.md @@ -1,6 +1,7 @@ --- name: document -description: Use after completing features, resolving complex bugs, or when hard-won knowledge would otherwise be lost +description: Capture hard-won engineering knowledge in durable project documentation. +disable-model-invocation: true argument-hint: "[what to document]" --- @@ -12,7 +13,6 @@ Capture knowledge for humans. Not for AI, not for ceremony — for the person wh - After completing a feature with non-obvious behavior - After resolving a complex debugging session -- When the agent recognizes hard-won knowledge is about to be lost with the session - When the user asks to document something ## What to Document and Where diff --git a/skills/document/agents/openai.yaml b/skills/document/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/document/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/execute/SKILL.md b/skills/execute/SKILL.md index 55dd9b9..c6df6d5 100644 --- a/skills/execute/SKILL.md +++ b/skills/execute/SKILL.md @@ -1,119 +1,62 @@ --- name: execute -description: Use after brainstorm produces an approved spec, or when the user has clear requirements ready to implement +description: Implement an approved spec or clearly defined piece of work. +disable-model-invocation: true --- - -Do NOT claim work is complete without running verification commands and confirming their output. Do NOT trust subagent completion reports without independent verification. Evidence before assertions — always. - - - -Migration cutover: do NOT delete source until every caller compiles against the new target. Stubbing callers to restore a green build is a lie — the deleted behavior is gone, not migrated. If the spec says "full cutover" or the user says "port everything", porting means every caller invokes the new shape end-to-end. If scope exceeds the session budget, commit the partial port, leave legacy intact, and report remaining callers in the final summary. Never delete-and-stub to claim done. - - - -Completion headline matches reality. "Implementation complete" requires every spec acceptance criterion verified AND every verification command passing. If any criterion is unverified — skipped, deferred, infeasible in this session, or simply not run — the headline is "Implementation incomplete". If any verification command failed — *including* "pre-existing" or "unrelated" failures — the headline is "Implementation blocked". "Pre-existing" is a reason to surface in the body, not a license to upgrade the headline. Users read the headline first; burying blockers under "Open concerns" while declaring success is the failure mode this gate exists to prevent. - - # Execute -Implement an approved spec. Break it into tasks, verify as you go, review at the end. - -## Before Starting - -### 1. Read and Review the Spec - -Load the spec file. Review it critically. If you have concerns — ambiguities, gaps, things that will cause problems during implementation — raise them with the user before writing code. Don't start and hope it works out. - -### 2. Execution Preferences +Implement the scope the user explicitly authorized. Invocation is approval to begin ordinary in-scope implementation; do not ask the user to approve entering the workflow again. -Before asking, parse the user's invoking message for signals that pre-answer these: +## Invariants -- **Subagent signal** — any of "subagent", "parallel", "in parallel", "delegate", "use agents" in the request → **skip the execution-mode question, use Subagent mode**. -- **Commit strategy signal** — "commit after each", "per task", "per-task" → Per-task. "commit at the end" → At-the-end. "don't commit" / "i'll commit" / "no commit" → You-handle-it. -- **Stepping-away signal** — "im going to [shower/sleep/bed/a meeting/work]", "won't be [responsive/around/able to respond]", "see you in [time]", "be autonomous", "when i'm back", "when i get back", "by the time i return" → **activate `/auto` autonomy mode for the rest of the session** (see `auto` skill). This implies: skip remaining preference questions (pick Subagent + Per-task), skip approval gates, bias to completion, commit the work, document defaults in the final summary. Still honor destructive-operation guardrails and still challenge bad approaches via reasoning (not `AskUserQuestion`). +- Do not claim completion without observing risk-appropriate verification. +- Do not trust delegated completion reports without inspecting the diff and evidence yourself. +- Preserve unrelated user changes and keep the diff inside the approved scope. +- Do not delete a migration source until every caller uses and verifies the replacement. If full cutover is not achievable, leave the legacy path intact and report the partial state. +- Do not push, deploy, delete data, force-push, or perform another unrequested external or irreversible action. -If signals pre-answer both questions, say one line naming what was detected ("Detected: subagent mode, per-task commits, autonomy mode — starting now.") and proceed. Otherwise ask only the unanswered question(s) via `AskUserQuestion`: +## Process -1. **Execution mode** — options: "Subagent (Recommended)" (parallel where possible), "Inline" (you do everything) -2. **Commit strategy** — options: "Per-task", "At the end", "I'll handle it" +### 1. Ground the work -**Execution mode governs task dispatch:** -- **Subagent:** default to dispatching independent tasks via `meridian:delegate`. Only implement inline when a task requires judgment, architectural decisions, or deep codebase understanding. -- **Inline:** implement everything directly. Do not dispatch subagents. +Read the approved spec or supplied requirements, project instructions, relevant code, and current worktree state. Resolve ordinary reversible implementation details yourself. Ask only when a missing decision would materially change architecture, scope, risk, or an irreversible action. -**Commit strategy governs when `meridian:commit` runs:** -- **Per-task:** commit after each task is verified. -- **At-the-end:** commit once after all tasks pass final verification. -- **You-handle-it:** never commit. User handles it. +### 2. Plan proportionately -All commits — including from subagents — follow `meridian:commit` rules. +Break multi-part work into dependency-ordered tasks. Keep a small change inline. Use `meridian:delegate` only for genuinely independent or context-heavy work where isolation materially improves execution; agent count is not evidence of diligence. -## Process +### 3. Implement and verify -### 3. Break Into Tasks +For each task: -Identify discrete implementation units from the spec. Use TaskCreate/TaskUpdate to track progress. Sequence tasks logically — dependencies first. +1. Search for existing interfaces, utilities, tests, and conventions before adding competing logic. +2. Implement the smallest durable change that satisfies the requirement. +3. Exercise the highest practical real seam: focused tests, typecheck, build, CLI, service, or browser behavior as appropriate. +4. If behavior fails unexpectedly, use `meridian:debug` rather than stacking speculative patches. +5. Check the diff against scope and the spec's User Constraints. +6. If a spec exists, append a concise Progress Log entry containing the task outcome, verification, deviations, and open concerns that a future agent could not recover from the diff. -### 4. Implement Each Task +### 4. Finish -For each task: -1. Mark it in progress -2. Search the codebase for existing utilities, similar features, and data access patterns relevant to this task. Do not hardcode values that exist in data files or utilities. Do not reimplement existing logic. -3. Implement directly or dispatch via `meridian:delegate` (per execution mode preference). If dispatching: include User Constraints from the spec in the subagent prompt. If inline: consult User Constraints before implementing. -4. Run verification — tests, typecheck, lint, whatever the project uses. Where the task has a runtime surface, exercise the real flow with real data — run the actual executable, drive the actual page — not a synthetic probe that only proves your assumptions -5. If verification fails: invoke `meridian:debug`. Do not guess-fix. -6. If subagent was used: verify independently. Check the diff. Run tests yourself. -7. Present a brief verification summary: what was checked, results, any discrepancies. Also check against User Constraints — violations are defects. For UI tasks, check pattern consistency with existing pages. Keep to 2-4 lines — facts only. -8. Append entry to the spec's Progress Log section per the Progress Log rules below — typically one line (skip if no spec) -9. If commit strategy is per-task: invoke `meridian:commit` -10. Mark complete - -When user feedback introduces new constraints ("stop using partial opacity"), update the spec's User Constraints section (or note in task notes if no spec exists). This persists for future subagents. - -### 5. Final Verification and Review - -After all tasks: -1. Run the full verification suite — not just the tests you think are relevant -2. Present the final verification results: full suite results (all passed / N failures), files changed summary (count + key files), any open concerns. Keep it to 2-4 lines. -3. Invoke `meridian:review` for code review -4. Triage review findings by class. `review` labels each finding as `material-gap`, `prose-clarity`, or `implementation-detail`: - - **material-gap findings are blocking.** Fix them before reporting done. - - **prose-clarity findings are optional.** Apply them if the fix is cheap and the clarity win is real. Skip otherwise. - - **implementation-detail findings are advisory.** Do not address unless the user asks — these are the "cover-every-edge-case" nits that bloat specs and implementations. -5. Re-verify after fixes -6. If material-gap changes from fixes were substantial, re-review -7. Append a completion entry to the spec's Progress Log: full-suite outcome, review verdict, material-gap defects resolved, any open concerns (skip if no spec) - -### 6. Completion - -Pick the completion headline by checking the spec's acceptance criteria and verification suite against what actually happened: - -- Every acceptance criterion verified + every verification command passed → **"Implementation complete"** -- Any acceptance criterion unverified (skipped, deferred, infeasible, not run) → **"Implementation incomplete — "** -- Any verification command failed, regardless of cause (including pre-existing or out-of-scope) → **"Implementation blocked — "** - -A failure caused by something you didn't introduce is still a blocker on the headline; it just informs the body's explanation. The body then reports what was built, what was verified, what wasn't, and any open concerns. Ensure everything is committed per the chosen commit strategy. +Run the relevant full project checks after focused verification. Formal `meridian:review` and `meridian:commit` are separate user-owned flows; do not invoke them automatically. -## Progress Log +Report: -Append to a Progress Log section in the spec file. **Purpose:** a fresh agent (after context compaction, a cleared session, or a handoff to a subagent) can resume without re-deriving state. It is a pickup-ready summary, not a step-by-step journal. +- what changed; +- verification commands and observed results; +- any acceptance criterion that remains unverified; +- any known material concern; and +- relevant unrelated failures without treating them as introduced defects. -After each verified task, append one entry. Include only what applies: -- Task name + verification outcome (always) -- Decisions a future agent couldn't recover from reading the diff -- Deviations from the spec and why -- Open concerns discovered +## Completion -Do not log every tool call, search, or thought. A clean task with no deviations is a one-line outcome. The model narrates progress in chat natively — do not duplicate that narration here. Facts, not narrative. Append, don't rewrite. +- **Implementation complete:** every requested acceptance criterion is delivered and verified to the task's risk level, and no relevant required check fails. +- **Implementation incomplete:** an acceptance criterion remains undelivered or unverified. +- **Implementation blocked:** a failure or missing human-only decision prevents delivery or meaningful verification. -If no spec file exists (direct invocation without brainstorm), skip this — the work is ephemeral enough to live in conversation. +An unrelated pre-existing failure is reported accurately but does not change the headline unless it prevents verification of the requested work. -## Scope Discipline +## Progress Log -Match change scope to request scope, and don't gold-plate: -- Implement only what the spec asks for — no extra files, abstractions, or flexibility that wasn't requested -- Don't refactor adjacent code "while you're here", and don't add error handling for impossible cases -- "fix X" / "tweak Y" means touch only X / Y; "redesign Z" earns a broader rewrite -- If a subagent returns work beyond the spec, strip it before accepting -- If a small fix requires touching more code than expected, explain why first. Files changed that the user didn't mention mean you over-scoped. +When a spec exists, append one pickup-ready entry after each verified task and one completion entry at the end. Record outcomes, decisions not obvious from the diff, deviations, and open concerns. Do not turn the log into a tool-call journal. Do not stage or commit the spec. diff --git a/skills/execute/agents/openai.yaml b/skills/execute/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/execute/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/research/SKILL.md b/skills/research/SKILL.md index 5701ecc..58a6696 100644 --- a/skills/research/SKILL.md +++ b/skills/research/SKILL.md @@ -1,39 +1,20 @@ --- name: research -description: Use when touching external APIs, libraries, or patterns where training data may be outdated +description: Verify unstable or unfamiliar external API and library contracts that materially affect the task. argument-hint: "[query or topic to research]" --- - -Do NOT trust training data for external API signatures, library behavior, or version compatibility. Verify against live documentation before returning results. You MUST dispatch the `meridian:research` agent (which calls a web-fetching tool) before concluding — reasoning from memory does not satisfy this skill. - - # Research -Verify before you build. Training data is a starting point — live documentation is truth. This skill is a thin dispatcher: the heavy doc-reading lives in the `meridian:research` plugin agent so verification work doesn't bloat the orchestrator's context. - -## When This Triggers - -**Auto (invoke this automatically — don't wait for the user to ask):** - -- You're about to use an external API and aren't certain of the current signature -- A brainstorm or debug session touches a library you haven't verified -- The user mentions a tool/service/API you don't have verified knowledge of -- Task involves a library version where behavior may have changed -- About to write code based on memory of an API -- Any HARD-GATE would fire if you proceeded without verification - -**Manual:** User invokes `/meridian:research "query"` - -## Process - -1. **Construct the query.** If invoked manually, use `$ARGUMENTS`. Otherwise restate the specific verification need (API signature, behavior, configuration option, version compatibility) in one or two sentences. -2. **Dispatch the `meridian:research` agent** as a subagent (`subagent_type: meridian:research`). Prompt body: the query and any context the agent needs to find the answer (target library version, framework, etc.). Do NOT include conversation history or your reasoning about the problem. -3. **Return the agent's verdict to the caller verbatim.** The agent returns verified facts with source URLs (or "Could not verify" with explanation). Don't paraphrase, don't summarize, don't add hedging. +Use current primary sources when implementation depends on external behavior that may have changed or is not established in the available artifacts. -## When Research Fails +## Discipline -If the agent returns "Could not verify": +1. State the narrow question and the version or environment that matters. +2. Prefer official documentation, source code, standards, release notes, or direct runtime evidence. +3. Verify load-bearing details such as signatures, defaults, compatibility, and failure behavior. Do not pad the search with unrelated background. +4. Use a research subagent only when parallel reading or context isolation is materially useful; direct research is valid. +5. Return the answer in your own words with links or artifact paths close to the claims they support. +6. If the evidence is incomplete or conflicting, say what remains uncertain and whether it blocks the task. -- **Claim is critical to implementation:** Consult the user before proceeding with unverified assumptions. -- **Claim is peripheral:** Surface the unverified assumption explicitly to the caller; do NOT silently proceed as if confirmed. +Training knowledge can guide where to look, but it is not evidence for time-sensitive external behavior. diff --git a/skills/respond/SKILL.md b/skills/respond/SKILL.md index 49b2d92..fd0e6d2 100644 --- a/skills/respond/SKILL.md +++ b/skills/respond/SKILL.md @@ -1,62 +1,19 @@ --- name: respond -description: Use when receiving code review results from a subagent, human reviewer, or PR comments +description: Evaluate and address concrete code-review feedback against repository reality. --- # Respond -Evaluate review feedback technically before acting on it. This applies to all feedback — from the isolated review subagent, from human reviewers on PRs, from the user directly. The process is the same; the trust level differs. +User intent, priorities, and accepted tradeoffs are authoritative. Technical explanations and proposed fixes remain claims to verify against the code and evidence. ## Process -1. **Read** the complete feedback without reacting. +1. Read all feedback and its surrounding diff context. +2. Separate requested outcomes from technical assertions about the cause or solution. +3. Verify each technical assertion against repository behavior, tests, and relevant documentation. +4. Apply correct in-scope fixes one at a time with focused verification. +5. Push back concisely when a suggestion is technically wrong, breaks existing behavior, conflicts with an accepted decision, or expands scope without value. +6. Report what changed, what was declined, and the evidence supporting either result. -2. **Understand** — restate each item in your own words. If ANY item is unclear, use `AskUserQuestion` to ask about ALL unclear items before implementing anything. Batch them into a single call (up to 4 questions). Don't implement half the feedback and ask about the rest later — items may be related. - -3. **Verify** — check each suggestion against codebase reality. - - Does the code actually do what the reviewer claims? - - Is the suggestion technically sound for THIS codebase? - -4. **Evaluate** — source determines scrutiny level: - - *Review subagent:* generated under controlled conditions with full diff access. Generally reliable, but verify against codebase reality. - - *User:* trusted on intent and priorities — but their suggestions still get evaluated on technical merits, same as anyone's (the Challenge Protocol applies; a suggestion is an input, not an order). Still ask if scope is unclear. - - *External reviewer (PR, GitHub):* is the suggestion correct? Does it break existing functionality? Does the reviewer understand the full context? Does it conflict with the user's architectural decisions? - -5. **Respond** — technical acknowledgment or reasoned pushback. Never performative agreement. - -6. **Implement** — one item at a time, test each. Order: blocking issues first, then simple fixes, then complex fixes. - -## Forbidden: Validation and Gratitude - -No validation phrases, no gratitude, no performative agreement — of any kind, not only the examples below. If you find yourself writing an affirming opener, delete it. - -Examples of the pattern: -- "You're absolutely right!" -- "Great point!" / "Excellent feedback!" -- "Let me implement that now" (before verification) -- "Thanks for catching that" - -Actions speak. Just fix it and show in the diff. - -## When to Push Back - -Push back when: -- Suggestion breaks existing functionality -- Reviewer lacks full context -- Violates YAGNI (adding features nothing uses) -- Technically incorrect for this stack -- Conflicts with the user's prior architectural decisions - -How: use technical reasoning. Reference working code or tests. Ask specific questions. If it's architectural, involve the user. - -## When Feedback IS Correct - -- "Fixed. [Brief description]" -- "[Issue] was real — fixed in [location]." -- Or just fix it silently. The diff speaks. - -## When You Pushed Back and Were Wrong - -State the correction factually and move on: -- "You were right — I checked [X] and it does [Y]. Fixing." -- No long apology. No defending why you pushed back. Correct and continue. +Avoid performative agreement, but ordinary human acknowledgment is fine. If your earlier technical conclusion was wrong, correct it directly without defensive explanation. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index df6498f..a42072b 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -1,87 +1,33 @@ --- name: review -description: Use after completing implementation, before merging, or when the user asks for a review +description: Run an independent, report-only review of a change. +disable-model-invocation: true --- - -Do NOT skip automated checks (lint, typecheck, build, test) before dispatching the review subagent. Do NOT approve merging with unaddressed material-gap defects. - - - -Default mode is **report-only**. `review` produces a findings list and verdict; it does NOT modify code unless the invoking request explicitly asks for fixes ("review and fix", "fix any issues you find", "apply the suggestions"). If you are invoked by `execute`, return findings — `execute` decides what to address based on finding class. The orchestrator/user acts on findings, not `review`. - -The reviewer's verdict is not yours to pre-empt. You MUST NOT emit or queue any edit, write, build, commit, or other downstream tool call in the same turn that dispatches the reviewer subagents — and not at all until all three verdicts have actually returned and you have aggregated them. Queuing fixes alongside the dispatch predetermines the conclusion — it breaks the blind. Dispatch, wait, aggregate, *then* act. - - # Review -Code review that catches what matters. Dispatched as three **blind** lens passes in parallel — each reviewer sees only the diff and its rubric, never the orchestrator's reasoning or the other passes, so nothing predetermines the verdict. - -By default, review **reports** — it does not fix. This is deliberate: a spec or diff getting reviewed and then auto-edited makes it impossible to see what the reviewer flagged vs. what was changed in response. Findings go up; fixes happen at the caller's discretion. +Invocation authorizes one risk-proportionate independent review. The default is report-only; modify code only when the user explicitly asks for review and fixes. ## Process -### 1. Run Automated Checks First - -Run whatever the project uses — lint, typecheck, build, test. Fix failures before dispatching the reviewer; don't waste review time on things automation catches. - -### 2. Dispatch Three Lens Passes - -The review fans out into exactly three lens passes — always all three, regardless of what changed. Each pass is the same `meridian:reviewer` agent reviewing the full diff against one lens's rubric; an agent naturally reports only on the dimensions present in its slice of the diff (a config-only diff may legitimately come back empty from a lens). The lenses and their rubric files are listed under [Lenses](#lenses) below. - -Compute the diff range: base SHA from `git merge-base HEAD origin/master` (or equivalent) for initial reviews. For re-reviews after fixing defects, use the commit before fixes began. - -Dispatch all three lenses **in a single message** — three `Agent` calls with `subagent_type: meridian:reviewer`. Each dispatch's prompt body contains: - -- A **lens-role line**: "You are the **** pass. Read your rubric file (path below) and review the diff against ONLY that rubric, in depth." -- The **absolute path** to that lens's rubric file in this skill's directory (the reviewer reads it itself — do NOT copy the rubric contents into the prompt; the subagent has no other way to locate the file) -- The diff range -- The project CLAUDE.md content (conventions) -- The spec file content, if one exists -- A description of what was implemented - -Do NOT pass: conversation history, prior review results, the orchestrator's reasoning, or the other lenses' rubrics or results — keep each pass blind. Each reviews the diff on its own merits. - -### 3. Aggregate - -Wait for all three lens passes to return, then merge them into one report: - -- **Dedup findings** — key on `file:line` + the issue. Collapse same-line / same-issue duplicates to one; keep same-line / different-category findings separately. Dedup is a safety net, expected to fire rarely — the three lenses cover disjoint dimensions, so identical cross-lens findings are uncommon. -- **Concatenate** Smells and Simplification Opportunities across lenses (informational; no dedup). -- **Reconcile verdicts** in two ordered steps. Each lens emits its verdict per the reviewer doctrine, where "Do not ship" means the change is fundamentally wrong — not merely that it carries a localized material gap (those are "Fix material gaps and ship"). - 1. **Normalize each lens verdict (downgrade check, runs first).** For every lens that returned "Do not ship", inspect its cited rationale. If the rationale asserts the chosen approach or structure is wrong, keep "Do not ship"; if every cited item is instead a fixable defect (specific things to change, not "this design is wrong"), treat that lens's verdict as "Fix material gaps and ship". - 2. **Take most-severe of the normalized verdicts.** Any remaining "Do not ship" → **Do not ship**; else if any finding is `material-gap` → **Fix material gaps and ship**; else → **Ship it**. - - The downgrade step always precedes most-severe, so a localized defect a lens mislabeled "Do not ship" cannot escalate the merged verdict. A merged "Do not ship" therefore always reflects a genuine approach-level problem. - -Present one merged findings list (each finding keeps its `finding-class` label and originating category) and one verdict. - -### 4. Return Results - -`review` is report-only by default (see the hard-gate above). Return findings to the caller, classified by `finding-class` (below). The caller — `execute`, or the user directly — decides what to address. - -Only act on findings directly if the invoking request explicitly said to ("review and fix", "apply the suggestions"). In that case: fix material-gap findings first, consider prose-clarity on a cost-benefit basis, skip implementation-detail. Then re-run automated checks, and re-review if material-gap fixes were substantial (dispatch fresh subagents — a re-review stays blind; the previous passes' reasoning must not carry over). - -## Lenses - -Every review dispatches all three. Each lens's rubric lives in its own file; the orchestrator passes the relevant file's **path** in that lens's dispatch prompt and the reviewer reads it (per step 2). - -| Lens | Rubric file | Dimensions | -|------|-------------|------------| -| **Correctness & Safety** | `lens-correctness.md` | Security, Testing, Library Misuse, Logic & Error Handling | -| **Craft & Simplicity** | `lens-craft.md` | Code Reuse, Simplification, Quality Patterns, AI Slop, Code Style & Conventions, Naming | -| **Performance & Architecture** | `lens-performance.md` | Efficiency, Rendering Performance, Framework Anti-Patterns, API Design | - -## Finding Classification - -Every finding carries one of three classes — `material-gap` (blocking: incorrect, incomplete, or harmful), `prose-clarity` (cheap real clarity win), or `implementation-detail` (an opinion on a detail that is neither wrong nor unclear — dropped by default). The reviewer agent owns the full definitions and the emit/drop bar; the orchestrator consumes the labels to triage. Unclassified findings are noise. - -## What the Orchestrator Sees - -After aggregating the three lens passes, the review returns: -- A findings list — the deduped union across the three lenses — with file:line references AND a `finding-class` label per finding (`material-gap` | `prose-clarity` | `implementation-detail`) -- Smells (unclassified but honest observations — not required action) -- Simplification opportunities (explicitly labeled as such; the caller decides) -- Verdict: **Ship it / Fix material gaps and ship / Do not ship**, reconciled to most-severe across the lenses per step 3. `Do not ship` requires the approach to be fundamentally wrong — not merely the presence of material gaps. Fixable material-gap findings produce `Fix material gaps and ship`. - -No "strengths" section. Working code is the baseline, not an achievement. No list padding — if there are only two material gaps and no clarity issues, return exactly that. +1. Read project instructions and the relevant spec when present. Enumerate the complete change with `git status --short`, including staged, unstaged, deleted, and untracked files. Read both tracked diffs and the full contents of every untracked file in scope; `git diff` alone is never a complete working-tree review. +2. Run the automated checks appropriate to the changed surface before manual review. Report failures instead of hiding them. +3. Select only the lenses justified by the diff: + - **Correctness & Safety** (`lens-correctness.md`) always. + - **Craft & Simplicity** (`lens-craft.md`) for implementation, structure, reuse, naming, or maintainability changes. + - **Performance & Architecture** (`lens-performance.md`) for hot paths, rendering, concurrency, resource use, interface design, or architectural changes. + - Use all lenses when the user requests a comprehensive review or the blast radius warrants it. +4. Dispatch the selected lenses independently with project instructions, relevant spec, implementation description, only that lens's rubric path, and a complete change manifest. For committed changes, include the diff range. For working-tree changes, include staged and unstaged diffs plus untracked file paths and instruct the reviewer to read their contents. Do not pass conversation history, prior findings, or orchestrator reasoning. +5. Aggregate findings by `file:line` and issue, preserve finding class and category, and reconcile the verdict: + - approach-level unsoundness: **Do not ship**; + - localized material gaps: **Fix material gaps and ship**; + - no material gaps: **Ship it**. +6. Return the deduplicated findings and verdict. Do not pad the report with strengths or speculative nits. + +If fixes were explicitly requested, address material gaps and run targeted verification. Start another full review only when a material design change invalidates the first review or the user asks for one. + +## Finding classes + +- `material-gap`: incorrect, incomplete, unsafe, or materially harmful. +- `prose-clarity`: a concrete, inexpensive clarity defect. +- `implementation-detail`: non-blocking preference; omit unless the user requested exhaustive detail. diff --git a/skills/review/agents/openai.yaml b/skills/review/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/review/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/sketch/SKILL.md b/skills/sketch/SKILL.md index 7de3ff9..faa1821 100644 --- a/skills/sketch/SKILL.md +++ b/skills/sketch/SKILL.md @@ -1,20 +1,21 @@ --- name: sketch -description: Use for a small, well-scoped fix where the user can describe the change in 1-2 sentences and wants it done with the same rigor as a full spec +description: Create and approve a lightweight implementation sketch for a small change. +disable-model-invocation: true argument-hint: "[optional description of the fix]" --- -Do NOT invoke `execute` until the user has approved the sketch. +This flow produces an approved sketch. Do NOT implement it or invoke `meridian:execute`; the user owns the transition to implementation. # Sketch -A lightweight spec for small fixes. The spec is lighter than `brainstorm`'s; the `execute` → `review` quality gate is identical. +A lightweight spec for small fixes. ## When to Use -Primarily user-invoked via `/meridian:sketch [optional description]`. Auto-invocation via the orientation's routing table is rare and conservative — only fires for clearly-small fixes. Do NOT auto-invoke if any Escape Hatch trigger applies. Unfamiliar external APIs alone are not a disqualifier — they trigger `meridian:research` at step 2. +Use only when the user invokes it. Unfamiliar external APIs alone are not a disqualifier; they trigger `meridian:research` at step 2. ## Process @@ -24,7 +25,7 @@ Primarily user-invoked via `/meridian:sketch [optional description]`. Auto-invoc 2. **Identify research needs.** "Unfamiliar" means: imports a library not in the immediate file, or uses an API not verified in this session. If unfamiliar, invoke `meridian:research`. After it returns, re-evaluate scope: if research surfaced unexpected complexity, escape to `brainstorm`; otherwise resume. -3. **Clarify if needed.** Use `AskUserQuestion` for any genuine ambiguity — including proposing a slug if `/meridian:sketch` was invoked with no description. Capture stated constraints (`do/don't` rules) for the User Constraints section. Skip if the request is unambiguous. +3. **Clarify if needed.** Use the host's structured question tool for genuine ambiguity, or ask in plain text and wait when that tool is unavailable. This includes proposing a slug if `/meridian:sketch` was invoked with no description. Capture stated constraints (`do/don't` rules) for the User Constraints section. Skip if the request is unambiguous. 4. **Write the sketch file.** Path: `.meridian/sketches/YYYY-MM-DD-.md` (date in local time). Slug: lowercase, ASCII-fold, replace non-alphanumerics with hyphens, collapse, trim, truncate to 60 chars, re-trim. If the result is empty, propose a slug at step 3. On filename collision, append `-2`, `-3`. Create the directory if missing. Do NOT commit. Do NOT stage. @@ -32,22 +33,9 @@ Primarily user-invoked via `/meridian:sketch [optional description]`. Auto-invoc 5. **Self-review.** Placeholder scan (no TBD/TODO/incomplete bullets), internal consistency (Plan covers Done When; Done When is verifiable), ambiguity check. Fix inline. -6. **Present and approve.** Show the sketch contents. Ask via `AskUserQuestion` with options "Approve" / "Request changes". On "Request changes": collect changes, update file in place, re-run self-review, re-present. After 3 unsuccessful rounds, suggest transitioning to `brainstorm`. If a User Constraint surfaces during this step, it counts as a "Request changes" event — update the file and re-present, never silently mutate. +6. **Present and approve.** Show the sketch contents. Ask with options "Approve" / "Request changes" through the host's structured question tool, or in plain text when unavailable. On "Request changes": collect changes, update file in place, re-run self-review, re-present. After 3 unsuccessful rounds, suggest transitioning to `brainstorm`. If a User Constraint surfaces during this step, it counts as a "Request changes" event — update the file and re-present, never silently mutate. -7. **Hand off.** On approval, invoke `meridian:execute` via the `Skill` tool. Reference the sketch file's absolute path in the preceding message so `execute` can locate it. - -## Post-handoff Constraints - -After handoff to `execute`, the sketch file is still the contract — and so the sketch's "constraints surface = re-present" rule (step 6) keeps applying. When a User Constraint surfaces during execution ("only 2 lines", "no blur effects", "match the existing pattern exactly", "use my dev server, not yours"), treat it as a sketch event, not just an execute note: - -1. Pause the active execute task at the next safe checkpoint (don't abandon a half-edited file mid-write) -2. Append the new rule to the sketch's User Constraints section -3. Re-present the updated sketch — showing what changed — for re-approval via the same `AskUserQuestion` "Approve" / "Request changes" gate as step 6 -4. On re-approval, resume execute with the updated constraint propagated to any in-flight subagent - -Silent absorption — quietly adding the constraint to a future subagent prompt without re-presenting — is the failure mode this exists to prevent. A small fix that has accumulated three new constraints mid-execute is no longer the small fix the user approved; the sketch needs to reflect that, and the user needs to see what the running contract is now. - -If a constraint round suggests the original 1-2 sentence framing has been outgrown — multiple subsystems implicated, data shapes shifting, scope drifting toward a feature — surface that and escape to `brainstorm` per the rule below. The threshold is shape, not count. +7. **Hand off.** On approval, stop with the approved sketch and its absolute path. Tell the user they can invoke `meridian:execute` when they want implementation to begin. Do not invoke it on their behalf. ## Escape Hatch @@ -58,4 +46,4 @@ Stop and transition to `brainstorm` if mid-flow you discover: - Research returns blockers or significant unknowns - The user adds requirements that exceed the original 1-2 sentence framing -On escape: if the sketch file is untracked by git, delete it; otherwise leave it and warn the user. Pass any captured User Constraints into the `brainstorm` invocation message, then invoke `brainstorm` via the `Skill` tool. +On escape: if the sketch file is untracked by git, delete it; otherwise leave it and warn the user. Preserve any captured User Constraints and recommend that the user invoke `meridian:brainstorm`. Do not invoke it on their behalf. diff --git a/skills/sketch/agents/openai.yaml b/skills/sketch/agents/openai.yaml new file mode 100644 index 0000000..5b1f887 --- /dev/null +++ b/skills/sketch/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/skills/triangulate/SKILL.md b/skills/triangulate/SKILL.md index 0813930..c65e063 100644 --- a/skills/triangulate/SKILL.md +++ b/skills/triangulate/SKILL.md @@ -1,66 +1,27 @@ --- name: triangulate -description: Verify a specific-value or behavior claim across multiple primary sources before asserting or acting on it. Auto-invokes before asserting a value with a definite answer outside this conversation — API/protocol/format behavior, design tokens, computed runtime values, config/dependency fields, observable UI state — that you haven't read the source artifact for this session; also on code-edit-plus-confidence-escalation, output claims derived from code without reading output, and spec authoring against an unread config/token file. Full trigger self-check in the body. +description: Use when credible sources conflict, one artifact cannot establish a load-bearing claim, a claim gates a hard-to-reverse action, or the user challenges it. argument-hint: "[optional claim to audit]" --- - -You MUST NOT mark `confidence: high` with fewer than 2 different-kind primary sources. - -You MUST NOT perform a code edit if any audited claim it depends on has `code_edit_gated: true` AND `confidence != high`. - -You MUST NOT use the words "confirmed", "verified", "smoking gun", "definitive", "canonical", "authoritative" inline unless tied to an audit row with `confidence: high`. If you catch yourself reaching for one of these words, stop and check the audit. - - # Triangulate -A specialist lens for claim-confidence. Triangulate forces source rotation and surfaces doubt at output time. The heavy multi-source reading lives in the `meridian:triangulate` plugin agent so verification work doesn't bloat the orchestrator's context. - -Lenses compose with active skills — you can be in `brainstorm`, `execute`, `debug`, or `sketch` and still trigger triangulate. The audit lives in the active spec/sketch so future-you reading it sees the flag mid-execute. - -## Two Tiers - -The lens has two tiers, and the default is cheap on purpose — a lens that only knows one expensive move gets skipped under task pressure, which is the exact failure that lets wrong claims ship. - -- **Tier 1 — Ground (default, always-on, no ceremony).** When a trigger below fires, the baseline action is simply: read the source-of-truth artifact *this turn* before you assert — grep it, Read it, run the command, look at the screenshot. Then state the claim with that evidence. No subagent, no audit file, no spec row. This is the overwhelming majority of cases and costs one read. Do it silently. -- **Tier 2 — Triangulate (escalation, heavy).** Dispatch the `meridian:triangulate` agent and write a Ground Truth Audit only when grounding alone won't settle it: **candidate sources may disagree** (binary vs. decompile vs. runtime trace), the claim **gates an expensive or irreversible action**, a single source isn't enough to trust, or **the user already pushed back on this claim once**. Tier 2 is for contested or load-bearing claims — not for every value lookup. - -Never let "Tier 2 feels like a lot" become "so I'll assert from memory." The escape hatch from heavy ceremony is Tier 1, not skipping. If you catch yourself reaching for one of these — *"should be right"*, *"I'm confident"*, *"it almost certainly does X"*, *"pretty sure the function is named…"*, *"this is probably the value"* — that hedge is the tripwire: you have not read the artifact, so read it (Tier 1) before the sentence goes out. - -## When This Triggers (Orchestrator Self-Check) - -Auto-invocation is a model self-check, not a hook-driven mechanism. Before emitting any response, run these self-check questions. If any answer is yes, Tier 1 grounding is mandatory before the response goes out; escalate to Tier 2 only on the conditions above. - -The core question behind every trigger: **would a reader checking my claim against the actual artifact disagree with what I'm about to write?** If yes, and I haven't read that artifact this session, ground before claiming. - -1. **Specific-value or behavior claim** — am I about to assert a specific value, behavior, output, or state that has a definite answer outside this conversation, without having directly read the source-of-truth artifact in this session? This is broader than it sounds and is the most under-fired trigger. It covers: - - **External systems** — "the binary does X", "the protocol uses Y", "the API returns Z", "the format encodes W" - - **Stored-state values** — CSS token values, design-system palette colors, theme variables, Tailwind config fields, package.json fields, lockfile-resolved dependency versions, environment-variable defaults - - **Computed runtime values** — "neutral-700 is at L=0.27", contrast ratios, parsed font sizes, layout measurements, build outputs, rendered HTML structure - - **Observable UI state** — "the dropdown shows X when Y", "the button is disabled in state Z", "the toast appears for N seconds" - - **Repo state** — what's in a file, what a function returns, which exports a module has, what tests exist — when I haven't grepped or read it in this conversation -2. **Confidence-escalation paired with an edit** — am I about to perform a code edit AND use words like "smoking gun", "confirmed", "now I see", or "verified" in the same response? -3. **"Code does X so output is Y"** — am I deriving claims about runtime output from reading code design, without having read an actual output artifact (log, dump, runtime trace, browser screenshot, computed CSS dump, network response)? -4. **Spec authoring against an existing config / theme / token file** — am I writing a schema, format, or spec for a system that already has a config file, theme file, token CSS, Tailwind config, or design-system definition in the repo I haven't read? "Config" here is shape, not file extension — token CSS counts, theme JSON counts, Tailwind config counts. -5. **User-correction → re-claim** — did the user just correct me ("wait", "actually", "no") and am I now restating the corrected claim? If yes, audit the re-claim. +Match verification effort to the consequence of being wrong. -A useful negative test before skipping: if I had to bet $100 that my claim matches the artifact, would I take the bet at even odds? If no — fire the lens. The cost of an audit is one subagent dispatch; the cost of a wrong claim users notice is trust. +## Tier 1: Ground -**Manual:** User invokes `/meridian:triangulate ""` to audit a specific claim explicitly. +Before relying on a definite value or behavior, read its source-of-truth artifact in the current task: code, config, runtime output, screenshot, protocol, or official documentation. Most claims need nothing more. -## Process (Tier 2 — the heavy audit) +## Tier 2: Triangulate -This is the escalation path only. For Tier 1, there is no process: read the artifact, then assert. Run the steps below when a triggered claim is contested or load-bearing per the Two Tiers conditions, or when invoked manually via `$ARGUMENTS`: +Use multiple independent primary sources when: -1. **Identify the claim** — one sentence, in scope. If the claim isn't already a clear sentence in the conversation, articulate it explicitly before dispatching. -2. **Dispatch the `meridian:triangulate` agent** as a subagent (`subagent_type: meridian:triangulate`). Prompt body: the claim text, candidate source paths (whatever the orchestrator can identify — file paths, addresses, URLs, sister-repo references, ticket links, runtime artifacts, etc.), and the Ground Truth Audit format (`references/audit-format.md`). The agent's system prompt already contains the HARD-GATE and reads `references/source-kinds.md` to classify sources. -3. **Validate the returned audit** against the HARD-GATE: ≥2 different-kind sources required for `confidence: high`; no forbidden confidence words inline; no edits performed by the agent. -4. **Stitch the inline row** into the active spec/sketch under `## Ground Truth Audit` (create the heading if missing). The active spec/sketch is the most recently-written file under `.meridian/specs/` or `.meridian/sketches/` — or a path the caller passed in. -5. **Write the full audit file** to `.meridian/audits/YYYY-MM-DD-.md`. Slug rules: lowercase, ASCII-fold, non-alphanumeric → hyphen, collapse, trim, truncate to 60 chars. Audits are immutable — on filename collision, append `-2`, `-3`. Never overwrite an existing audit. -6. **If the gate is violated** (only one source found, sources disagree, agent returned `confidence: high` with same-type sources, etc.): - - **Interactive mode (default):** surface to the user via `AskUserQuestion` with options "Re-dispatch with broader source candidates" / "Accept lower confidence" / "Halt". Do not silently lower confidence and proceed. - - **Autonomous (`auto` skill active):** `AskUserQuestion` is unavailable. Lower the audit's `confidence` to the level the evidence supports, set `code_edit_gated: true` if it wasn't already, write the audit (including a "second source needed: " note in `Could Be Wrong If`), and DO NOT perform any code edit that depended on the violated claim. Document the lowered-confidence claim in the autonomous mode's final summary so the user sees it on return. +- credible sources disagree; +- the claim gates an expensive, destructive, or hard-to-reverse action; +- a single artifact cannot establish the behavior; +- the user has challenged the claim; or +- the user explicitly asks for triangulation. -## Audit Formats +State the claim narrowly, identify which evidence supports it, and preserve meaningful disagreement. Use a subagent when isolation or parallel source reading improves the result, not as a mandatory step. -Both the inline row (stitched into the active spec/sketch at step 4) and the full audit file (written at step 5) follow `references/audit-format.md`. Read it before writing either. +Create a durable audit artifact only when the user requests one or the active specification needs the evidence for future implementation. Do not turn ordinary repository facts into audit bureaucracy. diff --git a/templates/CLAUDE.md b/templates/CLAUDE.md index 65e521d..4866881 100644 --- a/templates/CLAUDE.md +++ b/templates/CLAUDE.md @@ -25,9 +25,28 @@ ## Meridian -These settings guide meridian skills when working in this project: +Meridian supplies a general professional engineering standard. Define only the project- or customer-specific policy that strengthens it here. -- **Default workflow:** brainstorm -> execute (adjust if your project uses a different flow) - **Research triggers:** [list external APIs or libraries that should always be verified against live docs before use — e.g., "Stripe API", "Next.js App Router"] - **Review focus areas:** [any project-specific review concerns beyond meridian's defaults — e.g., "accessibility compliance", "i18n coverage", "API backward compatibility"] - **Commit style:** [your project's commit message convention — e.g., "conventional commits", "imperative mood, no prefix"] + +## Security and Compliance + +[Customer data classifications, credential handling, trust boundaries, least-privilege requirements, audit controls, and applicable compliance rules. Omit if the repository has no additional policy.] + +## Required Verification + +[Commands, environments, evidence, approval gates, and mandatory checks by change type.] + +## Deployment and Rollback + +[Who may deploy, permitted targets, release procedure, rollback expectations, and actions that always require explicit approval.] + +## Repository and Artifact Conventions + +[Branch, commit, PR, generated-file, documentation, design-record, and local-working-artifact conventions.] + +## Allowed Tools and Environments + +[Approved package managers, cloud accounts, sandboxes, test tenants, external services, and prohibited environments.] diff --git a/test/meridian-consistency.test.mjs b/test/meridian-consistency.test.mjs index 73948d1..0d0e7cd 100644 --- a/test/meridian-consistency.test.mjs +++ b/test/meridian-consistency.test.mjs @@ -1,41 +1,35 @@ -// Consistency guards for facts that live in more than one file and silently drift. -// Plugin version across the per-host manifests is the first: .claude-plugin and -// .cursor-plugin diverged once (0.10.9 vs 0.10.8) and `claude plugin validate` -// only inspects the Claude manifest, so nothing caught it. +// Consistency guards for contracts duplicated across host manifests and skills. import { test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const MANUAL_FLOWS = ["auto", "brainstorm", "commit", "document", "execute", "review", "sketch"]; +const MODEL_DISCIPLINES = ["debug", "delegate", "research", "respond", "triangulate"]; /** @param {string} rel */ function readJSON(rel) { return JSON.parse(readFileSync(join(ROOT, rel), "utf8")); } -/** Skill directory names (those carrying a SKILL.md). */ function skillNames() { return readdirSync(join(ROOT, "skills"), { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name); + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); } -/** Agent names registered under agents/ (basename without .md). */ function agentNames() { return readdirSync(join(ROOT, "agents")) - .filter((f) => f.endsWith(".md")) - .map((f) => f.slice(0, -3)); + .filter((file) => file.endsWith(".md")) + .map((file) => file.slice(0, -3)); } -/** Markdown files where a meridian: reference is load-bearing. */ -function docFiles() { - const dirs = ["skills", "agents", "hooks/context", "output-styles"]; - /** @type {string[]} */ +function markdownFiles() { const files = []; - for (const dir of dirs) { + for (const dir of ["skills", "agents", "behavior"]) { for (const entry of readdirSync(join(ROOT, dir), { recursive: true, withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith(".md")) files.push(join(entry.parentPath, entry.name)); @@ -48,34 +42,111 @@ function docFiles() { test("per-host plugin manifests declare the same version", () => { const claude = readJSON(".claude-plugin/plugin.json").version; assert.match(claude, /^\d+\.\d+\.\d+$/, "Claude manifest has a semver version"); - // Compare against the Claude manifest rather than a literal, so a release bump - // touches the manifests but never this test. for (const rel of [".cursor-plugin/plugin.json", ".plugin/plugin.json"]) { const version = readJSON(rel).version; - if (version === undefined) continue; // a manifest may omit version; if present it must agree - assert.equal(version, claude, `${rel} version matches the Claude manifest`); + if (version !== undefined) assert.equal(version, claude, `${rel} version matches`); } }); -test("each skill's frontmatter name matches its directory", () => { +test("each skill frontmatter name matches its directory", () => { for (const name of skillNames()) { const body = readFileSync(join(ROOT, "skills", name, "SKILL.md"), "utf8"); - const declared = body.match(/^name:\s*(.+)$/m)?.[1]?.trim(); - assert.equal(declared, name, `skills/${name}/SKILL.md name frontmatter`); + assert.equal(body.match(/^name:\s*(.+)$/m)?.[1]?.trim(), name, `skills/${name}/SKILL.md`); + } +}); + +test("manual flows are manual-only on shared and Codex skill surfaces", () => { + for (const name of MANUAL_FLOWS) { + const body = readFileSync(join(ROOT, "skills", name, "SKILL.md"), "utf8"); + assert.match(body, /^disable-model-invocation:\s*true$/m, `${name} shared manual-only policy`); + const codex = readFileSync(join(ROOT, "skills", name, "agents", "openai.yaml"), "utf8"); + assert.match( + codex, + /^\s*allow_implicit_invocation:\s*false$/m, + `${name} Codex manual-only policy`, + ); + } +}); + +test("model disciplines remain available for implicit invocation", () => { + for (const name of MODEL_DISCIPLINES) { + const body = readFileSync(join(ROOT, "skills", name, "SKILL.md"), "utf8"); + assert.doesNotMatch(body, /^disable-model-invocation:/m, `${name} shared invocation policy`); + assert.equal(existsSync(join(ROOT, "skills", name, "agents", "openai.yaml")), false); + } +}); + +test("model discipline descriptions stay narrower than baseline grounding", () => { + const research = readFileSync(join(ROOT, "skills", "research", "SKILL.md"), "utf8"); + const triangulate = readFileSync(join(ROOT, "skills", "triangulate", "SKILL.md"), "utf8"); + const researchDescription = research.match(/^description:\s*(.+)$/m)?.[1] ?? ""; + const triangulateDescription = triangulate.match(/^description:\s*(.+)$/m)?.[1] ?? ""; + + assert.match(researchDescription, /external API and library contracts/); + assert.doesNotMatch(researchDescription, /technical claims|definite claims/); + assert.match(triangulateDescription, /sources conflict|load-bearing|hard-to-reverse/); + assert.doesNotMatch(triangulateDescription, /definite claims/); +}); + +test("interactive manual flows define a plain-text question fallback", () => { + for (const name of ["brainstorm", "sketch"]) { + const body = readFileSync(join(ROOT, "skills", name, "SKILL.md"), "utf8"); + assert.match(body, /structured question tool/); + assert.match(body, /plain text/); + assert.match(body, /when (?:that tool is )?unavailable|when unavailable/); + } +}); + +test("formal review covers untracked working-tree files", () => { + const body = readFileSync(join(ROOT, "skills", "review", "SKILL.md"), "utf8"); + assert.match(body, /git status --short/); + assert.match(body, /full contents of every untracked file/); + assert.match(body, /complete change manifest/); +}); + +test("behavior profiles reference only packaged atomic adapters", () => { + const registry = readJSON("behavior/profiles.json"); + const packaged = new Set( + readdirSync(join(ROOT, "behavior", "adapters")) + .filter((file) => file.endsWith(".md")) + .map((file) => file.slice(0, -3)), + ); + + for (const [profile, adapters] of Object.entries(registry.profiles)) { + assert.ok(Array.isArray(adapters), `${profile} is an adapter list`); + assert.equal(new Set(adapters).size, adapters.length, `${profile} has no duplicate adapters`); + for (const adapter of adapters) { + assert.match(adapter, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, `${adapter} is a registry id`); + assert.ok(packaged.has(adapter), `${profile} references packaged adapter ${adapter}`); + } + } + + for (const match of registry.modelMatches) { + assert.ok(registry.profiles[match.profile], `${match.profile} is a known profile`); + assert.ok(match.exact || match.prefix, "model match is exact or prefix based"); + } +}); + +test("removed workflow routing and session state surfaces stay removed", () => { + for (const rel of [ + "hooks/context/orientation.md", + "hooks/context/routing-audit.md", + "hooks/context/debug-reroute.md", + "hooks/user-prompt-submit.mjs", + "hooks/session-end.mjs", + "hooks/lib/state.mjs", + "output-styles/meridian.md", + ]) { + assert.equal(existsSync(join(ROOT, rel)), false, `${rel} remains removed`); } }); test("every meridian: reference resolves to a real skill or agent", () => { - // Catches a routing table, dispatch, or doc pointing at a renamed/removed/typo'd - // skill or agent — the drift class behind a table that lists what no longer exists. const valid = new Set([...skillNames(), ...agentNames()]); - for (const file of docFiles()) { - const text = readFileSync(file, "utf8"); - for (const match of text.matchAll(/meridian:(\w+)/g)) { - assert.ok( - valid.has(match[1]), - `${match[0]} in ${file.slice(ROOT.length + 1)} has no skill or agent`, - ); + for (const file of markdownFiles()) { + const body = readFileSync(file, "utf8"); + for (const match of body.matchAll(/meridian:(\w+)/g)) { + assert.ok(valid.has(match[1]), `${match[0]} in ${file.slice(ROOT.length + 1)} resolves`); } } }); diff --git a/test/meridian-hooks.test.mjs b/test/meridian-hooks.test.mjs index 98694ff..d3f6eba 100644 --- a/test/meridian-hooks.test.mjs +++ b/test/meridian-hooks.test.mjs @@ -1,32 +1,18 @@ -// 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, execSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, existsSync, readFileSync, rmSync, utimesSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; const HOOKS = join(dirname(fileURLToPath(import.meta.url)), "..", "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. -/** - * @param {string} name - * @param {object | string} payload - * @param {Record} [env] - */ +/** @param {string} name @param {object | string} payload @param {Record} [env] */ function runHook(name, payload, env = {}) { const input = typeof payload === "string" ? payload : JSON.stringify(payload); try { @@ -35,428 +21,300 @@ function runHook(name, payload, env = {}) { encoding: "utf8", env: { ...process.env, ...env }, }); - return { code: 0, stdout }; - } catch (err) { - const e = /** @type {any} */ (err); - return { code: e.status ?? 1, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? "") }; + return { code: 0, stdout, stderr: "" }; + } catch (error) { + const failure = /** @type {any} */ (error); + return { + code: failure.status ?? 1, + stdout: String(failure.stdout ?? ""), + stderr: String(failure.stderr ?? ""), + }; } } -test("session-start emits valid orientation JSON", () => { - const cfg = tmpConfig(); - const { code, stdout } = runHook( +/** @param {string} stdout */ +function additionalContext(stdout) { + return JSON.parse(stdout).hookSpecificOutput.additionalContext; +} + +test("session-start emits the professional baseline without guessing a profile", () => { + const config = tmpConfig(); + const result = runHook( "session-start.mjs", - { session_id: SID, hook_event_name: "SessionStart" }, - { CLAUDE_CONFIG_DIR: cfg }, + { hook_event_name: "SessionStart" }, + { + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }, ); - 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 }); + assert.equal(result.code, 0); + const context = additionalContext(result.stdout); + assert.match(context, /professional engineering standard/i); + assert.doesNotMatch(context, /model adapter/i); + rmSync(config, { 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("session-start selects the exact Opus profile from hook input", () => { + const config = tmpConfig(); + const result = runHook( + "session-start.mjs", + { hook_event_name: "SessionStart", model: "claude-opus-4-8" }, + { CLAUDE_CONFIG_DIR: config, MERIDIAN_MODEL_PROFILE: "" }, + ); + const context = additionalContext(result.stdout); + assert.match(context, /comment narration/i); + assert.match(context, /artifact overproduction/i); + assert.doesNotMatch(context, /review recursion/i); + rmSync(config, { recursive: true, force: true }); }); -test("session-start re-emits orientation after compaction (source: compact)", () => { - // Auto/manual compaction drops the originally-injected orientation; SessionStart - // fires again with source "compact" (PostCompact cannot inject), so the hook must - // re-emit the routing table. This is the plugin's core long-session promise. - const cfg = tmpConfig(); - const { code, stdout } = runHook( +test("environment override wins over detected model", () => { + const config = tmpConfig(); + const result = runHook( "session-start.mjs", - { session_id: SID, hook_event_name: "SessionStart", source: "compact" }, - { CLAUDE_CONFIG_DIR: cfg }, + { hook_event_name: "SessionStart", model: "claude-opus-4-8" }, + { CLAUDE_CONFIG_DIR: config, MERIDIAN_MODEL_PROFILE: "fable" }, ); - assert.equal(code, 0); - assert.match( - JSON.parse(stdout).hookSpecificOutput.additionalContext, - /\[Meridian orientation\]/, - "routing table restored after compaction", + const context = additionalContext(result.stdout); + assert.match(context, /professional engineering standard/i); + assert.doesNotMatch(context, /model adapter/i); + rmSync(config, { recursive: true, force: true }); +}); + +test("host-local fallback selects a profile when the hook has no model", () => { + const config = tmpConfig(); + mkdirSync(join(config, "meridian"), { recursive: true }); + writeFileSync( + join(config, "meridian", "config.json"), + JSON.stringify({ fallbackProfile: "sol" }), + ); + const result = runHook( + "session-start.mjs", + { hook_event_name: "SessionStart" }, + { + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }, ); - rmSync(cfg, { recursive: true, force: true }); + const context = additionalContext(result.stdout); + assert.match(context, /review recursion/i); + assert.match(context, /depth escalation/i); + assert.match(context, /workflow literalism/i); + assert.match(context, /agreement seeking/i); + rmSync(config, { recursive: true, force: true }); }); -test("hooks.json SessionStart matcher stays empty so it catches the compact source", () => { - // Narrowing this matcher to e.g. "startup" would silently stop post-compaction - // re-injection — the exact failure the test above guards against, pinned here too. - const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); - for (const matcher of config.hooks.SessionStart) { - assert.equal(matcher.matcher, "", "SessionStart must match all sources, incl. compaction"); - } +test("malformed host config degrades to the professional baseline", () => { + const config = tmpConfig(); + mkdirSync(join(config, "meridian"), { recursive: true }); + writeFileSync(join(config, "meridian", "config.json"), "not json"); + const result = runHook( + "session-start.mjs", + {}, + { + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }, + ); + assert.match(additionalContext(result.stdout), /professional engineering standard/i); + assert.doesNotMatch(additionalContext(result.stdout), /model adapter/i); + rmSync(config, { recursive: true, force: true }); }); -test("session-start emits orientation under Codex (un-prefixed PLUGIN_ROOT)", () => { - // Codex is detected via PLUGIN_ROOT and, like Claude, takes the hookSpecificOutput - // emit path — so the same orientation payload must come back. CODEX_HOME points state - // at the temp dir so the real ~/.codex is untouched. - const cfg = tmpConfig(); - const { code, stdout } = runHook( +test("session-start re-emits behavior context after compaction", () => { + const config = tmpConfig(); + const result = runHook( "session-start.mjs", - { session_id: SID, hook_event_name: "SessionStart", source: "startup" }, - { PLUGIN_ROOT: "/fake/plugin", CLAUDE_PLUGIN_ROOT: "/fake/plugin", CODEX_HOME: cfg }, + { hook_event_name: "SessionStart", source: "compact", model: "claude-opus-4-8" }, + { CLAUDE_CONFIG_DIR: config, MERIDIAN_MODEL_PROFILE: "" }, ); - assert.equal(code, 0); - const out = JSON.parse(stdout); - assert.equal(out.hookSpecificOutput.hookEventName, "SessionStart"); - assert.match(out.hookSpecificOutput.additionalContext, /\[Meridian orientation\]/); - rmSync(cfg, { recursive: true, force: true }); + assert.match(additionalContext(result.stdout), /comment narration/i); + rmSync(config, { recursive: true, force: true }); }); -test("pre-tool-use denies a git commit carrying AI attribution", () => { - for (const command of [ - 'git commit -m "feat: x\n\nCo-Authored-By: Claude "', - "git commit -m 'fix: y' -m 'Claude-Session: https://claude.ai/code/abc'", - 'git commit -m "chore\n\n🤖 Generated with Claude Code"', - ]) { - const { code, stdout } = runHook("pre-tool-use.mjs", { - tool_name: "Bash", - tool_input: { command }, - }); - assert.equal(code, 0); - const out = JSON.parse(stdout); - assert.equal(out.hookSpecificOutput.hookEventName, "PreToolUse"); - assert.equal(out.hookSpecificOutput.permissionDecision, "deny", command); - assert.match(out.hookSpecificOutput.permissionDecisionReason, /attribution/i); - } +test("session-start emits the Codex context shape", () => { + const config = tmpConfig(); + const result = runHook( + "session-start.mjs", + { hook_event_name: "SessionStart", model: "unknown" }, + { + PLUGIN_ROOT: "/fake/plugin", + CLAUDE_PLUGIN_ROOT: "/fake/plugin", + CODEX_HOME: config, + MERIDIAN_MODEL_PROFILE: "", + }, + ); + const output = JSON.parse(result.stdout); + assert.equal(output.hookSpecificOutput.hookEventName, "SessionStart"); + assert.match(output.hookSpecificOutput.additionalContext, /professional engineering standard/i); + rmSync(config, { recursive: true, force: true }); +}); + +test("session-start emits Cursor additional_context", () => { + const config = tmpConfig(); + const result = runHook( + "session-start.mjs", + { hook_event_name: "sessionStart", model_id: "claude-opus-4-8" }, + { + CURSOR_PLUGIN_ROOT: "/fake/plugin", + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }, + ); + const output = JSON.parse(result.stdout); + assert.match(output.additional_context, /comment narration/i); + assert.equal(output.hookSpecificOutput, undefined); + rmSync(config, { recursive: true, force: true }); +}); + +test("session-start emits Copilot additionalContext", () => { + const config = tmpConfig(); + const result = runHook( + "session-start.mjs", + { hook_event_name: "sessionStart" }, + { + COPILOT_PLUGIN_ROOT: "/fake/plugin", + COPILOT_HOME: config, + MERIDIAN_MODEL_PROFILE: "sol", + }, + ); + const output = JSON.parse(result.stdout); + assert.match(output.additionalContext, /review recursion/i); + assert.equal(output.hookSpecificOutput, undefined); + assert.equal(output.additional_context, undefined); + rmSync(config, { recursive: true, force: true }); +}); + +test("session-start survives malformed hook input", () => { + const config = tmpConfig(); + const result = runHook("session-start.mjs", "not json", { + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }); + assert.equal(result.code, 0); + assert.match(additionalContext(result.stdout), /professional engineering standard/i); + rmSync(config, { recursive: true, force: true }); }); -test("pre-tool-use denies staging .meridian artifacts", () => { +test("session-start emits a single-line JSON payload", () => { + const config = tmpConfig(); + const result = runHook( + "session-start.mjs", + {}, + { + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "", + }, + ); + assert.ok(result.stdout.endsWith("\n")); + assert.ok(!result.stdout.trimEnd().includes("\n")); + rmSync(config, { recursive: true, force: true }); +}); + +test("pre-tool-use denies AI attribution and staging Meridian artifacts", () => { for (const command of [ + 'git commit -m "feat: x\n\nCo-Authored-By: Claude "', + "git commit -m 'fix: y' -m 'Claude-Session: https://claude.ai/code/abc'", "git add .meridian/specs/x.md", - "git add -f .meridian", "git stage .meridian/audits/a.md", ]) { - const { code, stdout } = runHook("pre-tool-use.mjs", { + const result = runHook("pre-tool-use.mjs", { tool_name: "Bash", tool_input: { command }, }); - assert.equal(code, 0); - assert.equal(JSON.parse(stdout).hookSpecificOutput.permissionDecision, "deny", command); + assert.equal(result.code, 0); + assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, "deny", command); } }); test("pre-tool-use allows clean commits and unrelated commands", () => { - // A commit that merely mentions "claude" (no attribution trailer) and a normal - // `git add .` (which can't stage gitignored .meridian) must pass untouched. for (const command of [ - 'git commit -m "feat(meridian): add claude model id reference"', + 'git commit -m "feat: add model profile"', "git add src/index.ts", "git add .", - "git add src/data.meridian-export.json", // .meridian mid-filename is not the dir "npm test", ]) { - const { code, stdout } = runHook("pre-tool-use.mjs", { + const result = runHook("pre-tool-use.mjs", { tool_name: "Bash", tool_input: { command }, }); - assert.equal(code, 0, command); - assert.equal(stdout.trim(), "", `should allow: ${command}`); - } -}); - -test("pre-tool-use ignores non-Bash tools and malformed input", () => { - const edit = runHook("pre-tool-use.mjs", { - tool_name: "Edit", - tool_input: { file_path: "x.ts" }, - }); - assert.equal(edit.code, 0); - assert.equal(edit.stdout.trim(), ""); - const garbage = runHook("pre-tool-use.mjs", "not json {{{"); - assert.equal(garbage.code, 0); - assert.equal(garbage.stdout.trim(), ""); -}); - -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`); + assert.equal(result.code, 0); + assert.equal(result.stdout.trim(), "", command); } - 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 emits the debug reroute on a terse failure reply", () => { - for (const prompt of [ - "still broken", - "not fixed", - "doesn't work", - "still doesnt work", - "nope, still wrong", - "STILL BROKEN.", - ]) { - const cfg = tmpConfig(); - const { code, stdout } = runHook( - "user-prompt-submit.mjs", - { session_id: SID, prompt }, - { CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - const out = JSON.parse(stdout); - assert.equal(out.hookSpecificOutput.hookEventName, "UserPromptSubmit"); - assert.match( - out.hookSpecificOutput.additionalContext, - /debug reroute/i, - `should reroute on ${JSON.stringify(prompt)}`, - ); - rmSync(cfg, { recursive: true, force: true }); - } -}); +test("hook manifests expose only session behavior and the existing commit guard", () => { + const shared = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); + assert.deepEqual(Object.keys(shared.hooks).sort(), ["PreToolUse", "SessionStart"]); -test("user-prompt-submit does NOT reroute on elaborated or unrelated messages", () => { - // Anchored whole-message match: an elaborated bug report or a normal request - // must stay silent. Substring matching here would be the false-positive footgun. - for (const prompt of [ - "no, the real issue is still broken because the cache never clears so rework it", - "can you add a copy button to the toolbar", - "nope", // bare nope is deliberately excluded as too ambiguous - "is the build working", - ]) { - const cfg = tmpConfig(); - const { code, stdout } = runHook( - "user-prompt-submit.mjs", - { session_id: SID, prompt }, - { CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - assert.equal(stdout.trim(), "", `should be silent on ${JSON.stringify(prompt)}`); - rmSync(cfg, { recursive: true, force: true }); - } -}); + const cursor = JSON.parse(readFileSync(join(HOOKS, "hooks-cursor.json"), "utf8")); + assert.deepEqual(Object.keys(cursor.hooks), ["sessionStart"]); -test("debug reroute fires on the first prompt and pre-empts the periodic audit", () => { - const cfg = tmpConfig(); - // Advance to the audit tick, but make the 8th prompt a failure signal: reroute wins. - for (let i = 1; i <= 7; i++) { - runHook( - "user-prompt-submit.mjs", - { session_id: SID, prompt: "keep going" }, - { CLAUDE_CONFIG_DIR: cfg }, - ); - } - const { stdout } = runHook( - "user-prompt-submit.mjs", - { session_id: SID, prompt: "still broken" }, - { CLAUDE_CONFIG_DIR: cfg }, - ); - const out = JSON.parse(stdout); - assert.match(out.hookSpecificOutput.additionalContext, /debug reroute/i); - assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /routing audit/i); - rmSync(cfg, { recursive: true, force: true }); + const copilot = JSON.parse(readFileSync(join(HOOKS, "hooks-copilot.json"), "utf8")); + assert.deepEqual(Object.keys(copilot.hooks), ["sessionStart"]); }); -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 resolves the plugin root from env at runtime (cross-engine)", () => { - // Engines disagree on how the plugin root reaches a hook: Claude Code substitutes - // ${CLAUDE_PLUGIN_ROOT}; Copilot Chat sets only PLUGIN_ROOT and substitutes nothing. - // Resolve it inside node from either var so the command is shell-agnostic (works under - // sh AND PowerShell, where ${VAR:-default} would fail) and spawn the script directly — - // NOT via import()/pathToFileURL, which throws ERR_INVALID_MODULE_SPECIFIER on newer - // node when the path carries backslashes. A bare command:"node"+args crashes the - // no-args engines, so args must be absent. Under WSL, Copilot Chat hands over a Windows - // path (C:\...); translate it to the mount via wslpath, falling back to /mnt. The whole - // thing must contain no backslash literals (the shell eats them), so the split uses - // String.fromCharCode(92). - const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); - const pluginRoot = join(HOOKS, ".."); - for (const matchers of Object.values(config.hooks)) { +test("hook commands resolve existing scripts without command args", () => { + const shared = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); + for (const matchers of Object.values(shared.hooks)) { for (const matcher of matchers) { for (const hook of matcher.hooks) { - assert.equal(hook.args, undefined, "must not use the command+args exec form"); - assert.doesNotMatch( - hook.command, - /\$\{[A-Z_]+\}/, - "must not rely on engine/shell ${VAR} substitution", - ); - assert.doesNotMatch( - hook.command, - /import\(|pathToFileURL/, - "must not use dynamic import() (ERR_INVALID_MODULE_SPECIFIER on newer node)", - ); + assert.equal(hook.args, undefined); + assert.doesNotMatch(hook.command, /\$\{[A-Z_]+\}/); + assert.doesNotMatch(hook.command, /import\(|pathToFileURL/); assert.match( hook.command, /process\.env\.CLAUDE_PLUGIN_ROOT\s*\|\|\s*process\.env\.PLUGIN_ROOT/, - "resolves the root from CLAUDE_PLUGIN_ROOT or PLUGIN_ROOT", - ); - assert.match( - hook.command, - /if\(!r\)process\.exit\(0\)/, - "exits cleanly when neither plugin-root var is set, instead of crashing on an undefined path", ); - assert.doesNotMatch(hook.command, /\\/, "no backslash literals (the shell eats them)"); - assert.match( - hook.command, - /wslpath[\s\S]*\/mnt\//, - "translates a WSL Windows plugin-root path (wslpath, then /mnt fallback)", + assert.match(hook.command, /if\(!r\)process\.exit\(0\)/); + assert.doesNotMatch(hook.command, /\\/); + assert.match(hook.command, /wslpath[\s\S]*\/mnt\//); + const path = hook.command.match(/\/(hooks\/[\w-]+\.mjs)'/)?.[1]; + assert.ok( + path && existsSync(join(HOOKS, "..", path)), + `existing script for ${hook.command}`, ); - const m = hook.command.match(/\/(hooks\/[\w-]+\.mjs)'/); - assert.ok(m, `command embeds the script path: ${hook.command}`); - assert.ok(existsSync(join(pluginRoot, m[1])), `referenced hook script exists: ${m[1]}`); } } } }); -test("hooks.json SessionStart runs from any engine env, even with the wrong cwd", () => { - // Reproduce both engines: command run via shell, payload on stdin, no args, cwd is NOT - // the plugin dir (Copilot Chat runs hooks from $HOME). It must still resolve and emit - // the orientation whether the engine provides CLAUDE_PLUGIN_ROOT (Claude Code / Copilot - // CLI) or only PLUGIN_ROOT (Copilot Chat). +test("SessionStart command runs outside the plugin working directory", () => { const pluginRoot = join(HOOKS, ".."); - const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); - const command = config.hooks.SessionStart[0].hooks[0].command; + const shared = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); + const command = shared.hooks.SessionStart[0].hooks[0].command; + for (const rootVar of ["CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT"]) { - const cfg = tmpConfig(); + const config = tmpConfig(); /** @type {Record} */ - const env = { ...process.env, CLAUDE_CONFIG_DIR: cfg }; + const env = { + ...process.env, + CLAUDE_CONFIG_DIR: config, + MERIDIAN_MODEL_PROFILE: "fable", + }; delete env.CLAUDE_PLUGIN_ROOT; delete env.PLUGIN_ROOT; delete env.COPILOT_PLUGIN_ROOT; delete env.CURSOR_PLUGIN_ROOT; env[rootVar] = pluginRoot; - let out; - try { - out = execSync(command, { - cwd: tmpdir(), - input: JSON.stringify({ session_id: SID, hook_event_name: "SessionStart" }), - encoding: "utf8", - env, - }); - } catch (err) { - const e = /** @type {any} */ (err); - assert.fail(`hook command failed with only ${rootVar} set: ${e.stderr || e.message}`); - } - assert.match( - JSON.parse(out).hookSpecificOutput.additionalContext, - /\[Meridian orientation\]/, - `orientation emitted with only ${rootVar} set`, - ); - rmSync(cfg, { recursive: true, force: true }); + const output = execSync(command, { + cwd: tmpdir(), + input: JSON.stringify({ hook_event_name: "SessionStart" }), + encoding: "utf8", + env, + }); + assert.match(additionalContext(output), /professional engineering standard/i); + rmSync(config, { recursive: true, force: true }); } }); -test("hooks.json hooks exit cleanly when no plugin-root env var is set", () => { - // If neither CLAUDE_PLUGIN_ROOT nor PLUGIN_ROOT is set, the resolver must bail - // (exit 0) rather than spawn 'undefined/hooks/*.mjs' and crash the session. - const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); - for (const matchers of Object.values(config.hooks)) { +test("shared hooks exit cleanly without a plugin root", () => { + const shared = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); + for (const matchers of Object.values(shared.hooks)) { for (const matcher of matchers) { for (const hook of matcher.hooks) { - const cfg = tmpConfig(); /** @type {Record} */ - const env = { ...process.env, CLAUDE_CONFIG_DIR: cfg }; + const env = { ...process.env }; delete env.CLAUDE_PLUGIN_ROOT; delete env.PLUGIN_ROOT; delete env.COPILOT_PLUGIN_ROOT; @@ -464,152 +322,16 @@ test("hooks.json hooks exit cleanly when no plugin-root env var is set", () => { let code = 0; try { execSync(hook.command, { cwd: tmpdir(), input: "{}", encoding: "utf8", env }); - } catch (err) { - code = /** @type {any} */ (err).status ?? 1; + } catch (error) { + code = /** @type {any} */ (error).status ?? 1; } - assert.equal(code, 0, `exits 0 with no plugin root: ${hook.command}`); - rmSync(cfg, { recursive: true, force: true }); + assert.equal(code, 0, hook.command); } } } }); -test( - "hooks.json translates a WSL Windows plugin root to /mnt at runtime", - { skip: process.platform !== "linux" }, - () => { - // The static test above asserts the fallback CODE is present; this one runs it. - // On a Linux runner a Windows-style root (C:\...) takes the WSL branch; wslpath is - // absent on plain Ubuntu (or yields the same answer on real WSL), so the manual - // /mnt translation fires. node then fails to find the (nonexistent) translated - // script, and the path it reports proves C:\nope\plugin became /mnt/c/nope/plugin. - const config = JSON.parse(readFileSync(join(HOOKS, "hooks.json"), "utf8")); - const command = config.hooks.SessionStart[0].hooks[0].command; - const cfg = tmpConfig(); - /** @type {Record} */ - const env = { ...process.env, CLAUDE_CONFIG_DIR: cfg }; - delete env.PLUGIN_ROOT; - delete env.COPILOT_PLUGIN_ROOT; - delete env.CURSOR_PLUGIN_ROOT; - env.CLAUDE_PLUGIN_ROOT = "C:\\nope\\plugin"; - let diagnostics = ""; - try { - execSync(command, { cwd: tmpdir(), input: "{}", encoding: "utf8", env }); - } catch (err) { - const e = /** @type {any} */ (err); - diagnostics = String(e.stderr ?? "") + String(e.message ?? ""); - } - assert.match(diagnostics, /\/mnt\/c\/nope\/plugin/, "Windows root translated to /mnt path"); - rmSync(cfg, { recursive: true, force: true }); - }, -); - -test("hooks-cursor.json invokes node hook scripts", () => { - const config = JSON.parse(readFileSync(join(HOOKS, "hooks-cursor.json"), "utf8")); - assert.equal(config.version, 1); - for (const entries of Object.values(config.hooks)) { - for (const entry of entries) { - assert.match(entry.command, /^node \.\/hooks\/[\w-]+\.mjs$/); - const script = entry.command.slice("node ./hooks/".length); - assert.ok(existsSync(join(HOOKS, script)), `cursor hook script exists: ${script}`); - } - } -}); - -test("session-start emits additional_context on Cursor", () => { - const cfg = tmpConfig(); - const { code, stdout } = runHook( - "session-start.mjs", - { session_id: SID, hook_event_name: "sessionStart" }, - { CURSOR_PLUGIN_ROOT: "/fake/plugin", CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - const out = JSON.parse(stdout); - assert.ok(out.additional_context); - assert.equal(out.hookSpecificOutput, undefined); - assert.match(out.additional_context, /\[Meridian orientation\]/); - rmSync(cfg, { recursive: true, force: true }); -}); - -test("user-prompt-submit accepts conversation_id on Cursor", () => { - const cfg = tmpConfig(); - const conv = "cursor-conv-abc123"; - for (let i = 1; i <= 8; i++) { - const { code, stdout } = runHook( - "user-prompt-submit.mjs", - { conversation_id: conv, prompt: "hi" }, - { CURSOR_PLUGIN_ROOT: "/fake/plugin", CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - assert.equal(stdout.trim(), "", `cursor tick ${i} should be silent (no audit emit)`); - } - assert.ok( - existsSync(join(cfg, "meridian", "state", conv)), - "state dir created from conversation_id", - ); - assert.ok( - !existsSync(join(cfg, "meridian", "state", conv, "router-tick")), - "cursor should not write router-tick", - ); - rmSync(cfg, { recursive: true, force: true }); -}); - -test("hooks-copilot.json invokes node hook scripts via single-string command", () => { - const config = JSON.parse(readFileSync(join(HOOKS, "hooks-copilot.json"), "utf8")); - assert.equal(config.version, 1); - for (const entries of Object.values(config.hooks)) { - for (const entry of entries) { - // Copilot treats `command` as a shell string and ignores `args`; the - // command+args exec form is what crashes it. Guard against a regression. - assert.match(entry.command, /^node \.\/hooks\/[\w-]+\.mjs$/); - assert.equal(entry.args, undefined, "copilot must not use the command+args exec form"); - const script = entry.command.slice("node ./hooks/".length); - assert.ok(existsSync(join(HOOKS, script)), `copilot hook script exists: ${script}`); - } - } -}); - -test(".plugin/plugin.json redirects Copilot to its hooks config", () => { +test("Copilot manifest redirects to its hooks config", () => { const manifest = JSON.parse(readFileSync(join(HOOKS, "..", ".plugin", "plugin.json"), "utf8")); - assert.equal(typeof manifest.hooks, "string", "declares a hooks config path"); - assert.ok( - existsSync(join(HOOKS, "..", manifest.hooks)), - `redirect target exists: ${manifest.hooks}`, - ); -}); - -test("session-start emits flat additionalContext on Copilot", () => { - const cfg = tmpConfig(); - const { code, stdout } = runHook( - "session-start.mjs", - { sessionId: SID, hook_event_name: "sessionStart" }, - { COPILOT_PLUGIN_ROOT: "/fake/plugin", CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - const out = JSON.parse(stdout); - assert.ok(out.additionalContext, "flat additionalContext present"); - assert.equal(out.hookSpecificOutput, undefined, "not Claude's nested form"); - assert.equal(out.additional_context, undefined, "not Cursor's snake_case form"); - assert.match(out.additionalContext, /\[Meridian orientation\]/); - rmSync(cfg, { recursive: true, force: true }); -}); - -test("user-prompt-submit is state-only on Copilot (no injection, even on a failure signal)", () => { - const cfg = tmpConfig(); - const sid = "copilot-sess-abc123"; - for (let i = 1; i <= 8; i++) { - const { code, stdout } = runHook( - "user-prompt-submit.mjs", - { sessionId: sid, prompt: "still broken" }, - { COPILOT_PLUGIN_ROOT: "/fake/plugin", CLAUDE_CONFIG_DIR: cfg }, - ); - assert.equal(code, 0); - assert.equal(stdout.trim(), "", `copilot prompt ${i} should not inject`); - } - assert.ok(existsSync(join(cfg, "meridian", "state", sid)), "state dir created from sessionId"); - assert.ok( - !existsSync(join(cfg, "meridian", "state", sid, "router-tick")), - "copilot should not write router-tick", - ); - rmSync(cfg, { recursive: true, force: true }); + assert.ok(existsSync(join(HOOKS, "..", manifest.hooks))); }); diff --git a/test/meridian-lib.test.mjs b/test/meridian-lib.test.mjs index ce40ed8..13daf1f 100644 --- a/test/meridian-lib.test.mjs +++ b/test/meridian-lib.test.mjs @@ -4,66 +4,80 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { auditDue, isFailureSignal, sessionId } from "../hooks/lib/signals.mjs"; import { detectHost } from "../hooks/lib/host.mjs"; +import { + activeModel, + composeBehavior, + loadBehaviorContext, + resolveProfile, +} from "../hooks/lib/behavior.mjs"; -test("sessionId accepts safe session_id / conversation_id, rejects the rest", () => { - assert.equal(sessionId({ session_id: "abc-123_DEF" }), "abc-123_DEF"); - assert.equal(sessionId({ conversation_id: "cursor-conv-1" }), "cursor-conv-1"); - assert.equal(sessionId({ sessionId: "copilot-1" }), "copilot-1"); - assert.equal(sessionId({ session_id: "s", conversation_id: "c" }), "s", "session_id wins"); +const REGISTRY = { + profiles: { exact: ["a"], family: ["b"], fallback: [] }, + modelMatches: [ + { prefix: "model-", profile: "family" }, + { exact: "model-exact", profile: "exact" }, + ], +}; + +test("activeModel prefers model_id and ignores empty identifiers", () => { + assert.equal(activeModel({ model_id: "cursor-id", model: "fallback" }), "cursor-id"); + assert.equal(activeModel({ model_id: " ", model: "fallback" }), "fallback"); + assert.equal(activeModel({}), null); +}); + +test("resolveProfile applies override, exact, family, fallback, then baseline", () => { + assert.equal(resolveProfile(REGISTRY, "fallback", "model-exact", null), "fallback"); + assert.equal(resolveProfile(REGISTRY, null, "model-exact", "fallback"), "exact"); + assert.equal(resolveProfile(REGISTRY, null, "model-other", "fallback"), "family"); + assert.equal(resolveProfile(REGISTRY, null, "unknown", "fallback"), "fallback"); + assert.equal(resolveProfile(REGISTRY, null, "unknown", null), null); + assert.equal(resolveProfile(REGISTRY, "missing", "model-exact", "fallback"), null); +}); + +test("composeBehavior keeps the standard first and preserves adapter order", () => { assert.equal( - sessionId({ session_id: "s", sessionId: "p" }), - "s", - "session_id wins over sessionId", + composeBehavior(" standard\n", ["adapter one", "\nadapter two\n"]), + "standard\n\nadapter one\n\nadapter two", ); - for (const bad of ["../../etc/evil", "a/b", "has space", "$(touch x)", "", undefined, 42]) { - assert.equal( - sessionId(/** @type {any} */ ({ session_id: bad })), - null, - `rejects ${JSON.stringify(bad)}`, - ); - } - assert.equal(sessionId({}), null); }); -test("isFailureSignal matches only terse 'fix didn't land' replies", () => { - for (const p of [ - "still broken", - "not fixed", - "doesn't work", - "still doesnt work", - "nope, still wrong", - "STILL BROKEN.", - "that's still broken", - "still broken", // accidental double-space still matches - ]) { - assert.equal(isFailureSignal(p), true, `matches ${JSON.stringify(p)}`); - } - for (const p of [ - "nope", // bare nope deliberately excluded - "no, the real issue is still broken because the cache never clears so rework it", // elaborated - "can you add a copy button to the toolbar", // unrelated - "is the build working", // question - "", - 42, - undefined, - ]) { - assert.equal(isFailureSignal(p), false, `ignores ${JSON.stringify(p)}`); - } -}); +test("loadBehaviorContext composes detected, fallback, and baseline-only profiles", () => { + const stateBase = mkdtempSync(join(tmpdir(), "meridian-profile-")); + const opus = loadBehaviorContext({ + input: { model: "claude-opus-4-8" }, + stateBase, + }); + assert.match(opus, /professional engineering standard/i); + assert.match(opus, /comment narration/i); + assert.match(opus, /artifact overproduction/i); + assert.ok(opus.indexOf("professional engineering standard") < opus.indexOf("comment narration")); + assert.ok(opus.indexOf("comment narration") < opus.indexOf("artifact overproduction")); + + mkdirSync(join(stateBase, "meridian"), { recursive: true }); + writeFileSync( + join(stateBase, "meridian", "config.json"), + JSON.stringify({ fallbackProfile: "sol" }), + ); + const fallback = loadBehaviorContext({ input: {}, stateBase }); + assert.match(fallback, /review recursion/i); + assert.doesNotMatch(fallback, /comment narration/i); -test("auditDue fires every Nth count, never at 0", () => { - assert.equal(auditDue(8, 8), true); - assert.equal(auditDue(16, 8), true); - assert.equal(auditDue(7, 8), false); - assert.equal(auditDue(9, 8), false); - assert.equal(auditDue(0, 8), false); + const baseline = loadBehaviorContext({ + input: { model: "claude-opus-4-8" }, + stateBase, + environmentProfile: "missing", + }); + assert.match(baseline, /professional engineering standard/i); + assert.doesNotMatch(baseline, /model adapter/i); + rmSync(stateBase, { recursive: true, force: true }); }); -test("detectHost resolves the Cursor state base and event support", () => { +test("detectHost resolves the Cursor state base", () => { const saved = { CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, @@ -75,15 +89,13 @@ test("detectHost resolves the Cursor state base and event support", () => { const host = detectHost(); assert.equal(host.name, "cursor"); assert.equal(host.stateBase, join(homedir(), ".cursor")); - assert.equal(host.supportsContext("SessionStart"), true); - assert.equal(host.supportsContext("UserPromptSubmit"), false); for (const [key, val] of Object.entries(saved)) { if (val === undefined) delete process.env[key]; else process.env[key] = val; } }); -test("detectHost resolves the Copilot state base and event support", () => { +test("detectHost resolves the Copilot state base", () => { const saved = { CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, COPILOT_PLUGIN_ROOT: process.env.COPILOT_PLUGIN_ROOT, @@ -99,8 +111,6 @@ test("detectHost resolves the Copilot state base and event support", () => { const host = detectHost(); assert.equal(host.name, "copilot"); assert.equal(host.stateBase, join(homedir(), ".copilot")); - assert.equal(host.supportsContext("SessionStart"), true); - assert.equal(host.supportsContext("UserPromptSubmit"), false); for (const [key, val] of Object.entries(saved)) { if (val === undefined) delete process.env[key]; else process.env[key] = val; @@ -132,10 +142,10 @@ test("Copilot state base stays ~/.copilot even when CLAUDE_PLUGIN_ROOT is also s } }); -test("detectHost resolves the Codex state base and injects on every event", () => { +test("detectHost resolves the Codex state base", () => { // Codex ships no unique identity var; it sets the un-prefixed PLUGIN_ROOT (plus // CLAUDE_PLUGIN_ROOT for compat) — the documented discriminator from Claude, which - // sets only CLAUDE_PLUGIN_ROOT. Like Claude, Codex injects on every event. + // sets only CLAUDE_PLUGIN_ROOT. Codex and Claude share the SessionStart output shape. const saved = { CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, COPILOT_PLUGIN_ROOT: process.env.COPILOT_PLUGIN_ROOT, @@ -153,15 +163,13 @@ test("detectHost resolves the Codex state base and injects on every event", () = const host = detectHost(); assert.equal(host.name, "codex"); assert.equal(host.stateBase, join(homedir(), ".codex")); - assert.equal(host.supportsContext("SessionStart"), true); - assert.equal(host.supportsContext("UserPromptSubmit"), true); for (const [key, val] of Object.entries(saved)) { if (val === undefined) delete process.env[key]; else process.env[key] = val; } }); -test("detectHost defaults to claude and injects on every event", () => { +test("detectHost defaults to claude", () => { const saved = { CURSOR_PLUGIN_ROOT: process.env.CURSOR_PLUGIN_ROOT, COPILOT_PLUGIN_ROOT: process.env.COPILOT_PLUGIN_ROOT, @@ -172,7 +180,6 @@ test("detectHost defaults to claude and injects on every event", () => { delete process.env.PLUGIN_ROOT; const host = detectHost(); assert.equal(host.name, "claude"); - assert.equal(host.supportsContext("UserPromptSubmit"), true); for (const [key, val] of Object.entries(saved)) { if (val === undefined) delete process.env[key]; else process.env[key] = val;