# MORPH-SPEC Hooks Architecture (v2)

Comprehensive hooks system for enforcing spec-driven development at the Claude Code level.

## Architecture

```
framework/hooks/
├── claude-code/                     # Claude Code native hooks
│   ├── core/
│   │   ├── worktree-context.js      # SessionStart: worktree feature identity / active-feature summary
│   │   ├── worktree-create.js       # WorktreeCreate: governed tree via `morph-spec worktree setup|provision`
│   │   ├── worktree-remove.js       # WorktreeRemove: safe teardown via `worktree remove --if-clean`
│   │   ├── block-destructive-worktree-removal.js  # PreToolUse(Bash): raw removal follows junctions out
│   │   └── block-raw-worktree-add.js              # PreToolUse(Bash): raw `git worktree add` is ungoverned
│   ├── user-prompt/
│   │   └── enrich-prompt.js         # Context-aware prompt enrichment
│   ├── pre-tool-use/
│   │   ├── protect-spec-files.js    # Block edits to approved spec artifacts
│   │   └── enforce-phase-writes.js  # Enforce writes to correct phase dir
│   ├── post-tool-use/
│   │   └── dispatch.js              # Dispatch on CLI commands (auto-checkpoint)
│   ├── stop/
│   │   └── validate-completion.js   # Advisory: warn about incomplete work
│   ├── pre-compact/
│   │   └── save-morph-context.js    # Snapshot state before compaction
├── shared/                          # Reusable utilities for all hooks
│   ├── state-reader.js              # Read-only state.json accessor
│   ├── phase-utils.js               # Phase constants and path utilities
│   ├── hook-response.js             # JSON response builders
│   └── stdin-reader.js              # Stdin JSON reader
├── git/                             # Git hooks (Bash)
│   ├── pre-commit/
│   │   ├── orchestrator.sh          # Master hook dispatcher
│   │   ├── agents.sh                # Validates agents.json schema
│   │   └── specs.sh                 # Validates spec.md sections
│   ├── commit-msg/
│   │   └── conventional-commits.sh  # Enforces conventional commits
│   └── pre-push/
│       └── run-tests.sh             # Runs test suite before push
└── README.md                        # This file
```

## Hook Events

