diff --git a/hooks/ways/issues-pull.sh b/hooks/ways/issues-pull.sh index 4127860b..694f551a 100755 --- a/hooks/ways/issues-pull.sh +++ b/hooks/ways/issues-pull.sh @@ -12,6 +12,11 @@ # # SessionStart and UserPromptSubmit accept plain stdout as context. # PostToolUse needs hookSpecificOutput.additionalContext. +# +# PostToolUse also fires inside subagents, whose hook input carries the +# parent's session_id plus an agent_id. The whisper is the head agent's: a +# subagent that pulled would rotate the snapshot and consume the delta, so +# the hook exits before touching the store when agent_id is set. MODE="${1:-prompt}" GH_TASKS="$(dirname "$0")/softwaredev/delivery/issues/gh-tasks" @@ -21,6 +26,8 @@ command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || exit 0 INPUT=$(cat) SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') [[ -n "$SESSION_ID" ]] || exit 0 +AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty') +[[ -z "$AGENT_ID" ]] || exit 0 SOURCE=$(echo "$INPUT" | jq -r '.source // empty') CWD=$(echo "$INPUT" | jq -r '.cwd // empty') [[ -n "$CWD" && -d "$CWD" ]] && cd "$CWD" diff --git a/hooks/ways/softwaredev/delivery/issues/issues.md b/hooks/ways/softwaredev/delivery/issues/issues.md index d4d29489..01368f91 100644 --- a/hooks/ways/softwaredev/delivery/issues/issues.md +++ b/hooks/ways/softwaredev/delivery/issues/issues.md @@ -10,7 +10,7 @@ refire: 0.15 # Issues as Tasks -Issues carrying the `tasklist` label are mirrored into this session's task list (ADR-180). GitHub is the shared truth. The task store is a cache. Hooks pull on session start, at a prompt when the snapshot is stale, and after any `gh issue` command run here. `/issues` is the on-demand path. +Issues carrying the `tasklist` label are mirrored into this session's task list (ADR-180). GitHub is the shared truth. The task store is a cache. Hooks pull on session start, at a prompt when the snapshot is stale, and after any `gh issue` command run here. The hooks act for the head agent only; a subagent's `gh issue` command leaves the store and the whisper untouched. `/issues` is the on-demand path. ## What a mirrored task looks like diff --git a/tests/gh-tasks-test.sh b/tests/gh-tasks-test.sh index 2cd8324d..46df1ea3 100755 --- a/tests/gh-tasks-test.sh +++ b/tests/gh-tasks-test.sh @@ -6,9 +6,9 @@ # in_progress, remote close and reopen move status, body change replaces # description, local description edit survives an unchanged body); id # conflict left untouched; unrecognized layout writes nothing; whisper -# deltas; link; the TaskCreated guard; attach on a resume (fresh team by age -# and cwd, carry-forward of open tasks, claimed and stale teams skipped); -# refusal without a session id. +# deltas; the hook skips subagent input; link; the TaskCreated guard; +# attach on a resume (fresh team by age and cwd, carry-forward of open +# tasks, claimed and stale teams skipped); refusal without a session id. set -euo pipefail @@ -205,6 +205,21 @@ fixture "$GH_FIXTURE" "[$(issue 12 'Add widget v2' CLOSED 'Body twelve'), $(issu assert_has "full list shows the new issue" "$("$GH_TASKS" whisper --full)" "#31 Fresh" assert_empty "no second report on the next prompt" "$("$GH_TASKS" whisper)" +# ── 9d2. the hook leaves the store alone for a subagent ──────── +HOOK="$REPO_ROOT/hooks/ways/issues-pull.sh" +HOOK_CWD="$TMP/hookrepo"; git init -q "$HOOK_CWD" +hook_input() { jq -n --arg s "$CLAUDE_CODE_SESSION_ID" --arg c "$HOOK_CWD" --arg a "${1:-}" \ + '{session_id:$s, cwd:$c, tool_name:"Bash"} + (if $a == "" then {} else {agent_id:$a} end)'; } +fixture "$GH_FIXTURE" "[$(issue 12 'Add widget v2' CLOSED 'Body twelve'), $(issue 14 'New one' OPEN 'x'), $(issue 30 'Odd' OPEN 'x'), $(issue 31 'Fresh' OPEN 'x'), $(issue 32 'Later' OPEN 'x')]" +"$GH_TASKS" pull 2>/dev/null +# #32 is pulled and unread; #33 is on GitHub and not yet pulled. +fixture "$GH_FIXTURE" "[$(issue 12 'Add widget v2' CLOSED 'Body twelve'), $(issue 14 'New one' OPEN 'x'), $(issue 30 'Odd' OPEN 'x'), $(issue 31 'Fresh' OPEN 'x'), $(issue 32 'Later' OPEN 'x'), $(issue 33 'Head only' OPEN 'x')]" +assert_empty "subagent input gets no whisper" "$(hook_input a512f45a8aee73bde | "$HOOK" post-gh)" +assert_eq "subagent input does not pull" "$([[ -e "$STORE/gh-33.json" ]] && echo present || echo absent)" "absent" +assert_eq "subagent input leaves the previous snapshot" "$([[ -e "$STATE/snapshot.prev.json" ]] && echo present || echo absent)" "present" +assert_has "the unread delta is still the head agent's" "$("$GH_TASKS" whisper)" 'opened #32 "Later"' +assert_has "head input still gets the PostToolUse whisper" "$(hook_input | "$HOOK" post-gh | jq -r .hookSpecificOutput.additionalContext)" 'opened #33 "Head only"' + # ── 9e. blocks edges the session added survive ───────────────── jq '.blocks += ["gh-30"]' "$STORE/gh-14.json" >"$TMP/x" && mv "$TMP/x" "$STORE/gh-14.json" "$GH_TASKS" pull 2>/dev/null