π Pre-flight Checks
π Bug Description
plugin/claude-code/scripts/user-prompt-submit.sh depends on jq for both of its field extractions, with no availability check:
INPUT=$(cat)
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
jq is not part of Git for Windows, so on a stock Windows install it is simply absent. When it is, jq writes command not found to stderr and the command substitution yields an empty string β the script does not fail, it continues with CWD="" and SESSION_ID="". The header comment says the script "MUST exit 0 always", and it does; the cost is that every downstream behaviour silently degrades.
Four consequences, all observed on a live install:
1. The session state file is never reused, so the first-message branch runs on every prompt.
With SESSION_ID empty, the key falls to the PID-keyed branch:
SESSION_KEY="engram-claude-${SAFE_PROJECT}-$$-tools-loaded"
$$ is different for every invocation, so [ ! -f "$STATE_FILE" ] is always true. Every prompt takes the ββ FIRST MESSAGE ββ path and touches a new file. On my machine %TEMP% had accumulated 373 zero-byte engram-claude-*-tools-loaded files, none of them session-id-keyed.
2. The save-nudge is unreachable.
Everything below the first-message branch β the session-age check, the GET /observations call, the 15-minute MEMORY REMINDER β sits after an unconditional exit 0 on that path. If the state file is never found, that code can never run. (This is a different root cause from #668, which covers the timezone maths in parse_epoch(); on a jq-less host the nudge never gets far enough to reach that logic.)
3. The first-message payload is empty too.
The injection itself is built with jq -n:
OUTPUT=$(jq -n --arg msg "$TOOL_MSG" '{"systemMessage": $msg}')
printf '%s\n' "$OUTPUT"
That also fails, so the hook emits a bare newline. The bootstrap the branch exists to deliver is not delivered. (Independent of #653 / #670, which explain why the payload would not land even when it is produced β undelivered systemMessage and a non-matching tool prefix respectively. All three have to be fixed for the bootstrap to actually work.)
4. The failure is masked, which is why it has been hard to spot.
detect_project "$CWD" is called with an empty string, and git -C "" falls back to the current directory rather than erroring. So PROJECT still resolves correctly from the hook's cwd, and the state files look perfectly healthy β engram-claude-mfta-kb-9182-tools-loaded β while nothing else in the script did its job. The only visible symptom is the file-per-prompt growth in %TEMP%.
To be clear about what is not broken: Claude Code does send session_id in the UserPromptSubmit payload, as documented in the hooks reference, and the script honours it correctly when jq is present β I verified a session-id-keyed state file is created in that case. The field is being lost on the engram side, not omitted by the client.
π Steps to Reproduce
- On Windows (or any host without
jq on the hook's PATH), install engram as a Claude Code plugin.
- Confirm the gap:
command -v jq from Git Bash returns nothing.
- Start a session and send several prompts.
- Inspect
%TEMP% / /tmp:
ls -1 /tmp/engram-claude-*-tools-loaded | wc -l # grows by one per prompt
ls -1 /tmp/engram-claude-*-tools-loaded | grep -cE 'engram-claude-[0-9a-f]{8}-[0-9a-f]{4}' # 0 β never session-keyed
- Run the hook by hand to see the silent failure and the empty output:
printf '%s' '{"session_id":"11111111-2222-3333-4444-555555555555","cwd":"/your/repo"}' \
| bash plugin/claude-code/scripts/user-prompt-submit.sh
# stderr: jq: command not found (x3)
# stdout: (empty line)
- Put
jq on PATH and repeat step 5 β a engram-claude-11111111-...-tools-loaded file appears and stdout carries the systemMessage payload, confirming the only variable is jq.
β
Expected Behavior
The hook should either work without jq or fail loudly rather than degrading to a no-op that still exits 0. Concretely:
- Detect
jq up front. If it is missing, either use a fallback extraction or emit {} once and stop, instead of running the whole body against empty inputs.
- When
SESSION_ID genuinely cannot be determined, key the state file on something stable for the session (e.g. PPID, or transcript_path, which the payload also carries) rather than $$, so first-message detection still works instead of firing every prompt.
- Don't leave a file per prompt behind in the temp directory.
β Actual Behavior
jq is missing, both field extractions silently yield empty strings, the script runs to completion and exits 0 with empty output. The ToolSearch bootstrap is never delivered, the save-nudge can never fire, and the temp directory grows by one zero-byte file per prompt (373 observed). detect_project masks the failure by resolving the project name from the current directory anyway.
π Relevant Logs
$ command -v jq # Git Bash, stock Windows install
(nothing)
$ ls -1 /tmp/engram-claude-*-tools-loaded | wc -l
373
$ ls -1 /tmp/engram-claude-*-tools-loaded | grep -cE 'engram-claude-[0-9a-f]{8}-[0-9a-f]{4}'
0
$ ls -1 /tmp/engram-claude-*-tools-loaded | sed -E 's/.*-([0-9]+)-tools-loaded/\1/' | sort -u | wc -l
373 # one distinct PID per file
$ printf '%s' '{"session_id":"1111-2222","cwd":"U:/kbs/mfta"}' | bash scripts/user-prompt-submit.sh
scripts/user-prompt-submit.sh: line 22: jq: command not found
scripts/user-prompt-submit.sh: line 23: jq: command not found
scripts/user-prompt-submit.sh: line 55: jq: command not found
# exit 0, stdout empty
$ ls /tmp/engram-claude-*-tools-loaded
/tmp/engram-claude-mfta-kb-6880-tools-loaded # project resolved, session id lost
π‘ Additional Context
Found while investigating a separate symptom on the same install: the UserPromptSubmit hook intermittently hitting its 2000 ms timeout. That turned out to be detect_project() in scripts/_helpers.sh shelling out to git -C "$dir" remote get-url origin on every prompt β on a repo hosted on an SMB share, a cold reconnect ate the remaining headroom and the hook was killed mid-run. Caching the resolved name per directory took the hook from ~737 ms to ~501 ms and removed the network round-trip from the hot path entirely. Happy to open that as its own issue if useful β it is independent of the jq problem, though it compounds it, since the hook does this work on every prompt precisely because first-message detection is broken.
Related but distinct, for triage: #653 (systemMessage never reaches the model), #670 (ToolSearch prefix cannot match in a plugin install), #668 (parse_epoch() timezone handling in the nudge), #644 / #580 (the same class of jq/Windows portability problem, but in the codex plugin's Stop hook β the claude-code plugin has the same unguarded dependency and has not been covered).
π Pre-flight Checks
status:approvedbefore a PR can be openedπ Bug Description
plugin/claude-code/scripts/user-prompt-submit.shdepends onjqfor both of its field extractions, with no availability check:jqis not part of Git for Windows, so on a stock Windows install it is simply absent. When it is,jqwritescommand not foundto stderr and the command substitution yields an empty string β the script does not fail, it continues withCWD=""andSESSION_ID="". The header comment says the script "MUST exit 0 always", and it does; the cost is that every downstream behaviour silently degrades.Four consequences, all observed on a live install:
1. The session state file is never reused, so the first-message branch runs on every prompt.
With
SESSION_IDempty, the key falls to the PID-keyed branch:SESSION_KEY="engram-claude-${SAFE_PROJECT}-$$-tools-loaded"$$is different for every invocation, so[ ! -f "$STATE_FILE" ]is always true. Every prompt takes theββ FIRST MESSAGE ββpath andtouches a new file. On my machine%TEMP%had accumulated 373 zero-byteengram-claude-*-tools-loadedfiles, none of them session-id-keyed.2. The save-nudge is unreachable.
Everything below the first-message branch β the session-age check, the
GET /observationscall, the 15-minuteMEMORY REMINDERβ sits after an unconditionalexit 0on that path. If the state file is never found, that code can never run. (This is a different root cause from #668, which covers the timezone maths inparse_epoch(); on a jq-less host the nudge never gets far enough to reach that logic.)3. The first-message payload is empty too.
The injection itself is built with
jq -n:That also fails, so the hook emits a bare newline. The bootstrap the branch exists to deliver is not delivered. (Independent of #653 / #670, which explain why the payload would not land even when it is produced β undelivered
systemMessageand a non-matching tool prefix respectively. All three have to be fixed for the bootstrap to actually work.)4. The failure is masked, which is why it has been hard to spot.
detect_project "$CWD"is called with an empty string, andgit -C ""falls back to the current directory rather than erroring. SoPROJECTstill resolves correctly from the hook's cwd, and the state files look perfectly healthy βengram-claude-mfta-kb-9182-tools-loadedβ while nothing else in the script did its job. The only visible symptom is the file-per-prompt growth in%TEMP%.To be clear about what is not broken: Claude Code does send
session_idin theUserPromptSubmitpayload, as documented in the hooks reference, and the script honours it correctly whenjqis present β I verified a session-id-keyed state file is created in that case. The field is being lost on the engram side, not omitted by the client.π Steps to Reproduce
jqon the hook'sPATH), install engram as a Claude Code plugin.command -v jqfrom Git Bash returns nothing.%TEMP%//tmp:jqonPATHand repeat step 5 β aengram-claude-11111111-...-tools-loadedfile appears and stdout carries thesystemMessagepayload, confirming the only variable isjq.β Expected Behavior
The hook should either work without
jqor fail loudly rather than degrading to a no-op that still exits 0. Concretely:jqup front. If it is missing, either use a fallback extraction or emit{}once and stop, instead of running the whole body against empty inputs.SESSION_IDgenuinely cannot be determined, key the state file on something stable for the session (e.g.PPID, ortranscript_path, which the payload also carries) rather than$$, so first-message detection still works instead of firing every prompt.β Actual Behavior
jqis missing, both field extractions silently yield empty strings, the script runs to completion and exits 0 with empty output. The ToolSearch bootstrap is never delivered, the save-nudge can never fire, and the temp directory grows by one zero-byte file per prompt (373 observed).detect_projectmasks the failure by resolving the project name from the current directory anyway.π Relevant Logs
π‘ Additional Context
Found while investigating a separate symptom on the same install: the
UserPromptSubmithook intermittently hitting its 2000 ms timeout. That turned out to bedetect_project()inscripts/_helpers.shshelling out togit -C "$dir" remote get-url originon every prompt β on a repo hosted on an SMB share, a cold reconnect ate the remaining headroom and the hook was killed mid-run. Caching the resolved name per directory took the hook from ~737 ms to ~501 ms and removed the network round-trip from the hot path entirely. Happy to open that as its own issue if useful β it is independent of thejqproblem, though it compounds it, since the hook does this work on every prompt precisely because first-message detection is broken.Related but distinct, for triage: #653 (
systemMessagenever reaches the model), #670 (ToolSearch prefix cannot match in a plugin install), #668 (parse_epoch()timezone handling in the nudge), #644 / #580 (the same class ofjq/Windows portability problem, but in the codex plugin's Stop hook β the claude-code plugin has the same unguarded dependency and has not been covered).