| Event | Hook | Type | Purpose |
|-------|------|------|---------|
| **SessionStart** | worktree-context.js | Context inject | Secondary worktree → feature identity (name/branch/phase) + missing-junction warning; primary root → active-feature summary (phase, task stats, pending gates), silent if none |
| **PreToolUse** (Write\|Edit) | _(native permissions.deny)_ | Block | Blocks edits to state.json and .morph/framework/ |
| **PreToolUse** (Write\|Edit) | protect-spec-files.js | Block | Blocks edits to spec files after approval; for tasks.json validates the mutable-field diff, the status enum, and requires a `notes` when `outputs` diverge from the plan |
| **PreToolUse** (Write\|Edit) | enforce-phase-writes.js | Block | Ensures writes go to current phase directory |
| **PreToolUse** (Bash) | pre-bash-denylist.js | Block | Blocks `rm -rf .morph/` and direct state edits |
| **PreToolUse** (Bash) | block-destructive-worktree-removal.js | Block | Blocks `git worktree remove` / `rm -rf` / `Remove-Item -Recurse` on a directory holding reparse points (the infra junctions a raw removal would FOLLOW out, emptying the root's `.claude/`); names `morph-spec worktree remove` |
| **PreToolUse** (Bash) | block-raw-worktree-add.js | Block | Blocks raw `git worktree add` (a tree with no junctions → no hooks, no port block, no identity, outside `worktrees/`); names `morph-spec worktree setup <feature>` / `worktree provision <name> --base <ref>`. `list`/`prune`/`remove`/`lock`/`move`/`repair` pass |
| **WorktreeCreate** | worktree-create.js | Replaces native | Fires for `claude -w`, EnterWorktree and `Agent isolation:"worktree"`; replaces Claude Code's bare `git worktree add`. Runs `morph-spec worktree setup <n>` (known feature) or `worktree provision <n>` (task tree, branch `morph-task/<n>`), honours `worktree.baseRef:"head"`, prints the path as its last stdout line. CLI unreachable → plain git, still under `<root>/worktrees/` |
| **WorktreeRemove** | worktree-remove.js | Fail-open | Routes Claude Code's own cleanup through `morph-spec worktree remove --target --if-clean` (junctions dropped first; a dirty tree is left alone). Always exit 0. Secondary net — the event was observed NOT to fire headless; the Bash guard above stays the primary defense |
| **PostToolUse** (Write\|Edit) | post-edit-typecheck.js | Advisory | Debounced `tsc --noEmit` after TS/TSX edits; silent on success. Also touches `.morph/memory/build-dirty.json` on every source-file edit — the write side of validate-completion.js's Stop-hook build-dirty gate |
| **PostToolUse** (Write\|Edit) | tasks-json-guard.js | Advisory | Validates the written tasks.json schema (status/effort enums); warns so the LLM fixes it immediately |
| **PostToolUse** (Write\|Edit) | trace-autogen.js | State sync | Auto-generates `.morph/traces/YYYY-MM-DD-{feature}-implement.json` from tasks.json + recap.md + taskScores on every tasks.json write — the skills never write traces by hand |
| **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state updates (feature register, output tracking, gate approval) |
| **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions; logs one `coherent`/`divergent` line per check in `.morph/logs/activity.jsonl` (issue #63) |
| **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec telemetry` |
| **PostToolUse** (all tools) | loop-detect.js | Advisory | Warns when the last 8 actions collapse to ≤2 unique signatures |
| **UserPromptSubmit** | telemetry-log.js | Telemetry | Appends the user's prompt to the same `events.jsonl` stream; silent, fail-open |
| **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/recap drift (hash-deduped, only re-injects when the warning set changes); dirty-flag-gated build check |

## Design Principles

1. **Fail-open**: All hooks catch exceptions and `exit 0` — never accidentally block legitimate work
2. **Non-morph projects**: Every hook checks for `.morph/state.json` first and exits silently if missing
3. **Performance**: PreToolUse hooks use synchronous state reads for <100ms execution
4. **Cross-platform**: All hooks use `path.join()`/`path.resolve()`, no hardcoded path separators
5. **Node.js only**: All hooks use `node` as executor (no PowerShell/bash dependency)

## Installation

Hooks are automatically installed by `morph-spec init` and updated by `morph-spec update`.

During init/update, the entire `framework/hooks/` directory is copied to `.morph/framework/hooks/`.
Hook commands in `.claude/settings.local.json` reference `$CLAUDE_PROJECT_DIR/.morph/framework/hooks/`
so they work correctly in any project regardless of how morph-spec was installed.

The installer writes to `.claude/settings.local.json`:

```json
{
  "hooks": {
    "SessionStart": [{ "matcher": "startup|resume|compact", "hooks": [...] }],
    "UserPromptSubmit": [{ "hooks": [...] }],
    "PreToolUse": [
      { "matcher": "Write|Edit", "hooks": [...] },
      { "matcher": "Bash", "hooks": [...] }
    ],
    "PostToolUse": [
      { "matcher": "Bash", "hooks": [...] }
    ],
    "Stop": [{ "hooks": [...] }],
    "PreCompact": [{ "hooks": [...] }],
    "Notification": [{ "matcher": "idle_prompt", "hooks": [...] }]
  }
}
```

### Git Hooks

```bash
# In your project root
cd .git/hooks
ln -sf ../../framework/hooks/git/pre-commit/orchestrator.sh pre-commit
ln -sf ../../framework/hooks/git/commit-msg/conventional-commits.sh commit-msg
ln -sf ../../framework/hooks/git/pre-push/run-tests.sh pre-push
chmod +x pre-commit commit-msg pre-push
```

## Shared Utilities

All Claude Code hooks import from `framework/hooks/shared/`:

| Module | Purpose |
|--------|---------|
| `state-reader.js` | `loadState()`, `getActiveFeature()`, `getFeaturePhase()`, `isGateApproved()`, `getPendingGates()`, `getMissingOutputs()` |
| `phase-utils.js` | Phase constants, path extraction, file classification |
| `hook-response.js` | `block(reason)`, `approve(context)`, `injectContext(text)`, `pass()` |
| `stdin-reader.js` | `readStdin()` — Promise-based stdin JSON reader |
| `telemetry-logger.js` | `appendTelemetryEvent()`, `readTelemetryEvents()` — append-only JSONL event stream (`.morph/logs/events.jsonl`) |
| `morph-cli.js` | `runMorphSpec(args, { cwd, timeoutMs, env })` — spawns the morph-spec CLI from a hook (`MORPH_SPEC_BIN` → `morph-spec` on PATH → `npx --no-install`); `unavailable: true` when none resolves, never a throw. Hooks may never import from `src/` |
| `command-tokens.js` | `tokenize(command)` — quote-aware shell tokenizer shared by the Bash guards (a hook must never import a sibling hook: its standalone tail would fire) |

`injectContext(text)` is how `worktree-context.js` (and any other SessionStart hook) delivers context: it emits `{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": text } }` on stdout. Claude Code only honors additional context nested under `hookSpecificOutput` — a bare top-level field is ignored.

## How Hooks Work

### PreToolUse (Write|Edit) Flow

```
Claude calls Write/Edit tool
    ↓
Claude Code sends JSON to stdin: { tool_input: { file_path: "..." } }
    ↓
[native permissions.deny]
    ├── Is .morph/state.json? → BLOCK (use CLI)
    ├── Is .morph/framework/**? → BLOCK (read-only)
    └── Other → continue
    ↓
protect-spec-files.js
    ├── Is in .morph/features/{feature}/?
    │   ├── Is spec.md and design gate approved? → BLOCK
    │   ├── Is tasks.json and tasks gate approved? → BLOCK
    │   └── Gate not approved → pass
    └── Not a feature file → pass
    ↓
enforce-phase-writes.js
    ├── Is in .morph/features/{feature}/?
    │   ├── Phase is implement → pass (unrestricted)
    │   ├── Target dir matches phase dir → pass
    │   └── Target dir doesn't match → BLOCK
    └── Not a feature file → pass
```

### SessionStart Flow

```
Session starts/resumes/compacts
    ↓
worktree-context.js
    ├── No morph project (no state.json AND no .morph/features/) → silent exit
    ├── Secondary worktree (git-dir != git-common-dir)?
    │   ├── Feature identified (branch morph/{f}, or the lone .morph/features/ dir)
    │   │   → inject: "🌳 Worktree desta feature: {f} (branch morph/{f}, fase {phase}).
    │   │      Trabalhe apenas nesta feature nesta sessão; o estado vive no .morph/
    │   │      deste worktree." + a warning appended if node_modules/.claude
    │   │      junctions are missing ("Rode: morph-spec worktree link --all")
    │   └── No feature identified → silent exit
    └── Primary root (not a worktree)
        ├── Has active (in_progress/draft) feature → inject summary:
        │   "MORPH-SPEC: feature ativa {name} (fase {phase}) · tasks {c}/{t}
        │    · gates pendentes: {gate, gate}"
        └── No active feature → silent exit (don't clutter idle sessions)
```

v8 note: this hook also fulfills the SessionStart injection role this README
used to describe as `inject-morph-context.js` — that file was documented but
never registered in `MORPH_HOOKS`; `worktree-context.js` is the hook that
actually ships (v3.2.0), and its primary-root branch covers the same
active-feature-summary ground.

## Testing

```bash
# Run hook tests
npm test -- test/hooks/

# Run shared utilities tests
npm test -- test/hooks/shared-utils.test.js

# Run installer tests
npm test -- test/hooks/hooks-installer.test.js
```

## Troubleshooting

### Hooks Not Running

```bash
# Check if hooks are installed
cat .claude/settings.local.json | jq '.hooks'

# Reinstall hooks
morph-spec update
```

### Hook Blocking Legitimate Work

All hooks are fail-open. If a hook is incorrectly blocking:

1. The hook catches its own errors and exits 0
2. If truly stuck, remove the specific hook from `.claude/settings.local.json`
3. Report the issue so the hook logic can be fixed

### Resetting All Morph Hooks

```bash
morph-spec doctor --reset
```

---

*MORPH-SPEC by Polymorphism Tech — Hooks Architecture v2.7*
