---
phase: 36-hook-time-binding
plan: 01
type: execute
wave: 1
depends_on: ["35-01"]
files_modified:
  - plugins/autonomous-loop/hooks/session-bind.sh
  - plugins/autonomous-loop/hooks/heartbeat-tick.sh
  - plugins/autonomous-loop/scripts/hook-install-lib.sh
  - plugins/autonomous-loop/skills/start/SKILL.md
  - plugins/autonomous-loop/skills/setup/SKILL.md
  - plugins/autonomous-loop/tests/test-session-bind.sh
  - plugins/autonomous-loop/tests/test-heartbeat-stdin.sh
autonomous: true
requirements:
  - BIND-01
  - BIND-02
  - BIND-03
  - BIND-04

must_haves:
  truths:
    - "BIND-01: New hooks/session-bind.sh exists; hook-install-lib.sh installs it as SessionStart hook with matcher=*"
    - "BIND-02: SessionStart hook reads stdin JSON {session_id, cwd, source}; binds owner_session_id atomically via update_loop_field if currently in {pending-bind, unknown, unknown-session, ''}; logs bind_first/bind_resume/observer events to provenance"
    - "BIND-03: heartbeat-tick.sh reads session_id and cwd from stdin JSON (not $CLAUDE_SESSION_ID env var); records bound_cwd on first heartbeat; flags cwd_drift_detected=true in heartbeat.json + emits cwd_drift_detected provenance event when current cwd differs from bound_cwd"
    - "BIND-04: skills/start/SKILL.md no longer captures \\$CLAUDE_SESSION_ID; sets owner_session_id=pending-bind and bound_cwd=null at registration"
    - "All existing tests still pass; new test-session-bind.sh has 5 cases (bind_first, bind_resume idempotent, parallel race, dead-owner-no-auto-reclaim, observer); new test-heartbeat-stdin.sh has 3 cases (stdin happy path, cwd-drift, empty-stdin graceful)"
    - "shellcheck clean on all touched .sh files; bun scripts/validate-plugins.mjs PASS"
  artifacts:
    - path: "plugins/autonomous-loop/hooks/session-bind.sh"
      provides: "SessionStart hook with stdin-driven atomic binding"
      contains: "bind_first"
    - path: "plugins/autonomous-loop/hooks/heartbeat-tick.sh"
      provides: "PostToolUse hook reading stdin payload"
      contains: "bound_cwd"
  key_links:
    - "https://github.com/anthropics/claude-code/issues/47018"
---

<objective>
Move session→loop binding from skill-Bash-subprocess (broken `$CLAUDE_SESSION_ID`) to hook-time. Add a SessionStart hook that authoritatively binds `owner_session_id` from stdin payload. Rewrite the PostToolUse heartbeat hook to consume stdin JSON. Detect cwd drift on every heartbeat. Make multi-session same-folder a first-class concept (one owner, N observers).

Purpose: Eliminate the root cause of cross-contamination — sessions can no longer fail to identify themselves, and cwd drift is detected the moment it happens.
Output: 2 hook scripts + 1 lib extension + 2 skill updates + 2 test scripts.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/36-hook-time-binding/36-CONTEXT.md
@plugins/autonomous-loop/CLAUDE.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Author hooks/session-bind.sh (SessionStart hook)</name>
  <files>plugins/autonomous-loop/hooks/session-bind.sh</files>
  <read_first>
    - plugins/autonomous-loop/hooks/heartbeat-tick.sh
    - plugins/autonomous-loop/scripts/registry-lib.sh
    - plugins/autonomous-loop/scripts/provenance-lib.sh
  </read_first>
  <action>
Create `plugins/autonomous-loop/hooks/session-bind.sh`. Mirrors heartbeat-tick.sh structure (set -euo pipefail, error trap that logs and exits 0, source registry-lib + state-lib + provenance-lib).

**Logic:**

