Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,29 @@ documented per event in the official Claude Code docs.

## Hook inventory

| Hook | Event | Blocking? | Purpose |
|------|-------|-----------|---------|
| `safety-guardrails.py` | `PreToolUse` (Edit/Write/Read/MultiEdit/Bash) | Yes (JSON deny) | Block sensitive files, dangerous commands |
| `workflow-gate.py` | `PreToolUse` (Edit/Write/MultiEdit) | Yes (JSON deny) | Enforce Actor+Monitor workflow before edits |
| `workflow-context-injector.py` | `PreToolUse` (Edit/Write/Bash) | No | Inject MAP workflow reminder |
| `ralph-iteration-logger.py` | `PostToolUse` | No | Log iterations, detect file thrashing |
| `ralph-context-pruner.py` | `PreCompact` | No | Save restore point, prune logs |
| `pre-compact-save-transcript.py` | `PreCompact` | No | Save full conversation transcript |
| `post-compact-context.py` | `SessionStart` (compact) | No | Inject restore-point context |
| `end-of-turn.sh` | `Stop` | No | Auto-fix lint/format silently |
| `detect-clarification-triggers.py` | `UserPromptSubmit` | No | Detect "ask if unclear" + async/durability language |

Last reviewed: 2026-04-28.
All 11 hooks (10 `.py` + `end-of-turn.sh`) are classified against the
`MAP_INVOKED_BY` recursion-guard contract. **REQUIRE_GUARD** hooks early-exit
when MAP spawns a nested subprocess; **FORBID_GUARD** hooks must always fire
and may not carry the guard. Full contract and per-hook rationale:
[`../references/hook-patterns.md`](../references/hook-patterns.md). The
classification is enforced by `scripts/lint-hooks.py` (in `make lint` /
`make check`).

| Hook | Event | Blocking? | Class | Purpose |
|------|-------|-----------|-------|---------|
| `safety-guardrails.py` | `PreToolUse` (Edit/Write/Read/MultiEdit/Bash) | Yes (JSON deny) | FORBID_GUARD | Block sensitive files, dangerous commands |
| `workflow-gate.py` | `PreToolUse` (Edit/Write/MultiEdit) | Yes (JSON deny) | FORBID_GUARD | Enforce Actor+Monitor workflow before edits |
| `post-compact-context.py` | `SessionStart` (compact) | No | FORBID_GUARD | Inject restore-point context (re-prime after compaction) |
| `context-meter.py` | `UserPromptSubmit` | No | REQUIRE_GUARD | Nudge `/compact <focus>` when the token threshold is crossed |
| `map-token-meter.py` | `SubagentStop` + `Stop` | No | REQUIRE_GUARD | Attribute per-turn token usage to the active MAP subtask |
| `workflow-context-injector.py` | `PreToolUse` (Edit/Write/Bash) | No | REQUIRE_GUARD | Inject MAP workflow reminder |
| `ralph-iteration-logger.py` | `PostToolUse` | No | REQUIRE_GUARD | Log iterations, detect file thrashing |
| `ralph-context-pruner.py` | `PreCompact` | No | REQUIRE_GUARD | Save restore point, prune logs |
| `pre-compact-save-transcript.py` | `PreCompact` | No | REQUIRE_GUARD | Save full conversation transcript |
| `detect-clarification-triggers.py` | `UserPromptSubmit` | No | REQUIRE_GUARD | Detect "ask if unclear" + async/durability language |
| `end-of-turn.sh` | `Stop` | No | REQUIRE_GUARD | Auto-fix lint/format silently |

> The Codex twin `.codex/hooks/workflow-gate.py` is FORBID_GUARD like its
> Claude counterpart; this inventory covers `.claude/hooks/` only.

Last reviewed: 2026-05-29.
2 changes: 2 additions & 0 deletions .claude/hooks/context-meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ def _silent() -> None:


def main() -> None:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
# Read input strictly as JSON. Anything malformed -> silent no-op.
try:
input_data = json.load(sys.stdin)
Expand Down
3 changes: 3 additions & 0 deletions .claude/hooks/detect-clarification-triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import json
import os
import re
import sys

Expand Down Expand Up @@ -149,6 +150,8 @@ def build_message(clar: bool, dura: bool) -> str:


def main() -> int:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
try:
payload = json.load(sys.stdin)
except Exception:
Expand Down
3 changes: 3 additions & 0 deletions .claude/hooks/end-of-turn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@

set -euo pipefail

# Recursion guard: no-op when MAP spawned this subprocess (MAP_INVOKED_BY set)
[ -n "${MAP_INVOKED_BY:-}" ] && exit 0

# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions .claude/hooks/map-token-meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def _silent() -> None:


def main() -> None:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
Expand Down
2 changes: 2 additions & 0 deletions .claude/hooks/pre-compact-save-transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ def parse_transcript(transcript_path: Path) -> str:


