diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 42201c4..226a607 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ }, "metadata": { "description": "Pure-Rust Clean Architecture workflow. Six commands (start, fix, plan, ship, review, install-ci) for axum + sqlx + Dioxus 0.7+ + tokio. Always-latest deps, CI audit gate, anti-slop enforced.", - "version": "4.2.1" + "version": "4.2.3" }, "plugins": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ff123..715f389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to the code-et plugin will be documented in this file. +## [4.2.3] - 2026-05-12 + +### Fixed — task-metadata hook was wired to the wrong event + +The diagnostic dump added in 4.2.2 captured the live payload at last: the script was wired to `TaskCreated` (a *lifecycle* event that fires after the task is in the store), and that envelope is flat — `session_id`, `hook_event_name`, `task_id`, `task_subject`, `task_description`. No `tool_input`, no `metadata`. The metadata only rides on the *tool-call* envelope, not the lifecycle event. Every well-formed `TaskCreate` call was getting rejected because the hook was reading a field that never exists on the payload it receives. + +`TaskCreate`'s tool schema confirms `metadata` is a top-level call parameter, so the right hook is `PreToolUse` matched on `TaskCreate` — which fires *before* the task is created, with the raw `tool_input` (including `metadata`) intact. Two wins from the move: agents that forget `metadata` get rejected before an orphan task lands in the store, and the existing extractor's first try (`tool_input.metadata`) now works against the real envelope without falling through to the deep-search fallback. + +**Changed.** `hooks/hooks.json` swaps `TaskCreated` (empty matcher) for `PreToolUse` (matcher: `TaskCreate`). The script keeps its v4.2.2 envelope-agnostic extractor and rejection-time payload dump — both still pay off the next time the contract drifts. + +Verified against the captured real-world envelope plus two new bats cases (14/14 pass). + +## [4.2.2] - 2026-05-12 + +### Fixed — TaskCreated hook rejects valid metadata under unknown envelope shapes + +v4.2.1 normalised `tool_input.metadata` when the harness stringified it, but other sessions kept hitting "required metadata is missing or invalid" with well-formed TaskCreate calls. Direct shell invocation of the hook with `{"tool_input":{"metadata":{...}}}` passed, while the live harness still rejected — symptom of a different envelope shape we couldn't see, because auto-mode (correctly) refuses to let an agent self-modify a plugin hook to capture the live payload. + +**Fix.** Make `scripts/task-created-tag-check.sh` envelope-agnostic. Extraction now tries, in order: `tool_input.metadata` (object or JSON-string), `tool_input` (fields flattened directly), `.metadata` (no wrapper), the payload-as-string parsed once, then a deep search across the JSON tree for `user_story` / `layer` keys. Whatever shape the harness uses, valid metadata gets found. + +**Diagnostics.** When the hook does reject, it now writes the raw payload plus the extraction trace to `$TMPDIR/code-et-task-hook/last-rejected.json` and references that path in the error message. Next time a TaskCreate genuinely fails, the actual envelope is on disk — no wrapper script, no auto-mode prompt. + +Verified against twelve payload shapes (object, stringified, flattened, root-level, deeply nested, plus the original PRD/bug-lane matrix). + ## [4.2.1] - 2026-05-12 ### Fixed — TaskCreated hook rejects valid metadata when harness stringifies it diff --git a/code-et-implementer/.claude-plugin/plugin.json b/code-et-implementer/.claude-plugin/plugin.json index 885e78c..d3e35d6 100644 --- a/code-et-implementer/.claude-plugin/plugin.json +++ b/code-et-implementer/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code", - "version": "4.2.1", + "version": "4.2.3", "description": "Pure-Rust Clean Architecture workflow. Six commands: start, fix, plan, ship, review, install-ci. Always-latest deps, CI audit gate, anti-slop enforced.", "author": { "name": "Kennet Kusk" diff --git a/code-et-implementer/hooks/hooks.json b/code-et-implementer/hooks/hooks.json index 0d129ea..e6a4cf8 100644 --- a/code-et-implementer/hooks/hooks.json +++ b/code-et-implementer/hooks/hooks.json @@ -36,9 +36,9 @@ ] } ], - "TaskCreated": [ + "PreToolUse": [ { - "matcher": "", + "matcher": "TaskCreate", "hooks": [ { "type": "command", diff --git a/code-et-implementer/scripts/task-created-tag-check.sh b/code-et-implementer/scripts/task-created-tag-check.sh index 6403ecd..dd4f746 100755 --- a/code-et-implementer/scripts/task-created-tag-check.sh +++ b/code-et-implementer/scripts/task-created-tag-check.sh @@ -1,5 +1,14 @@ #!/usr/bin/env bash -# TaskCreated hook: enforce user_story tag on branches with an active PRD. +# PreToolUse(TaskCreate) hook: enforce user_story tag (and layer on Rust) +# on branches with an active PRD. Runs *before* the task is created so we +# reject the call instead of orphaning a malformed task. +# +# Pre-v4.2.3 this ran on the `TaskCreated` lifecycle event, which delivers +# a flat post-hoc payload (task_id, task_subject, task_description) with +# no `metadata` field — so well-formed calls were uniformly rejected. The +# `tool_input.metadata` payload only exists on the tool-call envelope +# (PermissionRequest / PreToolUse), not the lifecycle event. +# # Exit 0 = allow, exit 2 = block (per Claude Code hook contract). set -euo pipefail @@ -9,10 +18,46 @@ payload="$(cat)" prd="$("$here/resolve-prd.sh" 2>/dev/null || true)" -# metadata may arrive as an object OR as a JSON-encoded string (harness quirk). -md="$(printf '%s' "$payload" | jq -c '(.tool_input.metadata // {}) | if type=="string" then (fromjson? // {}) else . end' 2>/dev/null || echo '{}')" -tag="$(printf '%s' "$md" | jq -r '.user_story // ""' 2>/dev/null || echo '')" -layer="$(printf '%s' "$md" | jq -r '.layer // ""' 2>/dev/null || echo '')" +# Metadata location is harness-dependent. We try, in order: +# 1. tool_input.metadata (object or JSON-encoded string) +# 2. tool_input (fields may be flattened at top level of tool_input) +# 3. .metadata (no tool_input wrapper) +# 4. whole payload as string → parse → recurse +# 5. deep search for user_story / layer keys anywhere in the tree +# Whatever shape arrives, well-formed metadata gets extracted. +extract() { + # $1 = jq expr that produces the candidate metadata node + printf '%s' "$payload" | jq -c " + def norm(\$x): if (\$x|type)==\"string\" then (\$x|fromjson? // {}) elif (\$x|type)==\"object\" then \$x else {} end; + norm($1) + " 2>/dev/null +} + +read_field() { + # $1 = field name; tries each candidate metadata location in turn. + local field="$1" val="" + for expr in \ + '.tool_input.metadata' \ + '.tool_input' \ + '.metadata' \ + '(. | if type=="string" then (fromjson? // {}) else {} end)' \ + '(.tool_input | if type=="string" then (fromjson? // {}) else {} end)' + do + val="$(extract "$expr" | jq -r --arg f "$field" '.[$f] // empty' 2>/dev/null || echo '')" + [ -n "$val" ] && [ "$val" != "null" ] && { printf '%s' "$val"; return; } + done + # Last resort: deep search anywhere in the JSON tree. + val="$(printf '%s' "$payload" | jq -r --arg f "$field" ' + [.. | objects | select(has($f)) | .[$f]] + | map(select(type=="string" and length > 0)) + | first // empty + ' 2>/dev/null || echo '')" + [ "$val" = "null" ] && val="" + printf '%s' "$val" +} + +tag="$(read_field user_story)" +layer="$(read_field layer)" if [ -z "$prd" ]; then # Bug lane — anything goes @@ -45,6 +90,23 @@ if [ "$tag_ok" -eq 1 ] && [ "$layer_ok" -eq 1 ]; then exit 0 fi +# Diagnostic: dump the raw payload + what we extracted, so the failure mode is +# legible without needing to wrap the hook. Newest dump wins; previous is kept +# as .prev.json for one cycle. +debug_dir="${TMPDIR:-/tmp}/code-et-task-hook" +mkdir -p "$debug_dir" 2>/dev/null || true +if [ -f "$debug_dir/last-rejected.json" ]; then + mv -f "$debug_dir/last-rejected.json" "$debug_dir/last-rejected.prev.json" 2>/dev/null || true +fi +payload_json="$(printf '%s' "$payload" | jq -c . 2>/dev/null || printf '%s' "$payload" | jq -Rs .)" +{ + printf '{"ts":"%s","extracted":{"user_story":%s,"layer":%s},"payload":%s}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$(printf '%s' "$tag" | jq -Rs .)" \ + "$(printf '%s' "$layer" | jq -Rs .)" \ + "$payload_json" +} > "$debug_dir/last-rejected.json" 2>/dev/null || true + cat >&2 <&2 <