Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion code-et-implementer/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions code-et-implementer/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
]
}
],
"TaskCreated": [
"PreToolUse": [
{
"matcher": "",
"matcher": "TaskCreate",
"hooks": [
{
"type": "command",
Expand Down
76 changes: 71 additions & 5 deletions code-et-implementer/scripts/task-created-tag-check.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 <<EOF
Task rejected: required metadata is missing or invalid.

Expand All @@ -58,4 +120,8 @@ metadata.layer — required on Rust projects: "domain" | "application" | "infras
See code-et-implementer/docs/architecture.md §"Layer model".
EOF
fi
cat >&2 <<EOF

Raw payload + extraction trace: $debug_dir/last-rejected.json
EOF
exit 2
54 changes: 54 additions & 0 deletions code-et-implementer/tests/task-created-tag-check.bats
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,57 @@ teardown() { rm -rf "$REPO"; }
run bash -c 'echo "{\"tool_input\":{\"metadata\":{\"user_story\":\"none\"}}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "tolerates stringified metadata when PRD exists" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"tool_input\":{\"metadata\":\"{\\\"user_story\\\":\\\"US-3\\\"}\"}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "tolerates flattened metadata fields on tool_input" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"tool_input\":{\"user_story\":\"US-3\",\"layer\":\"interface\"}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "tolerates metadata at payload root (no tool_input wrapper)" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"metadata\":{\"user_story\":\"AC-1.2\"}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "deep-searches for user_story in nested envelopes" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"params\":{\"input\":{\"task\":{\"metadata\":{\"user_story\":\"US-7\"}}}}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "accepts real PreToolUse(TaskCreate) envelope with metadata" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"session_id\":\"s1\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"TaskCreate\",\"tool_input\":{\"subject\":\"T1\",\"description\":\"d\",\"metadata\":{\"user_story\":\"US-1\",\"layer\":\"interface\"}}}" | "$0"' "$SCRIPT"
[ "$status" -eq 0 ]
}

@test "rejects real PreToolUse(TaskCreate) envelope without metadata" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
run bash -c 'echo "{\"session_id\":\"s1\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"TaskCreate\",\"tool_input\":{\"subject\":\"T1\",\"description\":\"d\"}}" | "$0"' "$SCRIPT"
[ "$status" -eq 2 ]
}

@test "rejection writes diagnostic dump to debug_dir" {
touch plans/2026-04-20-dark-mode.md
git checkout -q -b feature/dark-mode
export TMPDIR="$REPO/tmp"
mkdir -p "$TMPDIR"
run bash -c 'echo "{\"tool_input\":{\"metadata\":{\"user_story\":\"bogus\"}}}" | "$0"' "$SCRIPT"
[ "$status" -eq 2 ]
[ -f "$TMPDIR/code-et-task-hook/last-rejected.json" ]
run jq -r '.extracted.user_story' "$TMPDIR/code-et-task-hook/last-rejected.json"
[ "$output" = "bogus" ]
}
Loading