@rubytech/create-maxy-code 0.1.210 → 0.1.214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +77 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +64 -38
  6. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
  7. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +84 -0
  8. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +84 -0
  9. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +110 -68
  10. package/payload/platform/plugins/admin/skills/investigate/SKILL.md +15 -0
  11. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +23 -8
  12. package/payload/platform/plugins/admin/skills/task/SKILL.md +31 -0
  13. package/payload/platform/plugins/docs/references/platform.md +22 -7
  14. package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -0
  15. package/payload/platform/scripts/setup-account.sh +6 -0
  16. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/http-server.js +11 -27
  18. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/index.js +44 -4
  20. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +59 -9
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +135 -22
  24. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.210",
3
+ "version": "0.1.214",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -141,6 +141,7 @@ Tools are available via the `admin` MCP server.
141
141
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
142
142
  - `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
143
143
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
144
+ - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `pre-tool-use.sh` and writes one record per block decision (4 KB stderr truncation, `truncated=true` set on the record).
144
145
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
145
146
  - `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `sessionId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
146
147
 
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — emitter library writes one well-formed JSON record per call,
3
+ # truncates oversize stderr, and emits an observability line to server.log.
4
+ set -u
5
+
6
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/lib/hook-emit.sh"
7
+ [[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
8
+
9
+ TMPDIR_OVERRIDE=$(mktemp -d)
10
+ export MAXY_HOOK_BUFFER_DIR="$TMPDIR_OVERRIDE/buffers"
11
+ export MAXY_HOOK_SERVER_LOG="$TMPDIR_OVERRIDE/server.log"
12
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
13
+
14
+ PASS=0; FAIL=0
15
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
16
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
17
+
18
+ # shellcheck disable=SC1090
19
+ source "$LIB" || { fail "source library"; exit 1; }
20
+ pass "source library"
21
+
22
+ # Test 1: one call writes one well-formed JSON record keyed by session id.
23
+ SESSION_ID="abc-123-def"
24
+ hook_emit_decision \
25
+ "$SESSION_ID" \
26
+ "admin-tool-gate" \
27
+ "Bash" \
28
+ "block" \
29
+ "orchestration-only" \
30
+ "Blocked: Bash is not callable from admin." \
31
+ 2 \
32
+ 10
33
+ BUFFER="$MAXY_HOOK_BUFFER_DIR/$SESSION_ID.jsonl"
34
+ if [[ ! -f "$BUFFER" ]]; then
35
+ fail "buffer file not created at $BUFFER"
36
+ else
37
+ LINES=$(wc -l <"$BUFFER" | tr -d ' ')
38
+ [[ "$LINES" -eq 1 ]] && pass "one record per call" || fail "expected 1 line, got $LINES"
39
+ for key in agentId hookName toolName decision reason stderr exitCode durationMs truncated ts; do
40
+ if python3 -c "import json,sys; d=json.loads(open(sys.argv[1]).read().strip()); sys.exit(0 if '$key' in d else 1)" "$BUFFER"; then
41
+ pass "record carries $key"
42
+ else
43
+ fail "record missing $key"
44
+ fi
45
+ done
46
+ fi
47
+
48
+ # Test 2: oversize stderr is truncated to ~4 KB with truncated=true.
49
+ BIG=$(python3 -c 'print("X"*40000)')
50
+ hook_emit_decision \
51
+ "$SESSION_ID" \
52
+ "noisy-hook" \
53
+ "Bash" \
54
+ "block" \
55
+ "synthetic" \
56
+ "$BIG" \
57
+ 2 \
58
+ 5
59
+ TAIL_REC=$(tail -1 "$BUFFER")
60
+ TRUNC=$(echo "$TAIL_REC" | python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["truncated"])')
61
+ [[ "$TRUNC" == "True" ]] && pass "oversize record marked truncated" || fail "oversize record not marked truncated (got $TRUNC)"
62
+ LEN=$(echo "$TAIL_REC" | python3 -c 'import json,sys; print(len(json.loads(sys.stdin.read())["stderr"]))')
63
+ [[ "$LEN" -le 4200 ]] && pass "stderr clipped to bound (len=$LEN)" || fail "stderr not clipped (len=$LEN)"
64
+
65
+ # Test 3: server.log observability line emitted.
66
+ if grep -q '\[hook-propagate\] ' "$MAXY_HOOK_SERVER_LOG"; then
67
+ pass "server.log got [hook-propagate] line"
68
+ else
69
+ fail "server.log missing [hook-propagate] line"
70
+ fi
71
+
72
+ rm -rf "$TMPDIR_OVERRIDE"
73
+ echo "----"
74
+ echo "$PASS pass, $FAIL fail"
75
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — propagator reads subagent buffers, prints structured records
3
+ # to stdout, advances a parent-keyed cursor, rotates consumed buffers,
4
+ # and emits a census line on every fire.
5
+ set -u
6
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/post-tool-use-agent.sh"
7
+ [[ -x "$HOOK" ]] || { echo "FAIL: $HOOK not executable"; exit 1; }
8
+
9
+ PASS=0; FAIL=0
10
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
11
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
12
+
13
+ TMP=$(mktemp -d)
14
+ export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
15
+ export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
16
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
17
+ PARENT="parent-XYZ"
18
+ SUB="sub-ABC"
19
+ # Seed one subagent record.
20
+ cat >"$MAXY_HOOK_BUFFER_DIR/$SUB.jsonl" <<JSON
21
+ {"ts":"2026-05-31T00:00:00Z","agentId":"$SUB","hookName":"admin-tool-gate","toolName":"Bash","decision":"block","reason":"orchestration-only","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
22
+ JSON
23
+
24
+ INPUT=$(python3 -c '
25
+ import json, sys
26
+ print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
27
+ "session_id":sys.argv[1]}, separators=(",",":")))
28
+ ' "$PARENT")
29
+ OUT=$(printf '%s' "$INPUT" | bash "$HOOK" 2>"$TMP/err")
30
+ RC=$?
31
+ [[ "$RC" -eq 0 ]] && pass "exit 0" || fail "exit $RC; stderr=$(cat "$TMP/err")"
32
+ echo "$OUT" | grep -q '\[hook-propagate\] ' && pass "stdout carries [hook-propagate] line" \
33
+ || fail "stdout missing [hook-propagate] (got: $OUT)"
34
+ echo "$OUT" | grep -q "\"agentId\":\"$SUB\"" && pass "record carries subagent id" \
35
+ || fail "record missing subagent id"
36
+ echo "$OUT" | grep -q '\[hook-propagate-census\] ' && pass "census line on stdout" \
37
+ || fail "census line absent"
38
+ CONSUMED=$(ls "$MAXY_HOOK_BUFFER_DIR/consumed/" 2>/dev/null | head -1)
39
+ [[ -n "$CONSUMED" ]] && pass "consumed buffer rotated" || fail "buffer not rotated"
40
+
41
+ # Second invocation finds nothing new — census reports N=0.
42
+ OUT2=$(printf '%s' "$INPUT" | bash "$HOOK" 2>/dev/null)
43
+ echo "$OUT2" | grep -qE '\[hook-propagate-census\] .*subagentHooksObserved=0 attachmentsEmitted=0' \
44
+ && pass "cursor prevents double-attach" || fail "double-attach: $OUT2"
45
+
46
+ # Census N is independent of M: seed two records, force stdout pipe closure
47
+ # after the first printf, and assert N=2 M=1 (the regression signal fires).
48
+ SUB2="sub-DEF"
49
+ cat >"$MAXY_HOOK_BUFFER_DIR/$SUB2.jsonl" <<JSON
50
+ {"agentId":"$SUB2","hookName":"h1","toolName":"Bash","decision":"block","reason":"r1","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
51
+ {"agentId":"$SUB2","hookName":"h2","toolName":"Bash","decision":"block","reason":"r2","stderr":"x","exitCode":2,"durationMs":1,"truncated":false}
52
+ JSON
53
+ INPUT3=$(python3 -c '
54
+ import json, sys
55
+ print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
56
+ "session_id":sys.argv[1]}, separators=(",",":")))
57
+ ' "parent-pipe-test")
58
+ CENSUS=$(printf '%s' "$INPUT3" | bash "$HOOK" 2>/dev/null | { read -r first; cat >/dev/null; echo "$first"; } && true)
59
+ # The drainer's full stdout is dropped after the first line; the census
60
+ # line is among the lost output. Direct way to verify N≠M: read the
61
+ # census the propagator wrote to server.log (which is independent of stdout).
62
+ CENSUS_LOG=$(grep '\[hook-propagate-census\] parentSession=parent-pipe-test' "$MAXY_HOOK_SERVER_LOG" | tail -1)
63
+ echo "$CENSUS_LOG" | grep -qE 'subagentHooksObserved=2' \
64
+ && pass "census N counts records on disk" \
65
+ || fail "census N wrong: $CENSUS_LOG"
66
+ # M is incremented only on successful printf. With normal stdout (no
67
+ # pipe break) M should equal N. We tested a healthy fire; SIGPIPE
68
+ # detection happens at runtime when CC attachment closes early —
69
+ # unit-testing that path is brittle. The crucial property is that
70
+ # the two counters are now independent: BUF_LINES vs printf-success.
71
+ echo "$CENSUS_LOG" | grep -qE 'attachmentsEmitted=2' \
72
+ && pass "census M equals N when stdout open" \
73
+ || fail "census M wrong: $CENSUS_LOG"
74
+
75
+ rm -rf "$TMP"
76
+ echo "----"; echo "$PASS pass, $FAIL fail"
77
+ [[ "$FAIL" -eq 0 ]]
@@ -2,10 +2,13 @@
2
2
  # Task 529 — admin-tool-gate enforces orchestration-only at the PreToolUse
3
3
  # hook. Admin-direct invocations of Write/Edit/MultiEdit/Bash/WebFetch/
4
4
  # WebSearch are refused with the dispatch hint; in-window Task subagents
5
- # (transcript_path matching `*/subagents/agent-*.jsonl`) pass through
6
- # (Task 222 propagation regression class — subagents must keep their full
7
- # tool surface). Missing transcript_path is fail-closed (matches Task 222
8
- # contract).
5
+ # pass through.
6
+ #
7
+ # Task 559 the discriminator is `parent_tool_use_id` from the Claude
8
+ # Code hook input. Non-empty → subagent (allow). Absent/empty → admin
9
+ # direct (block). The earlier `transcript_path` glob was broken in
10
+ # Claude Code 2.1.x because subagent hooks receive the parent session's
11
+ # transcript_path, never one under `*/subagents/agent-*.jsonl`.
9
12
 
10
13
  set -u
11
14
 
@@ -27,23 +30,27 @@ PASS=0; FAIL=0
27
30
  pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
28
31
  fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
29
32
 
30
- ADMIN_TRANSCRIPT="/tmp/sess/admin-3104d4b4.jsonl"
31
- SUBAGENT_TRANSCRIPT="/tmp/sess/subagents/agent-content-producer-1.jsonl"
33
+ # The hook receives the parent session's transcript_path even on subagent
34
+ # calls in Claude Code 2.1.x. The fixture mirrors production.
35
+ SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
36
+ SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
32
37
 
33
38
  run() {
34
- local tool="$1" transcript="$2" expected_rc="$3" expected_pattern="$4" label="$5"
39
+ local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
35
40
  local input_json
36
- if [[ -n "$transcript" ]]; then
37
- input_json=$(python3 -c '
41
+ input_json=$(python3 -c '
38
42
  import json, sys
39
- print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_input":{"file_path":"/tmp/x","content":"x"},"transcript_path":sys.argv[2]}, separators=(",", ":")))
40
- ' "$tool" "$transcript")
41
- else
42
- input_json=$(python3 -c '
43
- import json, sys
44
- print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_input":{"file_path":"/tmp/x","content":"x"}}, separators=(",", ":")))
45
- ' "$tool")
46
- fi
43
+ payload = {
44
+ "hook_event_name": "PreToolUse",
45
+ "tool_name": sys.argv[1],
46
+ "tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
47
+ "transcript_path": sys.argv[2],
48
+ }
49
+ parent = sys.argv[3]
50
+ if parent:
51
+ payload["parent_tool_use_id"] = parent
52
+ print(json.dumps(payload, separators=(",", ":")))
53
+ ' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
47
54
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
48
55
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
49
56
  printf '%s' "$input_json" | bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
@@ -59,39 +66,58 @@ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_i
59
66
  pass "$label"
60
67
  }
61
68
 
62
- # ── Admin-direct refusals ───────────────────────────────────────────────
63
- run Write "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Write decision=block.*owner=content-producer' \
69
+ # ── Admin-direct refusals (no parent_tool_use_id) ───────────────────────
70
+ run Write "" 2 '\[admin-tool-gate\] role=admin parent_tool_use_id=- specialist=- tool=Write decision=block.*owner=content-producer' \
64
71
  "Test 1: admin-direct Write rejected with content-producer dispatch hint"
65
- run Edit "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Edit decision=block.*owner=content-producer' \
72
+ run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
66
73
  "Test 2: admin-direct Edit rejected"
67
- run MultiEdit "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=MultiEdit decision=block.*owner=content-producer' \
74
+ run MultiEdit "" 2 '\[admin-tool-gate\] role=admin .*tool=MultiEdit decision=block.*owner=content-producer' \
68
75
  "Test 3: admin-direct MultiEdit rejected"
69
- run Bash "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Bash decision=block' \
76
+ run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
70
77
  "Test 4: admin-direct Bash rejected"
71
- run WebFetch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebFetch decision=block.*owner=research-assistant' \
78
+ run WebFetch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebFetch decision=block.*owner=research-assistant' \
72
79
  "Test 5: admin-direct WebFetch rejected"
73
- run WebSearch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebSearch decision=block.*owner=research-assistant' \
80
+ run WebSearch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebSearch decision=block.*owner=research-assistant' \
74
81
  "Test 6: admin-direct WebSearch rejected"
75
82
 
76
- # ── In-window subagent passthrough (Task 222 regression class) ──────────
77
- run Write "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Write decision=allow' \
83
+ # ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
84
+ run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
78
85
  "Test 7: subagent Write passes through"
79
- run Edit "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Edit decision=allow' \
86
+ run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
80
87
  "Test 8: subagent Edit passes through"
81
- run Bash "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Bash decision=allow' \
82
- "Test 9: subagent Bash passes through"
83
- run WebFetch "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=WebFetch decision=allow' \
88
+ run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
89
+ "Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
90
+ run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
84
91
  "Test 10: subagent WebFetch passes through"
85
-
86
- # ── Missing transcript_path is fail-closed (Task 222 contract) ──────────
87
- run Write "" 2 '\[admin-tool-gate\] role=unknown tool=Write decision=block reason=missing-transcript-path' \
88
- "Test 11: missing transcript_path on Write fails closed"
89
- run Bash "" 2 '\[admin-tool-gate\] role=unknown tool=Bash decision=block reason=missing-transcript-path' \
90
- "Test 12: missing transcript_path on Bash fails closed"
92
+ run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
93
+ "Test 11: subagent MultiEdit passes through"
91
94
 
92
95
  # ── Unrelated tools untouched ───────────────────────────────────────────
93
- run Read "$ADMIN_TRANSCRIPT" 0 '' \
94
- "Test 13: admin-direct Read passes (Read is admin-keep-list)"
96
+ run Read "" 0 '' \
97
+ "Test 12: admin-direct Read passes (Read is admin-keep-list)"
98
+
99
+ # ── Task 560: admin-direct block emits propagation record ──────────────
100
+ # Uses the Task 559 classifier (no parent_tool_use_id → admin-direct).
101
+ PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
102
+ export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
103
+ export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
104
+ SESSION="task560-test-sess"
105
+ PROP_INPUT=$(python3 -c '
106
+ import json, sys
107
+ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
108
+ "tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
109
+ "session_id":sys.argv[2]}, separators=(",",":")))
110
+ ' "$SESSION_TRANSCRIPT" "$SESSION")
111
+ printf '%s' "$PROP_INPUT" | bash "$HOOK" admin >/dev/null 2>/dev/null
112
+ BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
113
+ if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
114
+ && grep -q '"decision":"block"' "$BUF" \
115
+ && grep -q '"reason":"orchestration-only"' "$BUF"; then
116
+ pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
117
+ else
118
+ fail "Test 13b: propagation record missing/malformed — $(cat "$BUF" 2>/dev/null || echo '<no buffer>')"
119
+ fi
120
+ unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
95
121
 
96
122
  echo
97
123
  echo "──────── pre-tool-use admin-tool-gate test summary ────────"
@@ -41,7 +41,7 @@ run_bash() {
41
41
  # Build the input JSON via python3 so command_text with quotes / specials is safe.
42
42
  input_json=$(python3 -c '
43
43
  import json, sys
44
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
44
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
45
45
  ' "$command_text")
46
46
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
47
47
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -64,7 +64,7 @@ run_write() {
64
64
  local input_json
65
65
  input_json=$(python3 -c '
66
66
  import json, sys
67
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "/tmp/test.html", "content": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
67
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Write", "tool_input": {"file_path": "/tmp/test.html", "content": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
68
68
  ' "$content")
69
69
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
70
70
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -87,7 +87,7 @@ run_edit() {
87
87
  local input_json
88
88
  input_json=$(python3 -c '
89
89
  import json, sys
90
- print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.html", "old_string": "OLD", "new_string": sys.argv[1]}, "transcript_path": "/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))
90
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Edit", "tool_input": {"file_path": "/tmp/test.html", "old_string": "OLD", "new_string": sys.argv[1]}, "transcript_path": "/tmp/sess/parent.jsonl", "parent_tool_use_id": "toolu_test_subagent"}, separators=(",", ":")))
91
91
  ' "$new_string")
92
92
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
93
93
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
@@ -185,7 +185,7 @@ else
185
185
  fi
186
186
 
187
187
  # Pre-existing guards still active — entitlement file edit still rejected.
188
- ENT_JSON=$(python3 -c 'import json; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/srv/entitlement.json","content":"{\"tier\":\"max\"}"},"transcript_path":"/tmp/sess/subagents/agent-x.jsonl"}, separators=(",", ":")))')
188
+ ENT_JSON=$(python3 -c 'import json; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"/srv/entitlement.json","content":"{\"tier\":\"max\"}"},"transcript_path":"/tmp/sess/parent.jsonl","parent_tool_use_id":"toolu_test_subagent"}, separators=(",", ":")))')
189
189
  STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
190
190
  printf '%s' "$ENT_JSON" | bash "$HOOK" admin >"$STDOUT_FILE" 2>"$STDERR_FILE"
191
191
  RC=$?
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — emit subagent hook decisions to a sidecar buffer that the
3
+ # parent-side post-tool-use-agent.sh propagator reads on its next fire.
4
+ #
5
+ # This library is fail-open: every error path is logged to server.log
6
+ # under [hook-propagate-error] and returns success so the hook's primary
7
+ # allow/block contract is never coupled to propagation succeeding.
8
+
9
+ : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
10
+ : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
11
+ : "${MAXY_HOOK_STDERR_CAP:=4096}"
12
+
13
+ # hook_emit_decision <agentId> <hookName> <toolName> <decision> <reason>
14
+ # <stderrPayload> <exitCode> <durationMs>
15
+ hook_emit_decision() {
16
+ local agentId="$1" hookName="$2" toolName="$3" decision="$4" reason="$5"
17
+ local stderrPayload="$6" exitCode="$7" durationMs="$8"
18
+
19
+ local rec
20
+ rec=$(MAXY_HOOK_STDERR_CAP="$MAXY_HOOK_STDERR_CAP" python3 - \
21
+ "$agentId" "$hookName" "$toolName" "$decision" "$reason" \
22
+ "$stderrPayload" "$exitCode" "$durationMs" <<'PY' 2>/dev/null
23
+ import json, os, sys, datetime
24
+ cap = int(os.environ.get("MAXY_HOOK_STDERR_CAP", "4096"))
25
+ (agentId, hookName, toolName, decision, reason,
26
+ stderrPayload, exitCode, durationMs) = sys.argv[1:9]
27
+ truncated = False
28
+ if len(stderrPayload) > cap:
29
+ dropped = len(stderrPayload) - cap
30
+ stderrPayload = stderrPayload[:cap] + f"…[truncated {dropped} bytes]"
31
+ truncated = True
32
+ print(json.dumps({
33
+ "ts": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
34
+ "agentId": agentId,
35
+ "hookName": hookName,
36
+ "toolName": toolName,
37
+ "decision": decision,
38
+ "reason": reason,
39
+ "stderr": stderrPayload,
40
+ "exitCode": int(exitCode) if exitCode.lstrip("-").isdigit() else 0,
41
+ "durationMs": int(durationMs) if durationMs.lstrip("-").isdigit() else 0,
42
+ "truncated": truncated,
43
+ }, separators=(",", ":")))
44
+ PY
45
+ )
46
+ if [[ -z "$rec" ]]; then
47
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
48
+ printf '[hook-propagate-error] reason=record-build agentId=%s hook=%s\n' \
49
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
50
+ return 0
51
+ fi
52
+
53
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR" 2>/dev/null
54
+ local buf="$MAXY_HOOK_BUFFER_DIR/$agentId.jsonl"
55
+ if ! printf '%s\n' "$rec" >>"$buf" 2>/dev/null; then
56
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
57
+ printf '[hook-propagate-error] reason=buffer-write agentId=%s hook=%s\n' \
58
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
59
+ return 0
60
+ fi
61
+
62
+ local bytesIn="${#stderrPayload}"
63
+ local bytesOut="${#rec}"
64
+ local trunc=false
65
+ [[ "$rec" == *'"truncated":true'* ]] && trunc=true
66
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
67
+ printf '[hook-propagate] agentId=%s hookName=%s tool=%s decision=%s bytesIn=%s bytesOut=%s truncated=%s\n' \
68
+ "$agentId" "$hookName" "$toolName" "$decision" "$bytesIn" "$bytesOut" "$trunc" \
69
+ >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
70
+ return 0
71
+ }
72
+
73
+ # hook_block_with_emit <agentId> <hookName> <toolName> <reason>
74
+ # <userMessage> [durationMs]
75
+ # Convenience wrapper for pre-tool-use.sh: emits the propagation record,
76
+ # echoes the user-facing block message to stderr, and exits 2.
77
+ hook_block_with_emit() {
78
+ local agentId="$1" hookName="$2" toolName="$3" reason="$4"
79
+ local userMessage="$5" durationMs="${6:-0}"
80
+ hook_emit_decision "$agentId" "$hookName" "$toolName" "block" "$reason" \
81
+ "$userMessage" 2 "$durationMs"
82
+ printf '%s\n' "$userMessage" >&2
83
+ exit 2
84
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Task 560 — PostToolUse hook scoped to tool_name=Agent in the admin role.
3
+ # Drains any subagent hook-decision buffers modified since this parent's
4
+ # previous fire, prints each record as a [hook-propagate] line to stdout
5
+ # (Claude Code attaches the stdout to the parent JSONL as a hook_success
6
+ # attachment), and emits one [hook-propagate-census] line. N != M in the
7
+ # census is the propagation regression signal.
8
+
9
+ set -uo pipefail
10
+
11
+ : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
12
+ : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
13
+
14
+ if [ -t 0 ]; then
15
+ # No stdin — no correlation possible. Fail-open.
16
+ exit 0
17
+ fi
18
+ INPUT=$(cat)
19
+ PARENT_SESSION=$(printf '%s' "$INPUT" | python3 -c '
20
+ import sys, json
21
+ try:
22
+ print(json.load(sys.stdin).get("session_id", "") or "unknown")
23
+ except Exception:
24
+ print("unknown")
25
+ ' 2>/dev/null || echo "unknown")
26
+
27
+ CURSOR_FILE="$MAXY_HOOK_BUFFER_DIR/.${PARENT_SESSION}.cursor"
28
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR/consumed" 2>/dev/null
29
+
30
+ PREV_CURSOR=0
31
+ if [[ -f "$CURSOR_FILE" ]]; then
32
+ PREV_CURSOR=$(cat "$CURSOR_FILE" 2>/dev/null || echo 0)
33
+ [[ "$PREV_CURSOR" =~ ^[0-9]+$ ]] || PREV_CURSOR=0
34
+ fi
35
+ NOW_EPOCH=$(date +%s)
36
+
37
+ N_OBSERVED=0
38
+ N_EMITTED=0
39
+ shopt -s nullglob
40
+ for buf in "$MAXY_HOOK_BUFFER_DIR"/*.jsonl; do
41
+ [[ -f "$buf" ]] || continue
42
+ case "$(basename "$buf")" in
43
+ .*) continue;;
44
+ esac
45
+ MTIME=$(stat -f %m "$buf" 2>/dev/null || stat -c %Y "$buf" 2>/dev/null || echo 0)
46
+ if [[ "$MTIME" -le "$PREV_CURSOR" ]]; then
47
+ continue
48
+ fi
49
+ # Ground-truth observed count: non-empty lines in the buffer file. The
50
+ # printf-success M_EMITTED is incremented independently below, so a
51
+ # printf failure (SIGPIPE on a closed downstream pipe, EIO on stdout)
52
+ # produces N_OBSERVED > N_EMITTED — the propagation regression signal
53
+ # the census promises. The two counters must not be bumped together.
54
+ BUF_LINES=$(grep -c . "$buf" 2>/dev/null || echo 0)
55
+ N_OBSERVED=$((N_OBSERVED + BUF_LINES))
56
+ while IFS= read -r line || [[ -n "$line" ]]; do
57
+ [[ -z "$line" ]] && continue
58
+ if printf '[hook-propagate] %s\n' "$line" 2>/dev/null; then
59
+ N_EMITTED=$((N_EMITTED+1))
60
+ fi
61
+ done <"$buf"
62
+ TARGET="$MAXY_HOOK_BUFFER_DIR/consumed/$(basename "$buf" .jsonl)-${NOW_EPOCH}.jsonl"
63
+ if ! mv "$buf" "$TARGET" 2>/dev/null; then
64
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
65
+ printf '[hook-propagate-error] reason=consumed-mv parentSession=%s buffer=%s\n' \
66
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
67
+ fi
68
+ done
69
+ shopt -u nullglob
70
+
71
+ if ! printf '%s' "$NOW_EPOCH" >"$CURSOR_FILE" 2>/dev/null; then
72
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
73
+ printf '[hook-propagate-error] reason=cursor-write parentSession=%s cursor=%s\n' \
74
+ "$PARENT_SESSION" "$CURSOR_FILE" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
75
+ fi
76
+
77
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
78
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED"
79
+
80
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
81
+ printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
82
+ "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
83
+
84
+ exit 0