1. Read stdin JSON payload (with timeout fallback if no stdin available — degrade silently).
2. Extract `session_id`, `cwd`, `source` from payload.
3. If session_id is empty: emit_provenance with event=`bind_skipped_no_session_id` and exit 0.
4. Read registry; for EACH loop where `cwd` starts_with `dirname(contract_path)`:
   a. Read current `owner_session_id`.
   b. State-machine decision:
   - `""` / `unknown` / `unknown-session` / `pending-bind` → `bind_first`: atomic CAS via `update_loop_field` to set owner_session_id=session_id; emit_provenance bind_first.
   - matches current session_id → `bind_resume`: idempotent; emit_provenance bind_resume.
   - other UUID, owner_pid alive (kill -0) → `observer`: do NOT mutate; emit_provenance observer.
   - other UUID, owner_pid dead AND last_updated > 1h → `stale_owner_detected`: do NOT auto-reclaim; emit_provenance stale_owner_detected (Phase 38's doctor will handle).
5. Always exit 0 (SessionStart hook must never block session startup).

**Set `_PROV_AGENT="session-bind.sh"` before sourcing provenance-lib so the agent field is correct in events.**

**Header comment:** explain this is the SessionStart hook installed in `~/.claude/settings.json` and consumed payload schema.
</action>
</task>

<task type="auto">
  <name>Task 2: Rewrite hooks/heartbeat-tick.sh to read stdin (not env var)</name>
  <files>plugins/autonomous-loop/hooks/heartbeat-tick.sh</files>
  <read_first>
    - plugins/autonomous-loop/hooks/heartbeat-tick.sh
    - plugins/autonomous-loop/scripts/state-lib.sh
    - plugins/autonomous-loop/scripts/provenance-lib.sh
  </read_first>
  <action>
Edit existing heartbeat-tick.sh. Replace the `SESSION_ID="${CLAUDE_SESSION_ID:-}"` env var read with stdin JSON parsing. Preserve all existing structure (error trap, library sourcing, registry lookup).

**Changes:**

1. At the top of main logic (after sourcing libs): read stdin into `PAYLOAD` via `PAYLOAD=$(cat 2>/dev/null || echo '{}')`. Parse `SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // ""')` and `STDIN_CWD=$(echo "$PAYLOAD" | jq -r '.cwd // ""')`. Fall back to `${CLAUDE_SESSION_ID:-}` env var ONLY if stdin payload is empty (back-compat).
2. Use `STDIN_CWD` if non-empty, else `$(pwd)` as `CWD`.
3. After matching loop and verifying owner_session_id (existing logic), add bound_cwd handling:
   - Read current `bound_cwd` from heartbeat.json (if exists) via `jq -r '.bound_cwd // ""'`.
   - If empty (first tick after bind): set bound_cwd=dirname(contract_path) on this write.
   - Else if `CWD` does NOT start_with `bound_cwd`: set `cwd_drift_detected=true` on this write AND emit_provenance with event=cwd_drift_detected (cwd_observed=$CWD, cwd_bound=$bound_cwd).
4. Pass bound_cwd and cwd_drift_detected through to write_heartbeat (extend state-lib if needed; if write_heartbeat doesn't support these, add them via a follow-up jq merge: write heartbeat normally then jq-update the file to add the fields).

**Source provenance-lib.sh near the top.** Set `_PROV_AGENT="heartbeat-tick.sh"`.

Keep all existing exit-0 fail-graceful paths intact.
</action>
</task>

<task type="auto">
  <name>Task 3: Extend hook-install-lib.sh to install SessionStart hook</name>
  <files>plugins/autonomous-loop/scripts/hook-install-lib.sh</files>
  <read_first>
    - plugins/autonomous-loop/scripts/hook-install-lib.sh
  </read_first>
  <action>
Generalize the installer to manage TWO hook entries:
1. PostToolUse → heartbeat-tick.sh (existing)
2. SessionStart → session-bind.sh (new)

**Approach (minimal changes):**

- Add a new function `install_session_bind_hook` that mirrors `install_hook_impl` but uses `.hooks.SessionStart[]` and points at `hooks/session-bind.sh`.
- Add a new public wrapper `install_session_bind` that uses `_with_settings_lock`.
- Update `install_hook` (the existing public function) so it ALSO calls `install_session_bind` after installing PostToolUse — making `install_hook` an idempotent "install ALL autonomous-loop hooks" entry point.
- Update `uninstall_hook` symmetrically (uninstall both).
- Update `is_hook_installed` to return "yes" only if BOTH hooks present.
- Add a `hook_path_default_session_bind` helper that returns the absolute path to session-bind.sh.

Do NOT remove or rename the existing `install_hook_impl` / `uninstall_hook_impl` functions; existing tests reference them.
</action>
</task>

<task type="auto">
  <name>Task 4: Update skills/start/SKILL.md to use pending-bind</name>
  <files>plugins/autonomous-loop/skills/start/SKILL.md</files>
  <read_first>
    - plugins/autonomous-loop/skills/start/SKILL.md
  </read_first>
  <action>
Edit `skills/start/SKILL.md` Step 5 (registry registration):
- Remove the line `--arg owner_session_id "$(echo "$CLAUDE_SESSION_ID" | cut -c1-28)"` (broken).
- Replace with `--arg owner_session_id "pending-bind"`.
- Add `--arg bound_cwd ""` to the entry.
- Add a paragraph note above Step 5 explaining: "owner_session_id is set to 'pending-bind' here. The SessionStart hook (`hooks/session-bind.sh`) authoritatively binds it on the next session start, because $CLAUDE_SESSION_ID env var is not populated in skill Bash subprocesses (anthropics/claude-code#47018)."

Also: ensure Step 1 mentions both hooks (PostToolUse + SessionStart) need to be installed. Update the comment to "Install heartbeat + session-bind hooks (idempotent)."
</action>
</task>

<task type="auto">
  <name>Task 5: Update skills/setup/SKILL.md to install both hooks</name>
  <files>plugins/autonomous-loop/skills/setup/SKILL.md</files>
  <read_first>
    - plugins/autonomous-loop/skills/setup/SKILL.md
  </read_first>
  <action>
If `setup/SKILL.md` references `install_hook` only, update it to note both hooks are installed via the same call. If it has separate references to PostToolUse, generalize. Add a verification step that confirms BOTH `is_hook_installed` returns yes (PostToolUse) AND a separate check for SessionStart.

Goal: `setup` is the single one-time bootstrap entry point — running it (idempotently) ensures both autonomous-loop hooks are present in `~/.claude/settings.json`.
</action>
</task>

<task type="auto">
  <name>Task 6: Author tests/test-session-bind.sh</name>
  <files>plugins/autonomous-loop/tests/test-session-bind.sh</files>
  <read_first>
    - plugins/autonomous-loop/tests/test-registry-write.sh
    - plugins/autonomous-loop/tests/test-provenance.sh
    - plugins/autonomous-loop/hooks/session-bind.sh
  </read_first>
  <action>
Create `tests/test-session-bind.sh`. Mirror `test-provenance.sh` structure (mktemp -d isolated HOME, pass/fail counters, assert helpers).

**Five test cases:**

1. `test_bind_first_pending_bind`: register loop with owner_session_id=pending-bind; pipe SessionStart payload to session-bind.sh; assert registry now has session_id; assert provenance has bind_first event.
2. `test_bind_resume_idempotent`: same session_id binds twice; second call no-ops registry but logs bind_resume.
3. `test_parallel_race`: spawn 5 sessions racing for binding (different session_ids, same loop_id); assert exactly one wins; others log observer.
4. `test_dead_owner_no_auto_reclaim`: register loop with dead PID (e.g., 999999) and last_updated > 1h; assert session-bind logs stale_owner_detected and does NOT modify registry.
5. `test_observer_when_other_alive`: register loop with current process's PID as owner; new session attempts bind; assert observer event logged, registry unchanged.

Each test sets `HOME=$(mktemp -d)`, mocks registry, pipes JSON payload via `echo "$JSON" | bash session-bind.sh`. Use `read_registry_entry` from registry-lib to verify state.
</action>
</task>

<task type="auto">
  <name>Task 7: Author tests/test-heartbeat-stdin.sh</name>
  <files>plugins/autonomous-loop/tests/test-heartbeat-stdin.sh</files>
  <read_first>
    - plugins/autonomous-loop/tests/test-heartbeat-hook.sh
    - plugins/autonomous-loop/hooks/heartbeat-tick.sh
  </read_first>
  <action>
Create `tests/test-heartbeat-stdin.sh`. Three test cases:

1. `test_happy_stdin`: register loop with bound owner_session_id; pipe PostToolUse payload `{"session_id": "...", "cwd": "<contract dir>"}`; assert heartbeat.json updated with bound_cwd set.
2. `test_cwd_drift`: pre-existing heartbeat.json has bound_cwd=/A; pipe payload with cwd=/B; assert heartbeat.json now has cwd_drift_detected=true AND provenance has cwd_drift_detected event.
3. `test_empty_stdin_graceful`: invoke heartbeat-tick.sh with empty stdin AND no $CLAUDE_SESSION_ID env var; assert exit 0, no errors, no heartbeat written, no provenance event.

Use the same mktemp -d isolation pattern.
</action>
</task>

<task type="auto">
  <name>Task 8: Run shellcheck + tests + plugin validation; commit on green</name>
  <files></files>
  <action>
1. `chmod +x plugins/autonomous-loop/hooks/session-bind.sh plugins/autonomous-loop/tests/test-session-bind.sh plugins/autonomous-loop/tests/test-heartbeat-stdin.sh`
2. `shellcheck` on all 6 modified .sh files (and the 2 new test scripts) — must exit 0.
3. Run new tests: `bash plugins/autonomous-loop/tests/test-session-bind.sh` and `bash plugins/autonomous-loop/tests/test-heartbeat-stdin.sh` — both exit 0.
4. Run existing test suite to verify no regression: any tests in `plugins/autonomous-loop/tests/test-*.sh` that don't depend on installed hooks — at minimum `test-provenance.sh` from Phase 35 must still pass.
5. `bun scripts/validate-plugins.mjs` — must PASS.
6. Stage and commit:
```
feat(autonomous-loop): hook-time session→loop binding (BIND-01..04)

Replace the broken $CLAUDE_SESSION_ID env-var capture (skill Bash
subprocesses don't get it; ref anthropics/claude-code#47018) with
hook-time binding via stdin JSON payloads.

- New hooks/session-bind.sh: SessionStart hook reads {session_id, cwd,
  source} from stdin; atomic CAS binds owner_session_id when current
  state ∈ {pending-bind, unknown, unknown-session, ''}; logs
  bind_first/bind_resume/observer/stale_owner_detected events to
  provenance. Multi-session same-folder is first-class: only the first
  becomes owner; others log observer and do not mutate.
- Rewrite hooks/heartbeat-tick.sh: reads session_id+cwd from stdin
  payload; records bound_cwd on first heartbeat; flags
  cwd_drift_detected=true and emits provenance event when current cwd
  diverges from bound_cwd. Env-var fallback retained for back-compat
  but path is deprecated.
- Extend scripts/hook-install-lib.sh: install_hook now installs BOTH
  PostToolUse heartbeat-tick.sh AND SessionStart session-bind.sh
  idempotently; uninstall symmetric.
- skills/start/SKILL.md: drop $CLAUDE_SESSION_ID capture; set
  owner_session_id=pending-bind and bound_cwd="" at registration.
  SessionStart hook completes the binding on first tool call.
- skills/setup/SKILL.md: clarify the single bootstrap call wires both
  hooks.

Tests: 8 new assertions across 5 cases for session-bind (bind_first,
bind_resume, parallel race, dead-owner-no-auto-reclaim, observer);
3 cases for heartbeat-stdin (happy stdin, cwd-drift, empty-stdin
graceful). Phase 35's test-provenance.sh continues to pass.

Refs: BIND-01, BIND-02, BIND-03, BIND-04

```
  </action>
</task>

</tasks>
```
