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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,11 @@ Nelson is not purely advisory. A set of Claude Code hooks (`hooks/nelson_hooks.p
| Event | Hook | What it enforces |
|---|---|---|
| `PreToolUse` on `Agent` | `preflight` | Station tier gate, file ownership conflicts, mode-tool consistency |
| `PreToolUse` on `TaskCreate` | `session-check` | Captain TaskCreate gate (admiral exception via session marker) |
| `PostToolUse` on `Write`/`Edit` | `brief-validate` | Turnover brief quality gate |
| `TaskCompleted` | `task-complete` | Validation evidence and station controls |
| `TeammateIdle` | `idle-ship` | Paid-off standing order advisory |
| `SessionStart` | `session-init` | Records admiral `transcript_path` for the TaskCreate gate |

Plugin installs auto-discover `hooks/hooks.json` and wire these up with no user action. Hooks degrade gracefully: if no active Nelson mission is found, they exit cleanly and do not interfere with non-Nelson workflows. See [Installation](#installation) for manual-install caveats.

Expand Down
19 changes: 19 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/nelson_hooks.py\" session-init"
}
]
}
],
"PreToolUse": [
{
"matcher": "Agent",
Expand All @@ -9,6 +19,15 @@
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/nelson_hooks.py\" preflight"
}
]
},
{
"matcher": "TaskCreate",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/nelson_hooks.py\" session-check"
}
]
}
],
"PostToolUse": [
Expand Down
125 changes: 125 additions & 0 deletions hooks/nelson_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
brief-validate — PostToolUse on Write/Edit: turnover brief quality gate
task-complete — TaskCompleted: validation evidence and station controls
idle-ship — TeammateIdle: paid-off standing order advisory
session-init — SessionStart: record admiral transcript_path for the
TaskCreate captain-misuse gate
session-check — PreToolUse on TaskCreate: reject captain TaskCreate calls
in subagents/single-session mode (admiral exception via
the marker written by session-init)

Exit codes:
0 — allow (action proceeds)
Expand All @@ -36,6 +41,11 @@
# ---------------------------------------------------------------------------


ADMIRAL_SESSION_MARKER = "admiral.session"
# NOTE: must stay in sync with
# skills/nelson/scripts/nelson_data_utils.py:ADMIRAL_SESSION_MARKER.


def _read_stdin() -> dict[str, Any]:
"""Parse JSON from stdin (Claude Code hook payload)."""
try:
Expand Down Expand Up @@ -116,6 +126,25 @@ def _load_mission_context(
return mission_dir, bp


def _write_admiral_marker(nelson_dir: Path, transcript_path: str) -> bool:
"""Write the admiral session marker. Returns True on success, False on failure.

The marker stores the admiral's transcript_path so cmd_session_check can
distinguish admiral calls (match) from captain subagent calls (mismatch).
"""
if not nelson_dir.is_dir():
return False
if not transcript_path.strip():
return False
try:
(nelson_dir / ADMIRAL_SESSION_MARKER).write_text(
transcript_path.strip() + "\n", encoding="utf-8",
)
return True
except OSError:
return False


# ---------------------------------------------------------------------------
# Preflight helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -209,6 +238,14 @@ def cmd_preflight(args: argparse.Namespace) -> None:
tasks = _get_tasks(battle_plan)
tool_input = payload.get("tool_input", {})

# Opportunistic admiral marker backfill: if init ran after SessionStart,
# the marker won't have been written. The admiral always fires PreToolUse
# on Agent before spawning captains, so this is a safe write point.
nelson_dir = Path(payload.get("cwd", ".")) / ".nelson"
marker = nelson_dir / ADMIRAL_SESSION_MARKER
if not marker.is_file():
_write_admiral_marker(nelson_dir, payload.get("transcript_path", ""))

for check in (
lambda: _check_station_tiers(tasks),
lambda: _check_file_ownership(tasks),
Expand Down Expand Up @@ -627,6 +664,84 @@ def _clear_idle_tracker(mission_dir: Path, ship_name: str) -> None:
clear_idle_tracker(mission_dir, ship_name)


# ---------------------------------------------------------------------------
# Subcommand: session-init (SessionStart)
# ---------------------------------------------------------------------------


def cmd_session_init(args: argparse.Namespace) -> None:
"""SessionStart event: record admiral identity for TaskCreate enforcement.

Writes the payload's transcript_path to .nelson/admiral.session so the
PreToolUse:TaskCreate hook can distinguish admiral (match) from captain
subagents (mismatch). No-op when .nelson/ does not yet exist — non-Nelson
projects are unaffected, and Nelson projects whose mission has not been
initialised will be backfilled by cmd_preflight on the first Agent spawn.
"""
payload = _read_stdin()
cwd = Path(payload.get("cwd", "."))
_write_admiral_marker(cwd / ".nelson", payload.get("transcript_path", ""))
_allow()


# ---------------------------------------------------------------------------
# Subcommand: session-check (PreToolUse on TaskCreate)
# ---------------------------------------------------------------------------


CAPTAIN_GATED_MODES = frozenset({"subagents", "single-session"})


def cmd_session_check(args: argparse.Namespace) -> None:
"""PreToolUse:TaskCreate gate using admiral session marker.

Rejects with wrong-ensign violation only when mode is in
CAPTAIN_GATED_MODES (subagents, single-session) AND the payload
transcript_path does not match the recorded admiral transcript
(i.e. captain subagent context).

Fails open in every other case:
- no active Nelson mission (graceful degradation)
- mode not in CAPTAIN_GATED_MODES (agent-team, future modes)
- admiral.session marker missing (never had a chance to record)
- admiral.session marker empty (interrupted write)
- payload transcript_path missing (defensive)
"""
payload = _read_stdin()
ctx = _load_mission_context(payload)
if ctx is None:
_allow()

_, battle_plan = ctx
mode = _get_mode(battle_plan)
if mode not in CAPTAIN_GATED_MODES:
_allow()

nelson_dir = Path(payload.get("cwd", ".")) / ".nelson"
marker = nelson_dir / ADMIRAL_SESSION_MARKER
if not marker.is_file():
_allow()

try:
admiral_transcript = marker.read_text(encoding="utf-8").strip()
except OSError:
_allow()

if not admiral_transcript:
_allow()

payload_transcript = payload.get("transcript_path", "").strip()
if payload_transcript and payload_transcript != admiral_transcript:
_reject(
"Standing order violation (wrong-ensign): "
f"TaskCreate is reserved for the admiral in {mode} mode. "
"Captains report progress via Agent return value, not the task list. "
"See references/tool-mapping.md."
)

_allow()


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -655,6 +770,14 @@ def main() -> None:
"idle-ship",
help="Idle ship advisory (TeammateIdle)",
)
subparsers.add_parser(
"session-init",
help="Record admiral transcript_path on session start",
)
subparsers.add_parser(
"session-check",
help="Captain TaskCreate gate (PreToolUse on TaskCreate)",
)

args = parser.parse_args()

Expand All @@ -663,6 +786,8 @@ def main() -> None:
"brief-validate": cmd_brief_validate,
"task-complete": cmd_task_complete,
"idle-ship": cmd_idle_ship,
"session-init": cmd_session_init,
"session-check": cmd_session_check,
}

handler = dispatch.get(args.command)
Expand Down
Loading
Loading