Skip to content
Open
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
45 changes: 45 additions & 0 deletions plugins/warp/scripts/extract-query.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/bash
# Extracts the last human-authored prompt from a Claude Code transcript.
#
# Usage: source this file, then call extract_query with the transcript path.
#
# source "$(dirname "${BASH_SOURCE[0]}")/extract-query.sh"
# QUERY=$(extract_query "$TRANSCRIPT_PATH")
#
# Not every "user" entry in the transcript is something a human typed. Claude
# Code injects turns of its own — background task notifications, skill loads,
# slash-command expansions, compaction summaries — and stores them with
# type "user" too. Those carry an origin.kind other than "human" (or no origin
# at all, plus isMeta: true), and their content is raw markup like
# <task-notification>. Selecting purely on type "user" surfaces that markup in
# the notification, so we key on origin.kind instead.

# jq program: prefer the last origin.kind == "human" message; if the transcript
# predates the origin field, fall back to the last non-meta message whose text
# doesn't start with an injected-markup tag.
EXTRACT_QUERY_JQ='
[
.[]
| select(.type == "user" and .isMeta != true)
| {
origin: .origin.kind,
text: (
if .message.content | type == "string" then .message.content
else [.message.content[]? | select(.type == "text") | .text] | join(" ")
end
)
}
| select(.text != null and .text != "")
] as $msgs
| (
([$msgs[] | select(.origin == "human")] | last)
// ([$msgs[] | select(.origin == null and (.text | test("^[[:space:]]*<[a-zA-Z-]+>") | not))] | last)
)
| .text // empty
'

extract_query() {
local transcript_path="$1"
[ -n "$transcript_path" ] && [ -f "$transcript_path" ] || return 0
jq -rs "$EXTRACT_QUERY_JQ" "$transcript_path" 2>/dev/null
}
15 changes: 2 additions & 13 deletions plugins/warp/scripts/on-stop-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ if ! should_use_structured; then
fi

source "$SCRIPT_DIR/build-payload.sh"
source "$SCRIPT_DIR/extract-query.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -25,19 +26,7 @@ ERROR_MESSAGE=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
QUERY=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
QUERY=$(jq -rs '
[
.[] | select(.type == "user") |
if .message.content | type == "string" then .
elif [.message.content[] | select(.type == "text")] | length > 0 then .
else empty
end
] | last |
if .message.content | type == "array"
then [.message.content[] | select(.type == "text") | .text] | join(" ")
else .message.content // empty
end
' "$TRANSCRIPT_PATH" 2>/dev/null)
QUERY=$(extract_query "$TRANSCRIPT_PATH")

if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
QUERY="${QUERY:0:197}..."
Expand Down
24 changes: 5 additions & 19 deletions plugins/warp/scripts/on-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ if ! should_use_structured; then
fi

source "$SCRIPT_DIR/build-payload.sh"
source "$SCRIPT_DIR/extract-query.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -30,25 +31,10 @@ sleep 0.3
QUERY=""
RESPONSE=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
# Get the last human prompt from the transcript.
# "user" type messages include both human prompts and tool-result messages.
# Human prompts have content that is either a plain string or an array
# containing {type:"text"} blocks. Tool-result messages have content arrays
# containing only {type:"tool_result"} blocks. We filter to messages that
# have at least one "text" block (or are a plain string).
QUERY=$(jq -rs '
[
.[] | select(.type == "user") |
if .message.content | type == "string" then .
elif [.message.content[] | select(.type == "text")] | length > 0 then .
else empty
end
] | last |
if .message.content | type == "array"
then [.message.content[] | select(.type == "text") | .text] | join(" ")
else .message.content // empty
end
' "$TRANSCRIPT_PATH" 2>/dev/null)
# Get the last human prompt from the transcript. See extract-query.sh —
# "user" entries also cover tool results and Claude Code's own injected
# turns, which must not reach the notification.
QUERY=$(extract_query "$TRANSCRIPT_PATH")

# Get the last assistant response
RESPONSE=$(jq -rs '
Expand Down
60 changes: 60 additions & 0 deletions plugins/warp/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,66 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do
assert_eq "$HOOK exits 0 without protocol version" "0" "$?"
done

echo ""
echo "=== extract-query.sh ==="

source "$SCRIPT_DIR/extract-query.sh"

FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$FIXTURE_DIR"' EXIT

# A turn that ended because a background task reported back: the human's real
# prompt came earlier, and Claude Code injected the <task-notification> turn.
cat > "$FIXTURE_DIR/task-notification.jsonl" <<'FIXTURE'
{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":"review the pipeline for MR !1151"}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Watching it now."}]}}
{"type":"user","origin":{"kind":"task-notification"},"promptSource":"system","message":{"role":"user","content":"<task-notification>\n<task-id>bpzyv8z2j</task-id>\n<summary>Monitor event</summary>\n</task-notification>"}}
FIXTURE
assert_eq "task-notification does not become the query" \
"review the pipeline for MR !1151" \
"$(extract_query "$FIXTURE_DIR/task-notification.jsonl")"

# Skill loads, slash-command expansions and compaction summaries are injected
# with isMeta: true and no origin.
cat > "$FIXTURE_DIR/meta.jsonl" <<'FIXTURE'
{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":"fix the hook"}}
{"type":"user","isMeta":true,"message":{"role":"user","content":"Base directory for this skill: /home/x/.claude/skills/y"}}
{"type":"user","isMeta":true,"message":{"role":"user","content":"[Image: source: /home/x/Pictures/shot.png]"}}
FIXTURE
assert_eq "isMeta turns do not become the query" \
"fix the hook" \
"$(extract_query "$FIXTURE_DIR/meta.jsonl")"

# Tool results are "user" entries too, but carry no text block.
cat > "$FIXTURE_DIR/tool-result.jsonl" <<'FIXTURE'
{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":[{"type":"text","text":"run the tests"}]}}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]}}
FIXTURE
assert_eq "text blocks in an array are joined, tool results skipped" \
"run the tests" \
"$(extract_query "$FIXTURE_DIR/tool-result.jsonl")"

# Transcripts written before the origin field existed still need a usable query.
cat > "$FIXTURE_DIR/no-origin.jsonl" <<'FIXTURE'
{"type":"user","message":{"role":"user","content":"older prompt with no origin field"}}
{"type":"user","message":{"role":"user","content":"<local-command-stdout>Added allow rule</local-command-stdout>"}}
FIXTURE
assert_eq "falls back to the last non-markup turn when origin is absent" \
"older prompt with no origin field" \
"$(extract_query "$FIXTURE_DIR/no-origin.jsonl")"

# A transcript with nothing human in it must yield an empty query, not markup.
cat > "$FIXTURE_DIR/only-injected.jsonl" <<'FIXTURE'
{"type":"user","origin":{"kind":"task-notification"},"promptSource":"system","message":{"role":"user","content":"<task-notification>\n<task-id>abc</task-id>\n</task-notification>"}}
FIXTURE
assert_eq "no human turn yields an empty query" \
"" \
"$(extract_query "$FIXTURE_DIR/only-injected.jsonl")"

assert_eq "missing transcript yields an empty query" \
"" \
"$(extract_query "$FIXTURE_DIR/does-not-exist.jsonl")"

# --- Summary ---

echo ""
Expand Down