claude-prompts-mcp
minipuft/claude-prompts-mcp/CLAUDE.md
Source of Truth: server/dist/**. Confirm behavior there before describing or modifying functionality. The server floor is where node:sqlite is available without an experimental flag. The standalone CLI remains a separate, self-contained compatibility surface. CI is the contract; every other gate is a documented strict subset of it. (Not projected into AGENTS.md -- this is CI-routing history a Codex or OpenCode reader does not need to act on. CONTRIBUTING.md and the Pull Request Boundary section below carry the commands those readers…
CLAUDE.md187 starsChanged 39 days ago
- Reads credentials
- Commits and pushes
# Claude Prompts MCP -- Operator Handbook
**Source of Truth**: `server/dist/**`. Confirm behavior there before describing or modifying functionality.
## Core Principles
1. **MCP Tooling Only** -- Prompts, templates, chains flow through MCP tools. Manual edits under `server/prompts/**` forbidden.
2. **Contract ownership** -- Hand-written schemas in `src/mcp/tools/schemas/` own runtime validation; `tooling/contracts/*.json` own descriptions/parameter metadata generated into `src/mcp/contracts/schemas/_generated/`. Run `npm run generate:contracts`, never edit `_generated/`. `config.schema.json` <- `ConfigFile` (`generate:config-schema`).
3. **Transport Parity** -- Runtime changes must work in STDIO and Streamable HTTP. The two differ in instance lifetime, and that difference is load-bearing: STDIO pins one `McpServer` per connection, while HTTP builds a fresh one per request. A change that mutates a registered instance passes STDIO and silently no-ops over HTTP. HTTP+SSE was removed in the SDK v2 upgrade.
4. **Docs/Code Lockstep** -- Update relevant doc in `docs/` when behavior changes.
5. **Validation Discipline** -- `npm run typecheck && npm run lint:ratchet && npm run typecheck:tests:ratchet && npm run test:all` minimum. `validate:arch` (module boundaries) is not a separate add-on: it is a member of the `validate:all` suite below, and runs there. **`typecheck:tests:ratchet` is not optional**: `tsconfig.json` excludes `tests/`, so `typecheck` is blind to every call site a signature change breaks, and `validate:all` -- which CI runs whole -- runs the ratchet second. Omitting it locally means CI fails on work that passed every gate you ran. **`test:all`, not `test:ci`**: `test:ci` is an alias for `test:unit` and runs neither `tests/integration` nor `tests/e2e`, while CI runs both as separate jobs -- so its name promises the opposite of what it does. Measured 2026-08-27: a default-deny control landed with the unit suite green, and 20 integration plus 17 e2e failures went unseen for five commits because every local check stopped at `test:ci`. `pre-push` does not run integration or e2e tests -- CI runs them at the PR boundary. `test:all` is the only way to catch this locally before a push.
## Node.js Support Boundaries
| Surface | Supported Node.js | Enforcement |
| -------------------------------- | ----------------- | ------------------------------------------------------------ |
| MCP server and desktop extension | >=22.13.0 | `server/package.json`, `manifest.json`, CI on 22.13.0 and 24 |
| Standalone CPM CLI | >=18.18.0 | `cli/package.json` and CLI runtime validation |
| Local development and publishing | 24 | `.node-version` and publish workflows |
The server floor is where `node:sqlite` is available without an experimental flag. The standalone CLI remains a separate, self-contained compatibility surface.
## Validation Gates (one contract, impact-aware subsets)
**CI is the contract; every other gate is a documented strict subset of it.**
*(Not projected into `AGENTS.md` -- this is CI-routing history a Codex or OpenCode reader does not
need to act on. `CONTRIBUTING.md` and the `Pull Request Boundary` section below carry the commands
those readers actually run.)*
The three gates once ran three different suites with no subset relation, so a green
`pre-push` did not predict CI, and neither did the local full-validation wrapper that
existed at the time -- that is how a pyrefly failure reached `main` from a clean local
push. That wrapper was deleted once the subset relation made it redundant.
`scripts/classify-validation-scope.js` is the changed-path SSOT for local push and CI.
It recognizes two narrow safe scopes and sends every empty, mixed, executable,
configuration, dependency, deleted-unknown, or unrecognized change to `full`.
| Scope | Trigger | CI |
| ------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `docs` | documented root handbooks, `docs/**/*.md`, `plans/**/*.md`, and server/CLI READMEs only | classifier hygiene · CONTRIBUTING commands · plan row tracking · README charter · guidance projection; four protected jobs report intentional lightweight passes |
| `hooks` | only `hooks/**` plus optional docs | pinned Ruff/Pyrefly/Pytest/PyYAML · the same four documentation checks as `docs`; other protected jobs report intentional lightweight passes |
| `full` | everything else; empty/unknown input | typecheck · `validate:all` · CLI · build/smoke/schema · Node 22/24 unit/coverage/integration/E2E |
`.husky/pre-push` does not branch by scope: every push runs `typecheck` and `lint:ratchet`
only -- a deletion-only push (`git push --delete`) skips even that. CI stays scope-routed and
owns the rest -- format, lockfile-sync, architecture, versions, Python hooks, unit/integration/
E2E, build, and post-build schema gates -- once, at the PR boundary.
The CI workflow remains unconditional. Do not add workflow-level `paths` or
`paths-ignore`: a required workflow skipped before jobs exist leaves its context
pending. Routing happens inside the workflow while the literal `Lint & Validate`,
`CLI`, `Build`, and `Test Suite` job names remain stable.
`.husky/pre-commit` remains the fast contract-regeneration, staged-lint, conditional
Python, and typecheck gate. Every local route remains a subset of CI.
**A local gate is only as trustworthy as the toolchain it ran against**, which is a
different axis from which steps run where. CI installs with `npm ci` and pinned pip
versions; a developer box installs whenever it last installed. Measured 2026-08-19: a local
tree sat 18 packages off the lockfile with knip at 6.32.1 against the lockfile's 6.32.2, and
the knip ratchet baseline was regenerated with the wrong binary. Two commands close it, both
reading the same file CI reads -- `validate:lockfile-sync` (inside `validate:all`) and
`setup:python` (`requirements-dev.txt`). Node no longer forks either: every job reads
`.node-version`, and the test matrix is the only place 22.13.0 appears.
**`lint:ratchet` does NOT run at pre-commit** (since `a5d8cb51`). It is a whole-project
DIRECTION measure, and direction is a push concern; per-commit conformance is `lint:staged`,
which lints exactly what is staged. Running it per-commit also meant an unrelated violation
anywhere in `src` blocked every commit -- in a shared worktree, blocking on work that is not
yours. Coverage is unchanged: `pre-push` runs it and CI runs it. Pre-commit floor measured
4.4s against the `ci-release.md` <10s budget.
**Adding a step to a hook that CI does not run breaks the contract** -- add it to
`validate:all` first, which CI runs whole. Removing a step CI depends on breaks it too.
Formatting is covered by `validate:format` in the full CI route and by `pre-commit`'s
staged-file check; `pre-push` does not check formatting. `validate:format` checks every tracked
`*.json`/`*.md`/`*.yml`/`*.yaml` in the repo, in two passes: repo-level files outside `server/**`
against the root Prettier config, and every tracked file under `server/**` (including
`resources/**` and `tests/**`) against `server/.prettierrc.json` and `server/.prettierignore`.
Anything a generator owns belongs in `.prettierignore` with a reason -- otherwise the generator
and Prettier disagree.
**Every formatting gate CHECKS; none of them writes** (since 2026-08-25). `pre-commit` and
`lint-staged` used to `prettier --write` the staged paths and re-`git add` them, so the bytes
committed were not the bytes any gate had validated -- each check ran against one version of
the file and the commit captured another. Formatting-only, so the blast radius was small, but
it is the same shape as a step that silently changes an artifact after the check that blessed
it. It also broke anchored editing: a rewrite between a read and the next fixed-string edit
makes that edit miss **silently**, which cost real work here on 2026-08-25. `--check` is also
a strict subset of CI, which only ever checks -- the old `--write` was a local step CI does
not run, which this section otherwise forbids. Fix with `npm --prefix server run format`
(same combined file set `validate:format` reads) or `format:server` (server TS/JS sources plus
the top-level `server/` config files -- narrower than `format`, since it does not recurse into
`resources/**` or `tests/**`).
**Format at authoring time; the gate is a backstop, not the boundary.** A gate you routinely
fail is a gate in the wrong place -- so format on save (editors) or as part of the edit itself
(agents: run `format` on what you touched BEFORE you validate, not after a hook rejects it).
This only works if your editor and the gate agree, which needs one contract per file: prettier
resolves the NEAREST config, so `server/**` takes `server/.prettierrc.json` (printWidth 100)
and everything else takes the root `.prettierrc.json` (printWidth 80). The root file was added
2026-08-25 and pins what was already happening -- root paths previously resolved NO config and
ran on prettier's built-in defaults, so the contract was whatever the installed major version
happened to do, and an editor plugin resolving its own settings would silently disagree with
the gate. Verified at the time: adding it reformatted zero files on either side.
## Pull Request Boundary
**`npm run pr:check` is the whole local gate, and it is a subset of CI by construction.**
`PR Conventions` is a required context whose gating steps on a non-bot PR are: two positive
controls, the body against `.github/pull_request_template.md`, and `commitlint` on the title. Run
every one of them before `gh pr create`, from the repo root:
```bash
TITLE="feat(scope): outcome"
npm run pr:body -- --out /tmp/pr-body.md # seed it; never author a body from scratch
$EDITOR /tmp/pr-body.md
npm run pr:check -- --body-file /tmp/pr-body.md --title "$TITLE"
```
The template requires `## Demonstration` (consumer-observable before/after, or `n/a: <reason>`), a
`## How it was verified` TABLE (claim · probe · baseline -> measured · the mutation that fails it --
a count with no baseline is noise), and `## Notes for Reviewers`, under a 400-word above-the-fold
budget that fenced blocks, tables and `<details>` do not count against. The `Plan:` footer is the
ONLY sanctioned plan mention and the gate FAILS while that plan's `status:` is non-final, so a PR
executing one step of a multi-step plan omits the footer entirely.
Parity is enforced rather than documented: `scripts/pr-check.mjs` names each workflow step it
mirrors and `server/tests/unit/scripts/pr-check-ci-parity.test.ts` reads `pr-conventions.yml` and
fails when the two sets diverge. That test exists because the relation had already broken in the
direction that costs a CI cycle -- `CONTRIBUTING.md` documented only the body check, and the body
checker states outright that it "does not read the title beyond its type", so following the
instructions exactly still shipped an unchecked title (#283, `subject-case`, 2026-09-14). A
hand-written body cost a second run three missing sections and a 488-word fold (#312, 2026-09-16).
**This section IS projected into `AGENTS.md`** (`PROJECTED_HANDBOOK_SECTIONS` in
`scripts/sync-project-guidance.js`). It previously was not: the projection measured 32,709 of its
32,768-byte ceiling with this section absent -- 59 bytes of headroom, not enough to add it. Evicting
`## Validation Gates (one contract, impact-aware subsets)` (below) freed the room: that section is
CI-routing detail a Codex or OpenCode reader does not need to act on, while this one is a command
they run before every PR. Codex and OpenCode also still reach this contract through
`CONTRIBUTING.md` §Pull Request Process and the usage text `pr-check.mjs` prints when invoked
without arguments.
## Fleet Standards Upstream (`minipuft/repository-standards`)
**Look there before writing any check that reads another repository, or any plan-status or dependency-policy rule.**
It owns the consumer-contract workflow + profiles, the shared Renovate preset, the plan
frontmatter/status vocabulary and `retire-done-plans`, and the **fleet drift audit** — which
already compares every downstream's resolved `claude-prompts` lock version, weekly, exiting
non-zero. This repo is the fleet's `upstream`, not a member; it consumes the standards as a pinned
tarball, a SHA-pinned action, a `$schema`, and a doc link.
A local downstream-version check was deleted here 2026-08-13 _because_ the fleet auditor does it
better (`validate-versions.js` records why). Re-adding one re-splits a settled decision — the
mistake was made and reverted on 2026-08-15. Two gates keep the relationship honest:
`validate:standards-pins` (all four references name one version) and
`validate:renovate-preset-agreement` (every preset key matches, or is a declared override).
## Documentation Map
| Topic | Doc |
| -------------------------------------------- | ----------------------------------------- |
| Architecture & runtime | `docs/architecture/overview.md` |
| SQLite persistence | `docs/architecture/sqlite-persistence.md` |
| MCP tools & symbolic commands | `docs/reference/mcp-tools.md` |
| MCP contract maintenance | `docs/guides/mcp-contract-maintenance.md` |
| Prompt authoring | `docs/tutorials/build-first-prompt.md` |
| Chains lifecycle | `docs/concepts/chains-lifecycle.md` |
| Gates | `docs/guides/gates.md` |
| Injection control | `docs/guides/injection-control.md` |
| Identity & scope | `docs/guides/identity-scope.md` |
| Skills Sync | `docs/guides/skills-sync.md` |
| Telemetry & observability | `docs/guides/telemetry-observability.md` |
| Troubleshooting | `docs/guides/troubleshooting.md` |
| Contributing & PR process | `CONTRIBUTING.md` |
| README charter (root README authoring rules) | `docs/portfolio/readme-charter.md` |
| Release highlights | `CHANGELOG.md` |
Read the relevant doc before editing. Update docs when behavior changes.
## Command Reference (run inside `server/`)
| Command | Purpose |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run build` | esbuild bundle -> `dist/index.js` |
| `npm run verify:mcp` | Spawn a server from `dist/` and prove all 3 MCP tools answer — **use instead of restarting Claude Code to check a build**. Refuses to run against a stale `dist/` |
| `npm run typecheck` | Strict TS type validation — **`src/` only**, `tsconfig.json` excludes `tests/` |
| `npm test` | Full Jest suite |
| `npm run lint:ratchet` | Fail if ESLint violations increased or decreased without lowering the baseline |
| `npm run typecheck:tests:ratchet` | Fail if `tests/` type errors increased or decreased without a baseline update. Covers call sites `typecheck` misses — a broken test file can otherwise land green |
| `npm run generate:contracts` | Regenerate MCP schemas from contracts |
| `npm run validate:lockfile-sync` | Fail when `node_modules` has drifted from `package-lock.json` — compares npm's own `node_modules/.package-lock.json`, so no subprocess and no output parsing |
| `npm run setup:python` | Install the pinned Ruff/Pyrefly/Pytest/PyYAML from `requirements-dev.txt` — the same file CI installs from |
| `npm run validate:all` | Full validation suite |
| `npm run validate:arch` | Dependency Cruiser architecture rules |
| `npm run validate:contracts` | Verify generated artifacts in sync |
| `npm run validate:domain-ownership` | Check the Domain Ownership Matrix against every module's `owns` declaration, both directions |
| `npm run validate:tool-parameter-reads` | Fail when a tool command declares a parameter its code never reads, on all three tools. A read is a property read reaching the handler, processor or pipeline stage — never a name in a string |
| `npm run test:integration` | FIRST for new features |
| `npm run test:coverage` | Baseline coverage (target: >80%) |
| `npm run skills:export` | Export skills from `skills-sync.yaml` |
## Domain Ownership Matrix (ENFORCED)
**Stages are thin orchestration. Domain logic lives in owner services.**
`validate:domain-ownership` checks this table both ways against each module's `module.yaml` `owns` block -- a row and its declaration must change together, and a row naming a symbol nobody exports, or a path the symbol is not defined in, fails.
| If you need... | Owner Service | Stage May Only |
| ----------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Gate normalization | GateService (`gates/services/`) | Call `gateService.normalize()` |
| Gate enhancement | GateEnhancementService | Call `enhancementService.enhance*()` |
| Gate selection | GateManager (`gates/gate-manager.ts`) | Call `gateManager.selectGates()` |
| Gate enforcement mode | `resolveEnforcementMode` (`execution/pipeline/decisions/gates/`) | Call `resolveEnforcementMode(mode)` -- a pure import, because `context.gateEnforcement` is optional and `?.` would silently relax enforcement |
| Gate verdict processing | GateVerdictProcessor (`gates/services/`) | Call `processor.handleGateAction()` |
| Inline gate parsing | InlineGateProcessor (`gates/services/`) | Call `processor.processInlineGates()` |
| Prompt resolution | PromptRegistry (`prompts/registry.ts`) | Call `registry.get()` |
| Command parsing | UnifiedCommandParser (`execution/parsers/command-parser.ts`) | Call `parser.parseCommand()` |
| Step capture | StepCaptureService (`execution/capture/`) | Call `captureService.captureStep()` |
| Response assembly | ResponseAssembler (`execution/formatting/`) | Call `assembler.format*()` |
| Framework selection | FrameworkManager (`frameworks/`) | Call `frameworkManager.selectFramework()` |
| Framework validity | FrameworkManager | Call `frameworkManager.getFramework(id)` -- never hardcode |
| Injection decisions | InjectionDecisionService (`execution/pipeline/decisions/injection/`) | Call `service.decide()` |
| Style resolution | StyleManager (`modules/formatting/style-manager.ts`) | Call `styleManager.getStyle()` |
| Delegation handoff evidence | `resolveHandoffEvidence` (`execution/delegation/handoff-contract.ts`) | Call `resolveHandoffEvidence(input)` -- a pure import, never re-parse the `HANDOFF RESULT` trailer inline |
| Detached report routing | `resolveDetachedReport` (`execution/delegation/detached.ts`) | Call `resolveDetachedReport(input)` -- a pure import; a late result lands on the node its trailer names, never on the current step |
## MCP Tool Layer Structure
**Thin handlers route to domain processors. CRUD logic lives in processors, not handlers.**
```
prompt_engine → PromptExecutor → PipelineBuilder → Pipeline (21 stages)
resource_manager → Router → Handler (≤125 lines) → Processors (lifecycle/discovery/versioning)
system_control → SystemControl Router → 12 action handlers
```
| Tool | Handler | Processors |
| ------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resource_manager` (prompt) | `PromptResourceHandler` | `PromptLifecycleProcessor`, `PromptDiscoveryProcessor`, `PromptVersioningProcessor` |
| `resource_manager` (gate) | `GateToolHandler` | `GateLifecycleProcessor`, `GateDiscoveryProcessor`, `GateVersioningProcessor` |
| `resource_manager` (framework) | `FrameworkToolHandler` | `FrameworkLifecycleProcessor`, `FrameworkDiscoveryProcessor`, `FrameworkVersioningProcessor`, `FrameworkValidator` |
| `resource_manager` (category) | `CategoryToolHandler` | `CategoryLifecycleProcessor`, `CategoryDiscoveryProcessor`, `CategoryVersioningProcessor`, `CategoryFileWriter`. Its resource is `category.yaml`, NOT the directory of prompts around it, which is why its mutation transaction targets the file and its `delete` leaves every prompt in place |
| `prompt_engine` | `PromptExecutor` | `PipelineBuilder` (factory), `ChainSessionRouter` |
| `system_control` | `ConsolidatedSystemControl` | 12 action handlers in `system-control/handlers/` (`analytics`, `changes`, `config`, `execution_history`, `framework`, `gates`, `guide`, `injection`, `maintenance`, `session`, `skills_sync`, `status`) -- `skills_sync` was absent from this list while present on disk, measured 2026-08-25 |
## Runtime State (SQLite -- never commit `state.db`)
| Table | Purpose |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kv_state` | Consolidated key-value store (`key='framework'` active framework + switch history, `key='gates'` enable/disable, `key='arg_history'` argument tracking, `key='resource_hashes'` content hash cache) |
| `chain_runs` | One row per live chain run (header facts: chain id, run owner, status, current node id, residual document). Primary SSOT for active runs -- replaces the retired `chain_run_registry` blob (schema v22) |
| `chain_run_nodes` | One row per step of a run (position, prompt id, step lifecycle, `origin`/`origin_unknown_id` provenance for nodes the adaptive mutation policy inserted, schema v23). Sibling to `chain_runs`; together they are what `chain_run_registry` used to serialize into one JSON blob |
| `chain_sessions` | Derived read-projection of `chain_runs` + `chain_run_nodes`, rebuilt in the same transaction, for Python hook + cross-language consumers |
| `execution_records` | SEP-1686 append-only per-step execution log (ULID-sorted); source for `v_execution_status` view |
| `resource_index` | Resource discovery cache |
State stores using `kv_state` pass `tableName: 'kv_state'` + a discriminator `key` to `SqliteStateStoreConfig`. **`state.db` is mixed-posture, not ephemeral.** A `SCHEMA_VERSION` bump drops and recreates, but `version_history` and `skills_sync_manifests` are durable: `ensureSchema()` snapshots their rows, recreates, and restores by column intersection. Adding a `NOT NULL` column with no default to either makes the restore throw by design -- that change needs a real migration. Per-table owner, posture, scope, and retention are declared in `src/infra/database/table-contracts.ts`, which is the SSOT.
**`state.db` is shared across projects, and TWO columns compete to isolate it.** One file serves every project. `workspace_id` has four readers, not zero as this handbook claimed on 2026-08-25 (re-measured 2026-08-27): `SqliteStateStore` filters on it in three queries, `run-registry` compares it to refuse a cross-workspace handoff claim -- a security decision -- `execution-record-store` projects it, and `v_execution_status` selects it. What is true is narrower and more awkward: for `version_history`, `execution_records` and `resource_changes` the column that actually isolates is `tenant_id` (9, 3 and 2 filter sites), so `workspace_id` there is a redundant second scope channel, indexed and written and never the thing that scopes. `tenant_id` meanwhile carries three incompatible meanings across the schema (server PID, workspace id, literal `'default'`). Choosing one scope column per table is open work, not a defect to patch. **Isolation tightened on 2026-08-27** and state written before then under the `'default'` key is not reachable from a workspace-scoped read, with two exceptions: active framework and gate enablement copy a legacy `'default'` row into the launch workspace once, when that workspace has no row of its own (`FrameworkStateStore`; `GateStateStore` since 2026-09-14). Argument history re-derives from config on first read, which costs a re-toggle. No bulk backfill was written on purpose -- it would have run against producers that were still truncating, which hides the defect rather than fixing it, and nothing stated what would ever retire it. Gate enablement could not stay a re-toggle: disabling gates is how a client stops `prompt_engine` advertising three parameters, and before 2026-09-14 every restart silently advertised them again -- the store loaded only the `'default'` row and created any other scope enabled without reading SQLite. It now loads every persisted `gates` row at startup, one per scope.
What actually isolates `version_history` is `tenant_id`, which `resolveContinuityScopeId` resolves to `workspaceId ?? organizationId ?? 'default'` -- so rollback history IS workspace-scoped, and every read filters on it. **This paragraph previously claimed the opposite** ("`kv_state` is the only table that writes it… rollback history is shared across every project on the machine"); both halves were false, and the error is kept visible here because a security claim that overstates exposure gets discounted wholesale once someone checks it. The residual limit is real but narrower: a process that resolves no workspace shares the `'default'` bucket with every other such process. For `kv_state`: a scope with no row falls back to `frameworks.defaultFramework`; the scope id derives from `CLAUDE_PROJECT_DIR` → cwd (basename) unless `--workspace-id` is passed. Reading or writing without a scope resolves to the process default set at startup -- passing one explicitly is required only when serving several workspaces from one process (HTTP). -> `docs/guides/identity-scope.md`
## Public API Contract (what a major version protects)
**Declared surface over "anything that feels significant"; consumer-observable over internal.**
Semver is defined relative to a declared API. Without one, every incidental change reads as
breaking and major versions inflate until they carry no information.
| In the contract -- break it, bump major | Not in the contract -- change freely |
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| MCP tool surface: `prompt_engine`, `resource_manager`, `system_control` names, parameters, and response shape | Internal TypeScript exports, including `src/index.ts` |
| CLI surface: `claude-prompts` and `cpm` commands and flags | `package.json` packaging fields (`types`, `exports`, `files`) |
| Resource formats: prompt/gate/framework YAML schema, `config.json` | `src/` layer structure, module layout, import style |
| Python hook contract consumed by downstream plugins -- **durable surface only**, see below | Which files land in the published tarball |
| | **PID-scoped derived projections**: `chain_sessions`, `chain_runs`, `chain_run_nodes` column names |
| Symbolic command language (`>>`, `==>`) | Build tooling, validation scripts, CI |
**The Python hook contract covers the durable surface, not every table a hook can open.**
`chain_sessions` is `derived`; `chain_runs` and `chain_run_nodes` are `ephemeral`. All three's rows are
`DELETE`d per-PID at cleanup, cleared when the owning process exits, and dropped outright by any
`SCHEMA_VERSION` bump. Nothing a hook reads there survives a restart -- they are a live-process
projection, closer to a cache than to an interface. Listing them as major-version-protected was a
mis-classification: it priced a rename of a column nobody can hold a durable reference to at the
same rate as breaking `prompt_engine`.
What IS protected on the hook side: the **module API** of `hooks/lib/*` that plugins import
(`load_active_chain_state`, `load_prompts`, and their return shapes), the `hooks-state.db` schema,
and the JSON payload contract hooks exchange with Claude Code. Those persist across restarts and
have no other source.
Renaming `chain_sessions.tenant_id` -> `run_owner_pid` was therefore **in-contract** — done at
schema v20, both sides in one commit, no dual-write. It is kept here as the worked example, provided the
reader lands in the same PR (verified 2026-08-05: zero readers of these columns exist across
`minipuft-plugins`, `gemini-prompts`, and `opencode-prompts`). A change here still requires the
Python side to move with it -- the constraint is atomicity, not a version bump.
**The tool surface is a union, not a snapshot.** `prompt_engine` builds its `inputSchema` from
runtime state: the three gate parameters (`gates`, `gate_verdict`, `gate_action`) are advertised
only while the gate system is enabled. The contract is the **union of every reachable shape** --
`tooling/contracts/prompt-engine.json`. Narrowing within that union is not breaking; adding or
removing a union member is. The alternative reading (contract = shape at current state) makes
every state change a major bump, which drains the major version of meaning. Since P4.93 a
narrowed-away gate parameter is REFUSED, not stripped, with its own "declared, but not advertised
right now" message -- "not a parameter" would be false of a name the contract carries.
**`gate_verdict` accepts two shapes, and one of them is retiring.** The structured object
(`{overall, rationale, per_gate[]}`) is schema-validated and cannot be malformed; the legacy
`"GATE_REVIEW: PASS - reason"` string is read back by five regexes and can fail to parse. Both are
in the union, so accepting the object was not breaking. **Retirement**: the string branch and the
four non-primary patterns in `resources/gates/config/verdict-patterns.yaml` are deleted once no
client has submitted a string verdict for one release cycle -- measurable via the `source` field
already on `ParsedGateVerdict`. That deletion IS breaking and needs a major bump.
**This package is a binary distribution** -- an MCP server, the `cpm` CLI, and Python hooks.
It publishes no library API: `src/index.ts` exports only `startServer`, `gracefulShutdown`,
`getApplicationHealth`, `getDetailedDiagnostics` (server lifecycle). Consumers run it; they do not
import it. Adding a library surface is a deliberate act -- restore `types`, `exports["."].types`,
`declaration: true` and `src` in `files` together, which `validate:package-entries` enforces.
-> `CONTRIBUTING.md` §Breaking Changes for how to mark one.
## Key Constraints
- **MCP Contract Dev**: Verify upstream first (`rg "paramName" src/mcp/tools src/modules src/engine`). Contract, schema, generated metadata, router, manager/types, and service must agree.
- **Client-work boundary**: Prompt and chain steps guide the client LLM, and resource operations may write only server-owned resource and runtime-state paths. **The server DOES execute shell commands, under one operator-held control.** `shell_verify` gate criteria and the inline `:: verify:"..."` operator both run their command through `sh -c` (`shared/utils/process.ts:381`), and script tools run an author-supplied file through a fixed interpreter. This is not incidental — ground-truth verification by exit code is the feature. What bounds it is three operator-held controls (below), not the absence of the capability: `MCP_SHELL_VERIFY_ALLOWLIST` over WHICH command, `MCP_SHELL_VERIFY_ALLOWED_DIRS` over WHERE, and a non-negotiable refusal of resource-supplied environment keys that decide what a command resolves to. This line previously claimed the opposite; it was false from the first `shell_verify` gate, and it is the sentence a reader consults when deciding how far to trust a third-party gate.
- **Instruction surface (accepted, documented)**: A prompt's `systemMessage`, `userMessageTemplate`, `description`, and argument descriptions ARE instruction to the client LLM. That is the product, not a defect — but it means installing a third-party prompt pack is equivalent to letting its author write into your model's context, and two surfaces deliver that text **before anyone invokes anything**: the MCP-standard `prompts/list` (which clients typically fetch at connect) carries every prompt's `description` and every argument description, and `resource_manager list detail:"full"` returns every prompt's `systemMessage` in a single call. Measured 2026-08-25. Treat a prompt pack the way you would treat a dependency, not a config file. The HTTP catalog route serving the same fields is credential-gated (`MCP_CATALOG_READ_TOKEN`); the MCP surface is not, by design, because the operator chose the client.
- **Guidance owner**: `PromptGuidanceService` remains the central framework-guidance service. Do not introduce a parallel coordinator/orchestrator with overlapping responsibility.
- **Framework validity**: Always `frameworkManager.getFramework(id)` -- never hardcode framework lists.
- **Consolidation over addition**: Enhance existing systems vs creating new ones.
- **Pipeline state**: Use `context.gates`, `context.frameworkAuthority`, `context.diagnostics` -- never mutate arrays directly.
- **Module organization**: import the defining module directly. **Banned is the compat re-export shim** -- a file whose whole body is `export ... from` AND which carries a back-compat marker, giving a symbol a second import path so `rg` for the canonical one misses consumers (the ESLint rule `claude/no-compat-reexport-shim` enforces exactly this). A markerless barrel is NOT banned and `src/` has ~60 of them; prefer direct imports anyway, because `validate:arch` expresses layer + cycle boundaries as **paths**, and a barrel spanning layers launders the real edge. Intra-layer barrels launder nothing -- judge by whether consumers cross a layer. A file that re-exports _and_ defines something is not a barrel (`infra/logging/index.ts`). Dead-barrel detection is `npx knip`; `validate:arch` cannot see it (`no-orphans` needs no incoming AND no outgoing edges, and a re-export always has outgoing). Use `internal/` for a genuinely private region.
- **Concurrent sessions**: work in a linked worktree, never a second session in this checkout — worktrees share `.git/config` but not HEAD, and a shared HEAD moves under whoever is mid-edit. Create with `npm run worktree:create -- <path> <branch> --from origin/main` (the path resolves from the repo root). **Do not use Claude Code's `--worktree`/`EnterWorktree` here**: anthropics/claude-code#72714 writes an absolutized `core.hooksPath` into the shared `.git/config`, disabling hooks in every worktree including this one — plain `git worktree add` is equally unsafe, for the reason `CONTRIBUTING.md` §Working in a second worktree records. A new worktree's `node_modules` and `server/node_modules` are symlinks pointing back at this checkout, so `npm ci` or an install from a branch that changed `package-lock.json` writes **through** them and mutates every worktree's dependency tree at once. Set a distinct `PORT` if two trees run servers.
- **Commit convention**: Conventional Commits enforced. Scopes (`commitlint.config.mjs` is the enforced list; keep this and `CONTRIBUTING.md` in lockstep with it): `server`, `cli`, `runtime`, `pipeline`, `gates`, `frameworks`, `prompts`, `chains`, `styles`, `scripts`, `hooks`, `resources`, `mcp-tools`, `contracts`, `parsers`, `ci`, `deps`, `config`, `logging`, `metrics`, `docs`, `tests`, `semantic`, `execution`.
- **Environment (execution capability)**: `MCP_SHELL_VERIFY_ALLOWLIST` declares which commands `shell_verify` may run. **Unset means every shell verification is refused** — the gate fails with a message naming this variable, and the server keeps serving. Entries are newline-separated (not comma: commands contain commas); an entry ending `*` is a prefix, but a command carrying shell control characters (`; & | \` < > $(`or a newline) must match exactly, because a prefix cannot bound what follows a`;`. The single entry `UNSAFE_ALLOW_ALL` permits everything, for an operator who authors all their own gates and accepts the risk. Both transports read it — this is not a listener concern: the sink is reached identically over STDIO and HTTP. The refusal is deliberately not a degrade-to-advisory; a gate reporting as passed while having verified nothing is a defect this repo has already fixed once.
- **Environment (execution capability, continued)**: `MCP_SHELL_VERIFY_ALLOWED_DIRS` declares directories, beyond the server's own, that a gate's `shell_working_dir` may resolve inside. **Unset does not mean "anywhere"** — it means the server's own directory, which is where a gate that says nothing already runs. Same newline-separated form and same `UNSAFE_ALLOW_ALL` sentinel as the command allowlist, and deliberately a _separate_ declaration: accepting arbitrary commands is not accepting arbitrary directories. There is no third variable for the environment, and that asymmetry is the point. A resource-supplied `shell_env` / `tool.env` naming a key that decides what a command RESOLVES to — `PATH`, `LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `BASH_ENV`, `IFS` and their siblings — is **refused outright, with no opt-out**, because otherwise the allowlist bounds a string whose meaning the author still picks: measured 2026-08-29, an operator allowlisting exactly `git status` ran the gate author's `git`. Ordinary variables pass through untouched. The full list and the mechanism behind each entry are in `shared/utils/process.ts`.
- **Environment (paths)**: `MCP_WORKSPACE` (primary — the default for resources AND runtime state), `MCP_RESOURCES_PATH` (resources base override), `MCP_RUNTIME_ROOT` (writable root for `runtime-state/` and relative `logs/`), `MCP_CONFIG_PATH` (config file override, same as `--config`). **Every path setting the server cannot use refuses startup** on both transports, before serving, naming the flag or variable, the value, its resolved path, what is wrong and what removing it falls back to: a config path that is not a readable JSON file, an `MCP_WORKSPACE`/`--workspace` or `MCP_RESOURCES_PATH` that is not an existing directory, and a workspace `config.json` that exists but is unreadable, a directory, or not a JSON object. Before 2026-09-14 each booted on something nobody asked for -- a named config file ignored, a missing workspace created by the logs `mkdir`, a missing resources path served as the bundled catalog, a malformed workspace config replaced by built-in defaults. One check (`PathResolver.assertUsablePathSettings`) runs in `createRuntimeFoundation` before the watcher, the transport choice and any `mkdir`; the path getters stay unchecked because tests and tooling resolve paths that need not exist. What still falls back, on purpose: a workspace with no `config.json` (packaged config), an empty value (unset), `MCP_RUNTIME_ROOT` (created on demand), and the packaged `config.json` itself (built-in defaults). `MCP_WORKSPACE` is not the SSOT for all paths: the other three each override it for their own concern, which is what lets a personal resource library live under `MCP_WORKSPACE` while `state.db` and logs stay put. Workspace resources overlay bundled ones — **the bundled tree stays loaded underneath**, and a same-id workspace entry wins. Before 2026-08-28 the workspace root REPLACED the bundle: one workspace framework was enough to make the server exit with `FATAL: Framework 'cageerf' not found`, and one workspace prompt made the served catalog that one prompt. Overlay detection compares the workspace to the package root, so `MCP_RESOURCES_PATH` alone does not enable overlays — a personal store must set `MCP_WORKSPACE`. **The Claude Code plugin sets both `MCP_WORKSPACE` and `MCP_RUNTIME_ROOT` to `${CLAUDE_PLUGIN_DATA}`** (`.mcp.json`, rendered from `mcp.json` by `scripts/render-distributions.mjs`): the directory Claude Code keeps across plugin updates and creates before the server starts, while the entry point stays under `${CLAUDE_PLUGIN_ROOT}`, which every update replaces. The canonical Agent Plugins `mcp.json` keeps `MCP_WORKSPACE=${PLUGIN_ROOT}`, because Codex does not pass `PLUGIN_DATA` to the MCP server. Hooks do not inherit the server's environment, so `get_state_db_path()` probes `${CLAUDE_PLUGIN_DATA}/runtime-state/state.db` after `MCP_RUNTIME_ROOT`. **A custom workspace is the write root**: without `MCP_RESOURCES_PATH`, a type resolves to `<workspace>/resources/<type>/` even before it exists (a legacy `<workspace>/<type>/` keeps it), the first write creates it, loaders read it as empty until then, and the file observer watches it once it appears.
- **Environment (secrets)**: `MCP_CATALOG_READ_TOKEN` gates executable prompt detail/history/compare; `MCP_CATALOG_WRITE_TOKEN` independently gates preview/apply/rollback and every legacy `/api/v1/tools/*` mutation. Either unset value refuses its surface (`503`), equal read/write values disable writes, and authentication runs before prompt lookup/body parsing. `MCP_HTTP_ALLOWED_ORIGINS` is a comma-separated allowlist for requests carrying `Origin`; absent `Origin` remains valid for server-side clients. HTTP transport only — STDIO reads none of these values.
-> `.claude/rules/mcp-contracts.md` for critical contract constraints (path-loaded)
-> `.claude/rules/sqlite-persistence.md` for critical persistence constraints (path-loaded)
-> `docs/guides/mcp-contract-maintenance.md` for the full contract procedure
-> `docs/architecture/sqlite-persistence.md` for the table map, scope split, and schema history
-> `docs/architecture/overview.md` for architecture, pipeline stages, subsystems
-> `docs/reference/mcp-tools.md` for MCP tool workflows, symbolic command language
-> `docs/guides/injection-control.md` for injection types, frequency, hierarchy
-> `docs/guides/gates.md` for gate/framework structure and hot-reload
-> `/testing` skill for test patterns and project-specific coverage
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

