Skip to content

feat: add doc-sentinel plugin - #36

Merged
dgalarza merged 2 commits into
mainfrom
doc-sentinel
Apr 15, 2026
Merged

feat: add doc-sentinel plugin#36
dgalarza merged 2 commits into
mainfrom
doc-sentinel

Conversation

@emma-praxis

@emma-praxis emma-praxis commented Apr 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the doc-sentinel plugin — a proactive documentation drift detector — and consolidates the fix work required to make its Stop hook reliably auto-dispatch the drift-resolver subagent. Plugin version is 1.0.5.

The first commit adds the plugin. The second commit collapses a multi-stage debugging session into a single fix covering bugs #1-#9 below.

Bugs fixed in 1.0.1 → 1.0.5

  1. systemMessage placement — Stop hook was emitting systemMessage nested under hookSpecificOutput; Stop hooks require it at the top level.
  2. systemMessage is informational only — even at the top level, systemMessage renders as a UI status line and is not injected into the model's next-turn context. Empirically confirmed: the user had to paste the directive manually for Claude to act on it.
  3. decision: "block" JSON is unreliable — 1.0.4 emitted {"decision":"block","reason":"..."}; the payload parses fine but the installed Claude Code version did not actually block the Stop. Per the docs both mechanisms should work; only the older one does in practice.
  4. Exit code 2 + stderr is the universal blocking contractemit_block now does printf '%s\n' "$reason" >&2; exit 2. This is the same mechanism PreToolUse hooks use. The session-marker write happens before emit_block since exit 2 short-circuits.
  5. Session marker safety valve.doc-sentinel-last-session records the session_id from the hook stdin envelope and blocks at most once per session, so a drift-resolver run that can't fully clear the drift file can't trap Claude in a loop.
  6. Timeout unitshooks.json timeout is seconds, not milliseconds. Corrected 500010 on both PostToolUse and Stop hooks.
  7. CWD handling — hooks may run from any subdirectory of the project. Both scripts now resolve $CLAUDE_PROJECT_DIR with a git rev-parse --show-toplevel fallback and prefix DRIFT_FILE / CONFIG_FILE / DOC_ROOT with $PROJECT_ROOT.
  8. BSD sed delimiter bugpost-commit-drift.sh glob→regex conversion used / as the sed delimiter with a / in the replacement, which fails on macOS sed. Switched to #.
  9. Directive wording — Stop hook reason now includes a numbered 5-step plan telling Claude exactly how to invoke the drift-resolver and to commit its changes with a docs: prefix. The post-commit hook already skips ^docs(\(.+\))?: commits, which closes the feedback loop.

Why exit-2 (empirical evidence)

Per the Claude Code docs, Stop hooks expose two blocking mechanisms: JSON {"decision":"block","reason":"..."} on stdout, or exit code 2 with the reason on stderr. In the version of Claude Code currently installed, only the exit-2 mechanism actually prevents the model from stopping — the JSON payload is parsed without error but the Stop proceeds anyway. Exit-2 is the older universal contract and matches how PreToolUse hooks block tool calls, so the Stop hook now uses it exclusively.

Test plan

  • Empty project (no drift file): echo '{"session_id":"x"}' | CLAUDE_PROJECT_DIR=/tmp/fake bash plugins/doc-sentinel/hooks/stop-drift-report.sh → exit 0, silent.
  • Populated drift file: exit 2, full numbered directive on stderr.
  • Second Stop-hook call with same session_id: exit 0 (marker honored, no re-block).
  • End-to-end in a real repo: edit a source file referenced by a doc, commit with a non-docs: prefix, let the Stop hook fire, verify Claude dispatches the drift-resolver subagent autonomously.

Plugin version

1.0.5 in both plugins/doc-sentinel/.claude-plugin/plugin.json and .claude-plugin/marketplace.json. No release tag — that's a manual step after merge.

dgalarza added 2 commits April 7, 2026 11:11
New plugin that scans docs for drift against the codebase, runs automatically
via post-commit and stop hooks, and resolves findings through a drift-resolver
agent.
…ut fixes

Consolidated fixes making the Stop hook reliably auto-dispatch the
drift-resolver subagent, plus portability/correctness fixes discovered
during debugging. Bumps plugin to 1.0.5.

Bugs fixed:

1. systemMessage placement — Stop hook was emitting systemMessage nested
   under hookSpecificOutput; Stop hooks need it at the top level.
2. systemMessage is informational — even at the top level, systemMessage
   is shown as a UI status line and is NOT auto-injected into the model's
   next-turn context, so the dispatch directive never reached Claude.
3. decision:"block" JSON unreliable — the JSON payload parses fine but
   the installed Claude Code version did not actually block on it.
4. exit 2 + stderr is the universal blocking contract — emit_block now
   does `printf '%s\n' "$reason" >&2; exit 2`. Session marker write moved
   before emit_block since exit 2 short-circuits.
5. Session marker safety valve — .doc-sentinel-last-session records the
   session_id from the hook stdin envelope and blocks at most once per
   session, preventing infinite loops if the drift-resolver can't fully
   clear the drift file.
6. timeout units — hooks.json `timeout` is seconds, not ms; corrected
   5000 → 10 on both PostToolUse and Stop hooks.
7. CWD handling — hooks may run from any subdirectory. Both scripts now
   resolve $CLAUDE_PROJECT_DIR with a `git rev-parse --show-toplevel`
   fallback and prefix DRIFT_FILE / CONFIG_FILE / DOC_ROOT with
   $PROJECT_ROOT.
8. BSD sed delimiter bug — post-commit-drift.sh glob→regex conversion
   used `/` as sed delimiter with `/` in the replacement, which fails on
   macOS sed. Switched to `#` delimiter.
9. Directive wording — Stop hook reason now includes a numbered 5-step
   plan telling Claude exactly how to invoke the drift-resolver and to
   commit with a `docs:` prefix (the post-commit hook already skips
   `^docs(\(.+\))?:` commits, closing the feedback loop).

Test plan:
- Empty project: `echo '{"session_id":"x"}' | CLAUDE_PROJECT_DIR=/tmp/fake \
  bash stop-drift-report.sh` → exit 0, silent.
- Populated drift file: exit 2, full directive on stderr.
- Second call same session_id: exit 0 (marker honored).
@dgalarza
dgalarza merged commit e4b8a68 into main Apr 15, 2026
1 of 2 checks passed
@dgalarza
dgalarza deleted the doc-sentinel branch April 15, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants