The vsdd-factory plugin ships hook scripts wired through hooks-registry.toml (the dispatcher's canonical routing table). hooks.json is a per-platform generated artifact produced by the activate skill — operators do not edit it directly. Hooks fire automatically on tool use events, subagent completion, and session end. They enforce pipeline discipline without requiring manual intervention.
The Instrumented column indicates whether the hook emits structured block events to .factory/logs/events-YYYY-MM-DD.jsonl. Native WASM plugins use the host::emit_event SDK call; bash hooks invoke bin/emit-event. Migration of bash hooks to native is tracked in epic E-8 (S-8.01..S-8.09 merged) and the ADR-015 wave 4 series (S-10.08, S-10.09). See the observability guide for the event schema, reason-code registry, and query recipes.
Note: This reference table currently undercounts. A doc audit reconciling every hook script in
plugins/vsdd-factory/hooks/with this page is tracked in the observability roadmap (end-of-Phase-2 cleanup release).
| Hook | Event | Matcher | What It Enforces | Blocking | Instrumented |
|---|---|---|---|---|---|
brownfield-discipline.sh |
PreToolUse | Edit|Write | .reference/ directories are read-only |
Yes | ✓ (1 code) |
protect-vp.sh |
PreToolUse | Edit|Write | Green Verification Properties are immutable | Yes | ✓ (1 code) |
protect-bc.sh |
PreToolUse | Edit|Write | Green Behavioral Contracts are immutable | Yes | ✓ (1 code) |
red-gate.sh |
PreToolUse | Edit|Write | TDD red-before-green discipline | Yes (when strict mode active) | ✓ (1 code) |
factory-branch-guard.sh |
PreToolUse | Edit|Write | .factory/ writes only allowed on factory-artifacts worktree |
Yes | ✓ (2 codes) |
destructive-command-guard.sh |
PreToolUse | Bash | Blocks catastrophic rm targets, rm -rf on protected paths, SoT clobbering redirects, find -delete, dangerous git/gh operations, --no-verify, curl|bash |
Yes | ✓ (27 codes) |
protect-secrets.sh |
PreToolUse | Bash + Read | Blocks reads/copies of .env files, echoing secret-shaped env vars, env | grep for secrets |
Yes | ✓ (6 codes) |
verify-git-push.sh |
PreToolUse | Bash | Blocks force push (--force/-f) and direct push to protected branches (main, master, develop) |
Yes | ✓ (2 codes) |
block-ai-attribution.sh |
PreToolUse | Bash | Blocks git commits containing Co-Authored-By: Claude/AI attribution patterns |
Yes | ✓ (2 codes) |
check-factory-commit.sh |
PreToolUse | Bash | Remind about STATE.md after .factory/ commits |
No (advisory) | — |
validate-wave-gate-prerequisite.sh |
PreToolUse | Agent | Blocks Wave N+1 worker dispatch if Wave N gate is pending | Yes | ✓ (1 code) |
validate-pr-merge-prerequisites.sh |
PreToolUse | Agent | Blocks github-ops merge dispatch if pr-description/pr-review/security-review evidence is missing | Yes | ✓ (1 code) |
purity-check.sh |
PostToolUse | Edit|Write | Pure-core boundary -- no side effects in pure modules | No (warn-only) | ✓ (1 code, severity=warn) |
validate-vp-consistency.sh |
PostToolUse | Edit|Write | VP-INDEX ↔ verification-architecture ↔ coverage-matrix consistency (Policy 9) | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-subsystem-names.sh |
PostToolUse | Edit|Write | BC/story subsystem fields match ARCH-INDEX canonical names (Policy 6) | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-bc-title.sh |
PostToolUse | Edit|Write | BC file H1 heading matches BC-INDEX title (Policy 7) | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-story-bc-sync.sh |
PostToolUse | Edit|Write | Story frontmatter bcs: ↔ body BC table ↔ AC traces sync (Policy 8) | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-template-compliance.sh |
PostToolUse | Edit|Write | Artifact has required frontmatter fields and section headings from its template | Yes (exit 2 on missing) | ✓ (1 code) |
validate-finding-format.sh |
PostToolUse | Edit|Write | Only current finding/fix ID formats accepted (blocks legacy ADV-NNN, STORY-NNN-FIX) | Yes (exit 2 on legacy format) | ✓ (1 code) |
validate-input-hash.sh |
PostToolUse | Edit|Write | Warns on missing/stale input-hash; blocks non-7-char or non-lowercase-hex format |
Partial (format=block, drift=advisory) | ✓ (1 code, format-block only) |
validate-state-size.sh |
PostToolUse | Edit|Write | STATE.md line count enforcement — warns >200, blocks >500 (allows compaction writes) | Yes (>500 and growing) | ✓ (1 code) |
validate-novelty-assessment.sh |
PostToolUse | Edit|Write | Adversarial review files must have Novelty Assessment section with required fields | Yes (exit 2 on missing) | ✓ (1 code) |
convergence-tracker.sh |
PostToolUse | Edit|Write | Convergence rule enforcement — trajectory monotonicity, min 3 clean passes, novelty ≤ 0.15, zero-findings warning | Partial (premature CONVERGENCE=block, regression=warn) | ✓ (1 code, block only) |
validate-table-cell-count.sh |
PostToolUse | Edit|Write | Markdown table rows must have same pipe count as header (catches unescaped pipes in cells) | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-changelog-monotonicity.sh |
PostToolUse | Edit|Write | Changelog versions descending, no duplicates, dates non-increasing, frontmatter version matches top row | Yes (exit 2 on violation) | ✓ (1 code) |
validate-state-pin-freshness.sh |
PostToolUse | Edit|Write | STATE.md version pins must match actual artifact file versions | Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-state-index-status-coherence.sh |
PostToolUse | Edit|Write | STATE.md convergence_status ↔ cycles/*/INDEX.md Status | Exit 1 (warn only) | ✓ (1 code, severity=warn) |
validate-index-self-reference.sh |
PostToolUse | Edit|Write | INDEX.md/burst-log.md edits should reference current pass/burst | No (advisory warning) | — |
validate-anchor-capabilities-union.sh |
PostToolUse | Edit|Write | Story anchor_capabilities: = sorted union of referenced BCs' capability: fields |
Yes (exit 2 on mismatch) | ✓ (1 code) |
validate-demo-evidence-story-scoped.sh |
PostToolUse | Edit|Write | Demo evidence files live under docs/demo-evidence/<STORY-ID>/ (POL-010) |
Yes (exit 2 on flat file) | ✓ (1 code) |
validate-pr-description-completeness.sh |
PostToolUse | Edit|Write | PR description has required sections + no placeholder tokens | Yes (exit 2 on incomplete) | ✓ (1 code) |
validate-wave-gate-completeness.sh |
PostToolUse | Edit|Write | gate_status: passed requires evidence of all 6 gates in the report |
Yes (exit 2 on incomplete) | ✓ (1 code) |
validate-factory-path-root.sh |
PostToolUse | Edit|Write | .factory/ writes use absolute project-root path, not worktree-relative |
Yes (exit 2 on worktree-relative) | ✓ (1 code) |
regression-gate.sh |
PostToolUse | Bash | Track test pass/fail transitions | No (telemetry) | ✓ (1 code, severity=warn) |
capture-pr-activity.sh |
PostToolUse | Bash | Capture gh pr create / gh pr merge and emit pr.opened / pr.merged events (with open→merge duration pairing) |
No (telemetry) | ✓ (2 event types) |
capture-commit-activity.sh |
PostToolUse | Bash | Capture git commit and emit commit.made events with sha, branch, subject, and --amend flag |
No (telemetry) | ✓ (1 event type) |
handoff-validator.sh |
SubagentStop | (all) | Subagent output is non-empty and structurally plausible | No (warn-only) | ✓ (2 codes, severity=warn) |
pr-manager-completion-guard.sh |
SubagentStop | pr-manager | Detect FM4: pr-manager exiting before 8+ STEP_COMPLETE emissions | Yes (exit 2 on early exit) | ✓ (1 code) |
update-wave-state-on-merge.sh |
SubagentStop | pr-manager | Append merged story to wave-state.yaml; flip gate_status to pending when wave complete | No (state update) | ✓ (1 code, hook.action) |
validate-pr-review-posted.sh |
SubagentStop | pr-reviewer | Verify pr-review.md written and formal gh pr review verdict posted |
Yes (exit 2 on incomplete) | ✓ (1 code) |
warn-pending-wave-gate.sh |
Stop | (all) | Session-end reminder if any wave has gate_status: pending | No (advisory) | ✓ (1 code, severity=warn) |
session-learning.sh |
Stop | (all) | Append learning marker to .factory/sidecar-learning.md |
No (non-blocking) | — |
Event: PreToolUse on Edit or Write
Blocks any edit or write to paths containing /.reference/ or starting with .reference/. Reference codebases are cloned into .reference/ during brownfield ingestion for analysis. Editing them corrupts the extraction and poisons downstream spec work.
Debugging: If a legitimate edit is blocked, verify the file path does not traverse a .reference/ directory. Reference repos are read-only by design.
Event: PreToolUse on Edit or Write
Blocks edits to Verification Property files in .factory/specs/verification-properties/ that have reached Status: green. Per SOUL.md principle 4 and spec-format rules, green VPs are immutable. To change a green VP, create a new VP that supersedes it and retire the old one.
Response format: Emits a PreToolUse JSON envelope with permissionDecision:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}Debugging: Read the VP file and check its Status field. If it shows green, the hook will deny edits. Create a new VP-NNN file instead of modifying the existing one.
Event: PreToolUse on Edit or Write
Companion to protect-vp.sh. Blocks edits to Behavioral Contract files in .factory/specs/behavioral-contracts/BC-*.md that have reached Status: green. Non-green contracts are freely editable.
Response format: Same permissionDecision envelope as protect-vp.sh.
Debugging: Same as protect-vp -- check the Status field. Draft and reviewed BCs allow edits; green BCs do not.
Event: PreToolUse on Edit or Write
Enforces TDD red-before-green discipline when strict mode is active. Strict mode is opt-in: create .factory/red-gate-state.json with the shape {"mode": "strict", "red": ["path/to/file.rs", ...]}. When active, only files listed in the red array may be edited. Test files are always allowed.
When mode is "off" or the state file does not exist, the hook allows all edits.
Debugging: Check .factory/red-gate-state.json. If strict mode is active and your edit is blocked, add the file to the red array or switch mode to "off".
Event: PreToolUse on Edit or Write
Blocks writes to .factory/ paths when the directory is not mounted as a git worktree on the factory-artifacts branch. Prevents artifacts from being committed to the wrong branch (develop, main) when the worktree is missing or misconfigured. Also guards .factory-project/ for multi-repo projects (expects factory-project-artifacts branch).
Checks:
- Does
.factory/.gitexist? (worktree marker file) - Is the worktree on
factory-artifactsbranch?
Debugging: Run git -C .factory rev-parse --abbrev-ref HEAD to check the branch. If wrong: cd .factory && git checkout factory-artifacts. If .factory/.git is missing: git worktree add .factory factory-artifacts.
Event: PreToolUse on Bash
Blocks destructive shell commands that could cause irreversible data loss. Each block message includes a suggestion for the safe alternative.
Blocked operations:
rm -rf/rm -rtargeting.factory/,src/,tests/(allows build dirs liketarget/,node_modules/)rmtargeting source-of-truth files (STATE.md, BC-INDEX.md, VP-INDEX.md, STORY-INDEX.md, ARCH-INDEX.md, prd.md)git reset --hard(suggestgit stashorgit reset --soft)git clean -f/git clean -fd(suggestgit clean -ndry-run first)git checkout -- ./git restore .(suggest targeting specific files)git rm -ron.factory/specs/or.factory/stories/
Allowed operations: rm -rf target/, rm -rf .worktrees/STORY-NNN/, rm of temp files, git reset --soft, git clean -n, git stash.
Debugging: If blocked, read the suggestion in the error message. Most blocks have a safe alternative that achieves the same goal.
Event: PreToolUse on Bash
Intercepts git push commands. Blocks force pushes (--force or -f) and direct pushes to protected branches (main, master, develop). For normal pushes to feature branches, injects a reminder to ensure tests pass before pushing.
Blocked operations:
git push origin main/master/develop— bypasses PR/review gatesgit push --force/-f— overwrites remote history
Block messages suggest the PR workflow: push to a feature branch and create a PR.
Debugging: If blocked, push to a feature branch instead (git push origin feature/STORY-NNN) and create a PR (gh pr create --base main).
Event: PreToolUse on Bash
Advisory hook that fires after git commit commands involving .factory/. If STATE.md was not part of the commit, it reminds the user to update STATE.md if a phase transition occurred. Non-blocking -- the commit proceeds regardless.
Event: PostToolUse on Edit or Write
Enforces the pure-core boundary from SOUL.md. Files under */pure/**, */core/**, or ending in _pure.rs / .pure.ts are scanned for known side-effect patterns (I/O, network, global state mutation). Emits a warning to stderr when violations are found. Non-blocking by design -- architectural drift is surfaced, not enforced. The regression gate and CI catch hard failures.
Event: PostToolUse on Edit or Write
Enforces Policy 9 (vp_index_is_vp_catalog_source_of_truth). After any edit to VP-INDEX.md, verification-architecture.md, or verification-coverage-matrix.md, validates consistency across all three files.
Checks:
- Every VP in VP-INDEX appears in verification-architecture.md Provable Properties Catalog
- Every VP in VP-INDEX appears in verification-coverage-matrix.md VP-to-Module table
- VP-INDEX per-tool summary totals match actual row counts
- Coverage matrix Totals row matches data row sums
- VPs referenced in coverage matrix exist in VP-INDEX (reverse check)
Exits non-zero (exit 2) on mismatch with a diagnostic listing the specific discrepancy. Tested with 3 fixture sets (green, canary with column drift, missing-VP).
Debugging: Read the error output — it names the specific VP, file, and mismatch. Fix the inconsistency and re-save. The hook re-validates on each edit.
Event: PostToolUse on Edit or Write
Enforces Policy 6 (architecture_is_subsystem_name_source_of_truth). After any edit to BC files (BC-*.md) or story files (STORY-*.md), extracts the subsystem: (BC) or subsystems: (story) field and verifies it matches a canonical name from ARCH-INDEX.md Subsystem Registry.
If ARCH-INDEX doesn't exist yet (architecture not produced), the hook passes silently. Error messages list all available canonical names so the agent can self-correct.
Debugging: Read the error — it names the invalid subsystem and lists valid options. Use the exact canonical name from ARCH-INDEX.
Event: PostToolUse on Edit or Write
Enforces Policy 7 (bc_h1_is_title_source_of_truth). After any edit to a BC file (BC-*.md, not BC-INDEX), extracts the H1 heading (# BC-S.SS.NNN: <title>) and compares it to the title column in BC-INDEX.md.
If BC-INDEX doesn't exist yet, the hook passes silently. Skips BC-INDEX.md itself. Error messages show both the H1 title and the BC-INDEX title so the agent knows which to fix.
Debugging: The H1 is authoritative. If they differ, update BC-INDEX to match the H1, not the other way around.
Event: PostToolUse on Edit or Write
Enforces Policy 8 (bc_array_changes_propagate_to_body_and_acs). After any edit to a story file (STORY-*.md), verifies bidirectional BC completeness:
- Every BC in frontmatter
bcs:appears in the body BC table - Every BC in frontmatter
bcs:has at least one AC trace annotation - Every BC in the body BC table appears in frontmatter
bcs:
Skips stories with no bcs: field (early creation). Error messages identify the specific missing BCs.
Debugging: Read the error — it names which BCs are missing from which representation. Add the missing rows to the body BC table and/or AC trace annotations.
Event: PostToolUse on Edit or Write
Validates that every artifact file written to .factory/ contains the required frontmatter fields and section headings defined by its corresponding template. Resolves templates by reading the file's document_type frontmatter and matching against template files in ${CLAUDE_PLUGIN_ROOT}/templates/. Falls back to path-pattern matching for files without document_type (e.g., stories/STORY-*.md → story-template.md).
What it checks:
- Required frontmatter fields present (keys only, not values)
- Required H2 section headings present (skips conditional/optional sections)
Skips INDEX files, non-.factory/ paths, and YAML/JSON config files.
Debugging: Read the warning — it lists exactly which frontmatter fields and sections are missing, and suggests running /vsdd-factory:conform-to-template to add them.
Event: PostToolUse on Edit or Write
Validates that finding and fix IDs in .factory/ files use only the current format. Blocks legacy formats that were deprecated during the ID formalization effort.
Blocked formats (legacy):
ADV-NNN— bare finding ID without cycle/pass/severityADV-P[N]-NNN— missing cycle and severitySTORY-NNN-FIX— old fix format
Accepted formats (current):
ADV-<CYCLE>-P[N]-[SEV]-NNN— full finding ID with cycle, pass, severityFIX-P[N]-NNN— current fix format
Debugging: Read the error — it identifies the legacy ID and shows the current format pattern. Replace with the current format.
Event: PostToolUse on Edit or Write
Advisory hook that checks .factory/ artifacts for input-hash drift. After any write to a .factory/ markdown file with an inputs: frontmatter field, checks whether the input-hash field is present and current.
What it checks:
- File has
inputs:butinput-hashis"[md5]"ornull→ warns "no computed input-hash" - File has
inputs:and a stored hash → runscompute-input-hash --checkto detect drift
Not blocking — the write proceeds regardless. The hook surfaces drift so agents can act on it. Use /vsdd-factory:check-input-drift for a batch scan at phase gates.
Debugging: Run compute-input-hash <file> --update to recompute and store the current hash.
Event: PostToolUse on Bash
Watches Bash commands that run tests (cargo test, pytest, npm test, go test, just test, just ci). Records pass/fail to .factory/regression-state.json. If the test suite transitions from pass to fail, emits a warning so the next edit can be informed. This is a telemetry hook consumed by the red-gate when strict mode is active.
Event: PostToolUse on Bash
Shipped in v0.73.1. Watches gh pr create and gh pr merge invocations and emits structured pr.opened / pr.merged events. Extracts PR URL / number / repo from gh's stdout (or the command args as fallback), plus title (--title "…") or merge strategy (--squash|--rebase|--merge). Word-boundary anchoring on the regex rejects echoed references in command text.
Since v0.75.0, pr.merged also carries open_to_merge_seconds — the hook looks back in the last 7 days of .factory/logs/events-*.jsonl for a matching pr.opened on the same PR number and computes the elapsed time. Absurd durations (negative or >30 days) are dropped to protect against PR-number collisions across weeks. Advisory (always exits 0); powers the Factory PRs dashboard's duration panels.
Event: PostToolUse on Bash
Shipped in v0.77.0. Watches git commit invocations and emits a commit.made event with commit_sha + branch parsed from the [<branch> <sha>] preamble git prints on success, plus message_subject from the rest of that line and amended="true" when --amend was in the command. Failed commits (pre-commit hook rejections) are no-ops; git commit --dry-run is a no-op; word-boundary anchoring rejects git commit-tree and echoed text. Exists because Claude Code's SDK doesn't actually emit the claude_code.commit.count metric its OTel docs list — we own the signal ourselves and feed the Factory ROI dashboard's Commits / Cost per commit panels.
Event: SubagentStop
Validates that subagent output is non-empty and structurally plausible. Warns to stderr if the result is empty, whitespace-only, or under 40 characters (suspiciously short for any factory agent). An empty subagent result is a common silent failure -- the orchestrator would act on nothing and the pipeline would drift. Non-blocking -- the orchestrator receives the result and decides how to proceed.
Event: Stop
At session end, appends a timestamped learning stub to .factory/sidecar-learning.md. The actual synthesis is done by the /vsdd-factory:session-review skill; this hook ensures a marker exists so nothing is silently lost when sessions end abruptly. Non-blocking and safe to fail.
All hooks are configured in plugins/vsdd-factory/hooks-registry.toml — the canonical, human-edited source of truth. Each entry is a [[hooks]] TOML stanza that the dispatcher reads at startup to build its routing table. hooks.json is regenerated from this file on every /vsdd-factory:activate run; operators never edit hooks.json directly.
The wiring uses these event types:
| Event | When It Fires |
|---|---|
PreToolUse |
Before a tool call executes. Can block ({"outcome":"block"}) or inject context. |
PostToolUse |
After a tool call completes. Cannot block -- advisory only. |
PostToolUseFailure |
After a tool call fails. |
SubagentStop |
When a subagent finishes and returns its result. Some hooks use advisory-block-mode here. |
Stop |
When the session ends. |
SessionStart / SessionEnd |
Session lifecycle. |
WorktreeCreate / WorktreeRemove |
Git worktree lifecycle. |
Most hooks today are bash scripts adapted via legacy-bash-adapter.wasm. The adapter reads [hooks.config] script_path and execs the underlying script:
[[hooks]]
name = "validate-bc-title"
event = "PostToolUse"
tool = "Edit|Write"
plugin = "hook-plugins/legacy-bash-adapter.wasm"
priority = 250
timeout_ms = 5000
on_error = "continue"
[hooks.config]
script_path = "hooks/validate-bc-title.sh"
[hooks.capabilities]
env_allow = ["PATH", "HOME", "TMPDIR", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT", "VSDD_SESSION_ID"]
[hooks.capabilities.exec_subprocess]
binary_allow = ["bash", "jq"]
shell_bypass_acknowledged = "legacy-bash-adapter runs unported hooks"
env_allow = ["PATH", "HOME", "TMPDIR", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT", "VSDD_SESSION_ID"]Native ports (S-2.5 onward) point plugin at the hook's own .wasm module and drop [hooks.config] script_path entirely:
[[hooks]]
name = "capture-commit-activity"
event = "PostToolUse"
plugin = "hook-plugins/capture-commit-activity.wasm"
priority = 110
timeout_ms = 5000
on_error = "continue"
[hooks.capabilities]
env_allow = []
[hooks.capabilities.exec_subprocess]
binary_allow = ["git"]Native and legacy entries coexist in the same registry. Capabilities (env, files, subprocesses) are declared per stanza and enforced by the dispatcher's WASI sandbox.
Each hook has a 5-second default timeout (10 seconds for several validators and verify-git-push.sh). Bash hooks require jq declared in binary_allow for JSON parsing of the tool input envelope.
factory-dispatcher is the WASM hook runtime that Claude Code invokes for every event in hooks-registry.toml. It is the single entrypoint shipped in plugins/vsdd-factory/bin/ (rc.10/rc.11) — every routed hook runs inside the dispatcher's wasmtime host, never as a direct subprocess of Claude Code.
On startup the dispatcher reads hooks-registry.toml, validates each stanza's schema, and builds an event → ordered-plugin-list routing table keyed by event (and optional tool matcher) and sorted by priority. When an event fires, the dispatcher loads each matching .wasm plugin into the sandbox, hands it the Claude Code event envelope on stdin, and enforces the declared capabilities (env_allow, read_file.path_allow, write_file.path_allow, exec_subprocess.binary_allow).
Native plugins are first-class wasm modules built from crates/hook-plugins/<name> and link directly against the host SDK (host::emit_event, host::read_file, etc.). Legacy bash hooks route through hook-plugins/legacy-bash-adapter.wasm, which reads [hooks.config] script_path from its own stanza, execs the referenced bash script with the granted subprocess capabilities, and forwards the script's stdout/exit code back as a dispatcher decision. This keeps the wiring uniform: every hook — native or bash — is discoverable via the same hooks-registry.toml and runs through the same dispatcher pipeline.