def main() -> None:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
Expand Down
2 changes: 2 additions & 0 deletions .claude/hooks/ralph-context-pruner.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ def format_recovery_message(state: Dict[str, Any], branch: str) -> str:

def main() -> None:
"""Main hook execution logic."""
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
# Read stdin (required by hook protocol)
try:
json.load(sys.stdin)
Expand Down
2 changes: 2 additions & 0 deletions .claude/hooks/ralph-iteration-logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ def detect_thrashing(log_file: Path) -> Optional[dict]:

def main() -> None:
"""Main hook execution logic."""
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
Expand Down
12 changes: 7 additions & 5 deletions .claude/hooks/workflow-context-injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def read_step_state(branch: str) -> tuple[dict | None, str | None]:

def load_step_state(branch: str) -> dict | None:
"""Load step state from .map/<branch>/step_state.json."""
state, _reason = read_step_state(branch)
state, _ = read_step_state(branch)
return state


Expand Down Expand Up @@ -282,7 +282,7 @@ def record_hook_injection_status(

def record_skip_if_state_available(branch: str, reason: str, tool_name: str) -> None:
"""Persist a skipped hook outcome only when existing state is safe to update."""
state, _state_error = read_step_state(branch)
state, _ = read_step_state(branch)
if state is not None:
record_hook_injection_status(branch, state, "skipped", reason, tool_name)

Expand Down Expand Up @@ -335,7 +335,7 @@ def state_string(state: dict, key: str, default: str = "") -> str:
return default


def required_action_for_step(step_id: str, step_phase: str, state: dict) -> str | None:
def required_action_for_step(step_id: str, step_phase: str) -> str | None:
"""Return a short required-next-action hint for common steps."""
if step_id == "1.55":
return "Approve plan (set_plan_approved true)"
Expand Down Expand Up @@ -521,7 +521,7 @@ def format_reminder(
wave_hint += f" ({', '.join(str(item) for item in current_wave)})"
mode = "batch:parallel"

required = required_action_for_step(step_id, step_phase, state)
required = required_action_for_step(step_id, step_phase)

diag_hint = ""
diag_file = (
Expand Down Expand Up @@ -602,6 +602,8 @@ def format_reminder(


def main() -> None:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
branch = get_branch_name()
try:
input_data = json.load(sys.stdin)
Expand Down Expand Up @@ -664,7 +666,7 @@ def main() -> None:
sys.exit(0)

# Load and format workflow step state
state, _state_error = read_step_state(branch)
state, _ = read_step_state(branch)

if state is None:
print("{}")
Expand Down
157 changes: 157 additions & 0 deletions .claude/references/hook-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Hook Patterns — The `MAP_INVOKED_BY` Recursion Guard

This document is the authoritative contract for the recursion guard that every
MAP hook is classified against. It is enforced mechanically by
`scripts/lint-hooks.py` (wired into `make lint` / `make check`) and proven by
`tests/test_hook_patterns.py`. The classification list here and in
`lint-hooks.py` must agree; a hook that is unclassified fails the linter.

## Why a recursion guard exists

A MAP workflow routinely spawns a nested Claude/Codex subprocess (a nested
Actor, Monitor, or — in Phase E — a memory-flush `claude -p` launched from a
hook). When it does, it sets the reserved environment variable
`MAP_INVOKED_BY` (see `.claude/references/host-paths.md` for the reserved
`MAP_*` namespace).

The nested subprocess re-fires the **entire hook chain**. Hooks that do
orchestration-, session-, or telemetry-level work belong to the *top-level*
session; re-running them inside a nested Actor is at best noise (duplicate
context injection, double-counted tokens) and at worst recursive (a hook that
spawns child tooling which itself re-enters the hook chain). The guard makes
those hooks no-op when `MAP_INVOKED_BY` is set.

The guard is **not** a blanket "exit everywhere" switch. A subset of hooks —
the deny gates and the post-compaction re-prime — MUST always fire, even
inside a nested invocation. Applying the guard to them would be a security
regression (a nested Actor doing real edits would no longer be gated) or a
correctness regression (a nested Actor whose context was just compacted would
lose its workflow re-prime). Those hooks are therefore guard-**forbidden**.

## The two classes

Every hook is in exactly one class.

### REQUIRE_GUARD — recursion-suppressed (early-exit on `MAP_INVOKED_BY`)

These only emit context / nudges / telemetry / transcript saves that belong to
the top-level session. They early-exit when the flag is set.

| Hook | Event | Blocking? | Rationale for suppression |
|------|-------|-----------|---------------------------|
| `context-meter.py` | `UserPromptSubmit` | No | `/compact` nudge is a top-level session concern; meaningless inside a nested turn |
| `map-token-meter.py` | `SubagentStop` + `Stop` | No | Token attribution is owned by the parent run; nested re-entry double-counts and can spawn child tooling |
| `workflow-context-injector.py` | `PreToolUse` (Edit/Write/Bash) | No | The MAP reminder targets the top-level operator, not a nested Actor that already has its subtask context |
| `detect-clarification-triggers.py` | `UserPromptSubmit` | No | Clarification nudges apply to the human-facing prompt, not nested machine turns |
| `ralph-iteration-logger.py` | `PostToolUse` | No | Iteration/thrashing logging is a parent-run concern; the orchestrator runs its own Monitor on the subtask diff |
| `ralph-context-pruner.py` | `PreCompact` | No | Restore-point/pruning belongs to the top-level transcript |
| `pre-compact-save-transcript.py` | `PreCompact` | No | Saving the parent transcript; a nested run has its own short-lived transcript |
| `end-of-turn.sh` | `Stop` | No | Auto-format could edit files outside a nested Actor's `affected_files`; lint surfacing is the orchestrator's job |

> **Intentional consequence:** suppressing `end-of-turn.sh` and
> `ralph-iteration-logger.py` in nested runs means a nested Actor's lint
> errors / tool calls are not surfaced or logged at the *parent* level. This
> is by design — the orchestrator runs its own Monitor and `make check` on the
> subtask diff. It is documented here, not a defect.

### FORBID_GUARD — must always fire (guard is forbidden)

These either enforce a safety/workflow boundary or recover context. The linter
forbids a `MAP_INVOKED_BY`-conditioned early-exit in them, in both directions,
so a future contributor cannot "helpfully" disable the gate for every
MAP-spawned subagent.

| Hook | Event | Blocking? | Rationale for always-fire |
|------|-------|-----------|---------------------------|
| `safety-guardrails.py` | `PreToolUse` (Edit/Write/Read/MultiEdit/Bash) | Yes (JSON deny) | A nested Actor doing real edits MUST still be blocked from sensitive files / dangerous commands |
| `workflow-gate.py` | `PreToolUse` (Edit/Write/MultiEdit) | Yes (JSON deny) | The Actor+Monitor phase gate must enforce on nested edits exactly as on top-level edits |
| `workflow-gate.py` (Codex) | `PreToolUse` | Yes (JSON deny) | Codex twin of the above (`.codex/hooks/` + `src/mapify_cli/templates/codex/hooks/`); same rule |
| `post-compact-context.py` | `SessionStart` (compact) | No | A nested Actor whose context was just compacted needs the MAP re-prime *more*, not less; SessionStart cannot be self-triggered by a hook, so it is not a recursion source |

> **Load-bearing security property (INV-A1):** A FORBID_GUARD hook's
> decision/recovery path is byte-identical whether or not `MAP_INVOKED_BY` is
> set. This mirrors the learned rule *"never structurally bypass the
> blocklist."* The deny gates read no env flag at all.

## The guard idiom and its position

### Position rule (INV-A2)

Presence is not enough — **position** is enforced.

- **Python REQUIRE_GUARD hooks:** the guard MUST be the **first statement of
the entry function** (`main()` or equivalent), after the function docstring
(if any) but before any `stdin` read or other I/O. If a hook has no `main()`
and executes at module scope, the guard MUST be the first statement at module
scope after the import block and constant definitions.
- **Shell REQUIRE_GUARD hooks (`end-of-turn.sh`):** the guard MUST appear
before the first command that reads input or runs tooling.

`scripts/lint-hooks.py` AST-walks each `.py` hook and regex-checks each `.sh`
hook to verify the class-appropriate guard *and* its position; a guard placed
after a side-effecting statement fails the linter, not just an absent one.

### Canonical idiom (SC-1 — byte-identical across all REQUIRE_GUARD hooks)

Python:

```python
def main() -> None:
if os.environ.get("MAP_INVOKED_BY"):
sys.exit(0)
...
```

Shell (`set -euo pipefail` safe — the `:-` default avoids tripping `nounset`):

```bash
set -euo pipefail

# Recursion guard: no-op when MAP spawned this subprocess (MAP_INVOKED_BY set)
[ -n "${MAP_INVOKED_BY:-}" ] && exit 0
```

`MAP_INVOKED_BY` set to the empty string counts as "not invoked": both
`os.environ.get(...)` (falsy on `""`) and the shell `-n "${MAP_INVOKED_BY:-}"`
test treat empty as unset.

## Pointer: the `LockState` marker enum

Hook serialization across processes is governed by the lock-state marker
contract, **not** by this env-flag guard. The authoritative marker enum is
`LockState` in `src/mapify_cli/_locking.py` (a closed `StrEnum`:
`in_progress`, `created`, `updated`, `skipped`, `timeout`, `error`), written to
the sidecar at `~/.map/locks/<name>.state.json` by `flock_with_state`. See
`.claude/references/host-paths.md` §(f)/(g) for the marker contract and the
`~/.map/locks/` protocol.

Phase A deliberately does **not** call `flock_with_state` for hook
serialization — there is no current recursion-by-concurrency case, so the
env-flag guard above is sufficient. The lock-state contract is referenced here
only so the two mechanisms are not confused.

## Phase E forward reference — not used by any current hook

> **The pattern below is documented for forward compatibility only. No current
> hook implements it.** It is recorded here so it is not mistaken for an
> active convention.

Phase E will let a hook spawn a fully detached background process (e.g. a
memory-flush `claude -p`) that outlives the hook without re-entering the hook
chain on the parent's stdin. The contract for that detached spawn is:

```python
import subprocess

subprocess.Popen(
[...],
start_new_session=True, # detach from the parent process group
stdin=subprocess.DEVNULL, # never inherit / block on the parent's stdin
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
```

The detached child sets `MAP_INVOKED_BY` in its own environment so that any
hooks it triggers honor the REQUIRE_GUARD early-exit above. Until Phase E
lands, treat this section as design intent, not implemented behavior.
19 changes: 19 additions & 0 deletions .claude/rules/learned/architecture-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,22 @@
def get_result(self, run_id):
return self.db.fetch_one("runs", run_id=run_id)
```

- **CLI Gate Reading From stdin Must Distinguish "No Input Piped" From "Invalid Content"** (2026-05-29): When a MANDATORY gate CLI reads its subject from stdin (truncation detector, validator), empty stdin and valid-but-failing content are different failure modes that need different exit behavior. In a Task/Agent flow a bare call with nothing piped means the caller forgot to pipe — a caller error, not a gate verdict. Returning `truncated:true` / nonzero on empty stdin turns every bare invocation into a false-positive hard stop, silently making the gate non-functional (operators learn to ignore the always-red signal). Add a distinct non-blocking `status:"no_input"` (exit 0) for empty stdin; keep the pure function strict (empty→invalid) for programmatic/library callers; and fix the skill docs to actually pipe the captured response. [workflow: map-efficient]
```python
# WRONG — CLI: empty stdin == truncated content == hard stop on every bare call
text = sys.stdin.read()
report = detect_truncated(text) # "" -> {"truncated": True, "reasons": ["empty response"]}
print(json.dumps(report))

# CORRECT — CLI distinguishes caller-error from content failure; pure fn stays strict
text = sys.stdin.read()
if not text.strip():
print(json.dumps({"truncated": False, "status": "no_input",
"reasons": ["no response on stdin — pipe the captured response"]}))
sys.exit(0) # bare call is non-applicable, not a failure
report = detect_truncated(text) # only runs on real content
print(json.dumps({**report, "status": "ok"}))
```

- **Always-Loaded Skill Body Has a Hard Line Budget — Put Detail in the Reference File** (2026-05-29): An always-loaded active skill body (e.g. `SKILL.md`) is guarded by a CI test enforcing a max line count (it loads on every invocation and costs context). Adding even correct, useful prose to it can silently push it over budget and break the test. Architectural rule: the active body holds only a short pointer; detail lives in the bundled reference file (e.g. `efficient-reference.md`), which is not budget-gated. If the budget itself is wrong, change the test and the budget together in a deliberate commit — never grow the active body past it by accident. [workflow: map-efficient]
13 changes: 13 additions & 0 deletions .claude/rules/learned/implementation-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,16 @@ paths:
del _args, _kwargs
return mock_result
```

- **Blast-Radius / "Validate Callers" Detectors Must Exclude Generic Process-Entrypoint Names** (2026-05-29): When a static-analysis detector flags a changed module-level symbol and recommends validating its external callers, exclude generic process-entrypoint names (`main`, and by extension `run`/`cli`/`app` if a project uses them that way) in the SAME predicate that already excludes dunders and too-short names. These names are invoked by convention (`if __name__ == "__main__"`, `python -m`, entry_points), never imported as shared helpers, so they have no true import-callers — but the literal word matches prose in docs/config. A changed `def main()` matched "main" in ~168 SKILL.md / settings.json lines and recommended `validate_callers` on every entrypoint edit. Centralize the exclusion in one `_is_reportable_symbol` predicate so every consumer inherits it; meaningful-symbol callers in markdown stay flagged by design. [workflow: map-efficient]
```python
_GENERIC_ENTRYPOINT_NAMES = frozenset({"main"}) # add run/cli/app only if used that way

def _is_reportable_symbol(name: str) -> bool:
return (
bool(name)
and not (name.startswith("__") and name.endswith("__")) # dunders
and len(name) >= 3 # too-short
and name not in _GENERIC_ENTRYPOINT_NAMES # convention-called entrypoints
)
```
Loading
Loading