A Claude Code plugin that gives a session a module's decision context before it edits the module, and records new decisions so the next session inherits them.
- Ledger: one markdown file per decision under
decisions/, append-only, with YAML frontmatter (decisions/D-NNNN.md). - Wiki: one page per module under
docs/wiki/, rendered from the ledger plus a curated preamble that Claude maintains. - Hooks: SessionStart injects the consult contract; the first Edit/Write per module per session is denied with the module page in the reason and allowed on retry; Stop blocks once when a gated module changed with no decision recorded.
Runtime: Node 24 running TypeScript natively (erasable syntax only, no build step), zero
runtime dependencies, node --test.
User level is the default: install once and every repository gets the hooks. They are inert in
a repository with no .claude/decision-wiki.json (SessionStart emits nothing, PreToolUse
allows), so a global install costs nothing until a repo opts in with init.
This repository is its own marketplace (.claude-plugin/marketplace.json lists the plugin),
so installing takes two commands. From GitHub:
claude plugin marketplace add Kastalien-Research/decision-wiki
claude plugin install decision-wiki@decision-wiki # --scope user is the defaultFrom a local clone, give the path instead of the GitHub slug:
claude plugin marketplace add /path/to/decision-wiki
claude plugin install decision-wiki@decision-wikiRepository level is optional, for a team that wants the plugin pinned to one project:
claude plugin install decision-wiki@decision-wiki --scope projectFor development, load a checkout directly without installing:
claude --plugin-dir /path/to/decision-wikiInstalling copies the plugin into ~/.claude/plugins/cache/, so after the repository changes
run claude plugin update decision-wiki to refresh the installed copy.
Then in the target repository run /decision-wiki:init (or decision-wiki init).
bin/decision-wiki is a shell shim for node src/cli.ts. Exit codes: 0 ok, 1 command
failure, 2 usage error.
Global option -C <dir> (before the command) runs as if started in <dir>. The skills always
pass -C "${CLAUDE_PROJECT_DIR}" so a record lands in the project even when the shell's working
directory is elsewhere; in one headless session a decide call ran with its cwd in a different
repository and appended to that repository's ledger.
| Command | What it does |
|---|---|
init |
Writes .claude/decision-wiki.json with one placeholder module, docs/wiki/index.md, docs/wiki/SCHEMA.md, docs/wiki/placeholder.md, an empty decisions/, and a .gitignore entry for the state directory. Refuses to overwrite an existing config. |
decide --statement S --rationale R [--alternatives A] [--evidence E] [--reversal V] [--module m]... [--supersedes D-NNNN] [--provisional] [--session id] |
Appends a record with the next id, stamps files from session state when --session is given, updates decisions/.index.json, runs generate, prints the id. Text flags accept @path to read from a file. With --session and no --module, modules are inferred from the session's touched files; nothing inferable is a usage error (exit 2). Without --session, --module is required. Exit 1 when --supersedes names an unknown or already superseded id, or when a module is not in config. |
generate |
Renders module pages and index.md, rewriting only generated blocks and frontmatter hashes. Idempotent: a second run writes nothing and prints nothing. |
lint [--strict] |
Checks ledger and wiki. Exit 1 on any error; under --strict, warnings are errors too (except the advisory rule below). |
check <base> <head> |
git diff --name-only base..head; exit 1 when a gated module changed and no decisions/D-NNNN.md added in the range (read at head) has that module or * in its modules. Per module: a record mapped to an unrelated module does not cover the change. |
hook <session-start|pre-tool-use|stop> |
Runs a hook handler over Claude Code hook JSON on stdin. Always exits 0. |
{
"version": 1,
"paths": { "decisionsDir": "decisions", "wikiDir": "docs/wiki", "stateDir": ".claude/state/decision-wiki" },
"budgets": { "injectionBytes": 8192, "pageWholesaleBytes": 4096 },
"modules": {
"core": { "globs": ["src/**"], "why": "…", "gate": true }
}
}paths and budgets default to the values above. Module names match
^[a-z0-9]+(?:-[a-z0-9]+)*$; globs are repo-relative and matched with node:path
matchesGlob. Files under wikiDir, decisionsDir, and stateDir never match a module.
Record format and page anatomy are documented in docs/wiki/SCHEMA.md (written by init).
Declared in hooks/hooks.json in exec form, with a 10 second timeout each:
{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/src/cli.ts", "hook", "pre-tool-use"], "timeout": 10 }Every internal error in a hook (missing or broken config, unparsable ledger, bad stdin) is converted to allow: one line on stderr, no stdout, exit 0. A hook can never block a session because of its own failure.
State lives in <stateDir>/<session_id>.json:
{
"startedAt": "2026-09-07T22:00:00.000Z",
"knownIds": ["D-0001", "D-0002"],
"surfaced": ["core", "a8256c37/core"],
"touched": ["src/cli.ts"],
"stopBlockedFor": []
}-
surfacedis per context, not per session. Subagents share the parent'ssession_idbut have their own context, so a subagent's entries are keyed<agent_id>/<module>and the parent's are the bare module name. Each context gets the page once;touched,knownIds, andstopBlockedForstay shared, so the parent's Stop gates a subagent's edits. -
A rebuilt context forgets its pages: SessionStart with kind
compactorclearemptiessurfaced(keeping everything else), so the next edit in each module injects again. -
Parallel first-touch edits in one turn run their hooks concurrently. The claim on
surfacedhappens under the lock, so exactly one hook injects the page; the others are allowed and recorded as touched. The hook cannot tell a concurrent sibling from the model's retry, and the page reaches the model in the same turn either way. -
knownIdsis the set of ledger ids that existed when the state file was created. A record is new this session iff its id is not inknownIds, or its frontmattersessionequals the session id. No file time or wall clock is consulted anywhere in Stop orcheck: mtime is refreshed by checkout, rebase, and stash, and one candidate implementation wrote second-precision timestamps, so a record created in the same second as the session start was invisible.startedAtis informational only. -
stopBlockedForis the set of gated modules Stop has already blocked for. It replaces the boolean one-shot so the block is per module set, not per session (below). -
The file is created by SessionStart, or lazily by the first PreToolUse or Stop that needs it (snapshot taken at that moment). Hooks whose input has no
session_idare stateless: they allow with no output and write nothing. -
Writes are serialised with a lock directory next to the file, merged with what is on disk (
surfaced,touched,stopBlockedForunioned;knownIdsfrom disk wins), written to a temp file in the state directory, and renamed into place. Concurrent hook processes for the same session cannot lose each other's updates.
SessionStart (startup|resume|clear|compact|fork)
no config -> exit 0, no output
config -> create state if absent (knownIds = ledger ids now)
compact|clear with existing state -> surfaced = [] (context was rebuilt)
prune state files older than 7 days
emit additionalContext: consult contract + module table
no session_id -> emit the context, write nothing
PreToolUse (Edit|Write|MultiEdit|NotebookEdit), path = tool_input.file_path | notebook_path
under wiki/ledger/state dir -> allow, no output
matches no module -> allow, no output
every matched module surfaced -> allow, no output; append path to touched
(surfaced is checked per context: bare module for the parent, agent_id/module for a subagent)
any module unsurfaced -> claim it under the lock
claimed something -> deny; reason = "Imported by" line (files in mapped modules whose
relative imports resolve to the touched file, with [[module]] links),
then each page without frontmatter: curated preamble first, then
decisions, edges, global (global replaced by a pointer when the page
exceeds pageWholesaleBytes); truncated to injectionBytes,
ending "Retry the edit now; it will be allowed."
claimed nothing (a parallel hook won) -> allow, no output; append path to touched
no session_id -> allow, no output, no state written
internal error -> allow, stderr line, exit 0
Stop
no session_id -> allow
new = records with id not in knownIds, or session == this session
uncovered = gated modules with a touched file and no new record whose
modules include that module or "*"
uncovered = ∅ -> allow
uncovered ⊆ stopBlockedFor -> allow (already blocked for these)
otherwise -> block; reason lists each uncovered module with
its touched files and the exact
`/decision-wiki:decide --module <m>` call;
stopBlockedFor ∪= uncovered
Coverage is per module. A decision mapped to cli does not cover an edit in core; a
decision mapped to * covers everything. The one-shot is per module set: after a block for
core, touching a gated hooks module blocks once more (for hooks), and recording a
decision mapped to the uncovered modules clears the block. Ungated modules never block Stop.
A file matching several modules surfaces every unsurfaced one in a single denial.
Verified against the hooks reference at https://code.claude.com/docs/en/hooks (fetched 2026-09-07, Claude Code 2.1.263 installed):
| Event | Shape implemented | Receipt |
|---|---|---|
| PreToolUse deny | {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…"}} |
"PreToolUse decision control": top-level decision/reason are deprecated for this event; hookSpecificOutput.permissionDecision is current. Reason is shown to Claude for deny. |
| SessionStart context | {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "…"}} |
"SessionStart decision control": context-only event; additionalContext inside hookSpecificOutput. Matcher values startup, resume, clear, compact, fork. We register startup|resume|clear|fork per the spec. |
| Stop block | {"decision": "block", "reason": "…"} |
"Stop decision control" and the "Decision control" table: Stop uses top-level decision: "block" with a required reason; hookSpecificOutput on Stop only carries additionalContext (non-blocking feedback). This is the current form, not legacy. The spec's stated shape was correct. |
| Command form | "command": "node", "args": [...] |
"Exec form and shell form": exec form is command (executable, a string) plus args (array); "The command field itself is never an array". The brief's array-valued command is not a documented shape, so the documented one is used. timeout is in seconds. |
| Exit codes | always 0 | "Exit code output": exit 0 with JSON stdout is parsed; stderr on exit 0 goes to the debug log only, which is what the never-block guarantee needs. |
Common input fields used: session_id, cwd, tool_input.file_path / notebook_path,
stop_hook_active (read but not required; the one-shot is stopBlockedFor in state).
All user-invocable, namespaced /decision-wiki:<name>:
init: runinit, inspect the repo layout, propose real module globs, write on confirmation.decide: collect the record fields, search the ledger for an existing record first, rundecide --session ${CLAUDE_SESSION_ID}, show the regenerated page.curate <module>: rewrite the preamble as a story with[[module]]links and[D-NNNN]citations, runlint --strict, fix what it flags.
Errors:
| Rule | Trips when |
|---|---|
block-hash |
a generated block's content hash differs from generated_hashes in the page frontmatter |
block-missing |
a page lacks one of its generated blocks |
link-unknown |
[[module]] names a module not in config |
cite-unknown |
[D-NNNN] names an id not in the ledger |
ledger-index |
a record's content hash differs from, or is missing in, decisions/.index.json |
supersedes-unknown |
a record supersedes an id that does not exist |
page-orphan |
a page exists for a module not in config |
page-missing |
a module in config has no page |
marker-in-preamble |
the preamble contains <!-- BEGIN GENERATED: or <!-- END GENERATED: |
ledger-parse |
a record cannot be parsed (bad id, missing Statement/Rationale, unknown section, * mixed with names) |
Warnings, errors under --strict:
| Rule | Trips when |
|---|---|
unmapped-decision |
a record's modules contain no configured module and not * |
gated-empty |
a gated module has zero decisions |
Advisory (warning even under --strict):
| Rule | Trips when |
|---|---|
uncited-assertion |
a preamble line starts with "We decided", "Always", "Never", or "Must" and carries no [D-NNNN] |
decision-wiki check origin/main HEADFails when a gated module changed in the range and no decisions/D-NNNN.md added in the
range (read from head, so the check is about the range, not the worktree) maps to that
module or to *. The output lists each uncovered module with its changed files and the
/decision-wiki:decide --module <m> call that would cover it. The same core function backs
the Stop hook, with "new this session" (see knownIds) replacing the git range.
npm test # node --test test/*.test.ts
npm run test:live # scripts/live-demo.sh: a real headless session; costs a model callUnit tests cover ledger parse/write round-trip and id allocation, glob resolution including
self-exclusion, page rendering with hash stability, idempotent generate with a preserved
preamble, every lint rule with a fixture that trips it, and check over synthetic and real
git diffs. Hook contract tests spawn src/cli.ts hook <event> in a temp repo with real
stdin JSON and assert exit code and parsed stdout for: first-touch deny, retry allow,
multi-module surfacing, unmapped allow, wiki/ledger/state path allow, broken-config allow,
malformed stdin allow, Stop block-then-pass, Stop pass with a new record, Stop never blocking
ungated modules, SessionStart with and without config, and state pruning. The hardening
items each have a named test:
| Item | Test |
|---|---|
No file-time comparison; knownIds + session decide "new" |
hooks.test.ts "Stop counts a record as new by id absent from knownIds, ignoring file times (H1)" and "...even if its id was in the snapshot (H1)" |
| SessionStart writes state; PreToolUse and Stop create it lazily | hooks.test.ts "(H2)" cases |
| Per-module coverage, per-module-set one-shot, reason lists files and the decide call | check.test.ts and hooks.test.ts "Stop per-module coverage (H3)" |
| Atomic merge-on-write state | hooks.test.ts "six concurrent PreToolUse hooks for distinct files all land in touched (H4)" |
generate preserves prose and foreign frontmatter outside the blocks; missing block appended |
render.test.ts "(H5)" cases |
decide rejects a module not in config (exit 1) |
hooks.test.ts "usage errors exit 2, command failures exit 1" |
decide --session infers modules; nothing inferable is exit 2 |
hooks.test.ts "(H7)" |
Hooks without session_id are stateless |
hooks.test.ts "(H8)" |
| Every lint message names its remedy | lint.test.ts "(H9)" cases |
| Millisecond ISO timestamps | hooks.test.ts "(H10)" and the SessionStart case |
scripts/live-demo.sh (npm run test:live) creates a temp git repo with src/a.ts, runs
init, gates core -> src/**, runs
claude -p --model haiku --plugin-dir <this repo> --permission-mode acceptEdits "Edit src/a.ts to change the constant to 2, then finish."
with the temp repo as cwd, and asserts from the session state file that core was surfaced,
src/a.ts was touched, and Stop blocked for core. It prints PASS or FAIL and exits
accordingly. DW_MODEL, DW_KEEP=1, and DW_TMP adjust the model, keep the temp repo, and
place it. It is deliberately not part of npm test.
This repository dogfoods the plugin: core (src/**, gated) and plugin (manifest, hooks,
skills, shim) are mapped in .claude/decision-wiki.json, and decisions/ holds the decisions
made while building it.
docs/experiments/2026-09-08-value-ab.md: three scenarios where a recorded decision explains a
curious piece of code and a task tempts reversing it. With the plugin the model avoided the
violation in 6 of 6 haiku runs and 2 of 2 sonnet runs, citing the decision; with no context
it violated 4 of 6; with the wiki merely present on disk it violated 6 of 6; with a CLAUDE.md
telling it to read the wiki, 4 of 6. A two-session run shows a reason recorded in one session
reaching the next and shaping its reversal record. Runners under scripts/experiments/.
scripts/probes.sh subagent|bigreason|quickstart|all runs real claude -p sessions against
the installed plugin (no --plugin-dir) in temp repos and prints what the hooks and state
show: whether a subagent's edit gets its own page and is gated by the parent's Stop, whether
an injectionBytes-sized deny reason reaches the model intact, and what /decision-wiki:init
proposes for a fresh layout. Each costs a few cents on haiku; meant for a tmux pane, not CI.
This repository dogfoods the plugin: src/** is a gated module, the ledger in decisions/ is
the truth, and docs/wiki/ is its projection. With the plugin installed, a session here gets
the module table at start and the core page on its first edit under src/. Record decisions
with /decision-wiki:decide before stopping; npm test, node src/cli.ts lint --strict, and
node grading/grade.ts . must all pass.
- Dotfiles are not matched by module globs:
src/**does not coversrc/.env.ts, becausenode:pathmatchesGlobexcludes dot segments by default and the plugin has no glob engine of its own. Name such files explicitly in a module's globs if they need to be governed.
Where the spec was silent, these choices were made:
- Repo root discovery: the config is found by walking up from the hook's
cwd(or the CLI's working directory) to the first directory containing.claude/decision-wiki.json. - Content hashes are the first 16 hex characters of SHA-256, for both the ledger index and the generated blocks. A block's hash covers the text between its marker lines.
generateand the index:generateadds index entries for records that have none and never overwrites an existing hash, so a hand-edited record keeps failing lint until the index is corrected deliberately ([D-0002]).generated_atchanges only when a block's hash changes; otherwise the existing value is kept, which is what makes a secondgeneratea no-op.deciderequires--moduleunless--sessionis given, in which case modules are inferred from the session's touched files;"*"is accepted alone; names not in config are a command failure. The lint warningunmapped-decisiontherefore only arises when a module is later removed from config.- "New this session" is decided by
knownIdsand the record'ssessionfield only. The spec's "created afterstartedAt" was implemented with file times in the candidates and dropped here (see State machine for why). - Hooks without a
session_idare stateless: allow, no output, no state file. There is no sharedunknown.json. - Stop blocking is a one-shot per module set, not per session:
stopBlockedForgrows as new gated modules are touched after a block. - The state lock is a directory (
<session>.json.lock) taken withmkdir; a lock older than ten seconds is treated as stale. If the lock cannot be taken within two seconds the write proceeds anyway, because a hook must never hang a session. checkreads added records fromheadwithgit show, so it judges the range even when the worktree has moved on; an unparsable added record fails the command (exit 1).- Budgets must be integers in 256..65536 and
pageWholesaleBytes <= injectionBytes. - Index page (
docs/wiki/index.md) has a single generated block namedindexand its owngenerated_hashes; it andSCHEMA.mdare not treated as module pages by lint. - Text outside generated blocks (between or after blocks) and frontmatter keys the
plugin does not own are preserved byte for byte by
generate; only the block bodies,generated_hashes,generated_at, and themodule/globs/gatekeys are rewritten. A block missing from a page is appended at the end. Such prose is not a lint finding. - Two extra core files beyond the spec's list:
src/core/frontmatter.ts(the YAML-subset reader/writer shared by ledger and pages) andsrc/core/init.ts(inithas noRepoyet, so it does not fit the other modules'Repo-in, data-out shape). .gitignore:initskips the entry when an existing line already covers the state directory (this repo's.claude/state/does).- SessionStart matcher is
startup|resume|clear|forkas specified;compactis left out because the consult contract is already in the transcript.
- Contradiction detection (agent hook). The one genuinely agent-shaped job: at Stop, an
agent reads the session's touched files and the governing decisions for their modules and
flags a change that contradicts a decision, asking for a revert or a superseding record.
Semantic, not derivable from globs. Deferred because it costs a model call per session end,
is not contract-testable, and the agent-hook return contract was not verified. Ship as an
opt-in
userConfigflag over the samecheckcore. - MCP tool surface (
decide,lookup,curate) over the same core. initauto-suggesting modules from repo layout beyond what the skill does interactively.