Skip to content

About

Claude Code plugin with the Kastalien decision context system.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

58 Commits

Folders and files

Repository files navigation

decision-wiki

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.

Install

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 default

From 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-wiki

Repository level is optional, for a team that wants the plugin pinned to one project:

claude plugin install decision-wiki@decision-wiki --scope project

For development, load a checkout directly without installing:

claude --plugin-dir /path/to/decision-wiki

Installing 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).

CLI

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.

Config: .claude/decision-wiki.json

{
  "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).

Hooks

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 machine

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": []
}
  • surfaced is per context, not per session. Subagents share the parent's session_id but 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, and stopBlockedFor stay shared, so the parent's Stop gates a subagent's edits.

  • A rebuilt context forgets its pages: SessionStart with kind compact or clear empties surfaced (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 surfaced happens 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.

  • knownIds is the set of ledger ids that existed when the state file was created. A record is new this session iff its id is not in knownIds, or its frontmatter session equals the session id. No file time or wall clock is consulted anywhere in Stop or check: 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. startedAt is informational only.

  • stopBlockedFor is 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_id are 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, stopBlockedFor unioned; knownIds from 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.

Hook contract receipts

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).

Skills

All user-invocable, namespaced /decision-wiki:<name>:

  • init: run init, inspect the repo layout, propose real module globs, write on confirmation.
  • decide: collect the record fields, search the ledger for an existing record first, run decide --session ${CLAUDE_SESSION_ID}, show the regenerated page.
  • curate <module>: rewrite the preamble as a story with [[module]] links and [D-NNNN] citations, run lint --strict, fix what it flags.

Lint rules

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]

CI check

decision-wiki check origin/main HEAD

Fails 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.

Testing

npm test          # node --test test/*.test.ts
npm run test:live # scripts/live-demo.sh: a real headless session; costs a model call

Unit 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.

Evidence that it changes behavior

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/.

Headless probes

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.

Contributing

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.

Known gaps

  • Dotfiles are not matched by module globs: src/** does not cover src/.env.ts, because node:path matchesGlob excludes 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.

Assumptions

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.
  • generate and the index: generate adds 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_at changes only when a block's hash changes; otherwise the existing value is kept, which is what makes a second generate a no-op.
  • decide requires --module unless --session is 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 warning unmapped-decision therefore only arises when a module is later removed from config.
  • "New this session" is decided by knownIds and the record's session field only. The spec's "created after startedAt" was implemented with file times in the candidates and dropped here (see State machine for why).
  • Hooks without a session_id are stateless: allow, no output, no state file. There is no shared unknown.json.
  • Stop blocking is a one-shot per module set, not per session: stopBlockedFor grows as new gated modules are touched after a block.
  • The state lock is a directory (<session>.json.lock) taken with mkdir; 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.
  • check reads added records from head with git 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 named index and its own generated_hashes; it and SCHEMA.md are 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 the module/globs/gate keys 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) and src/core/init.ts (init has no Repo yet, so it does not fit the other modules' Repo-in, data-out shape).
  • .gitignore: init skips the entry when an existing line already covers the state directory (this repo's .claude/state/ does).
  • SessionStart matcher is startup|resume|clear|fork as specified; compact is left out because the consult contract is already in the transcript.

Future work

  • 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 userConfig flag over the same check core.
  • MCP tool surface (decide, lookup, curate) over the same core.
  • init auto-suggesting modules from repo layout beyond what the skill does interactively.

About

Claude Code plugin with the Kastalien decision context system.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages