@rubytech/create-maxy-code 0.1.212 → 0.1.216

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 (67) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  3. package/payload/platform/plugins/admin/PLUGIN.md +5 -1
  4. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit.test.sh +75 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +173 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +107 -40
  7. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-base64-guard.test.sh +4 -4
  8. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +114 -0
  9. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +136 -0
  10. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +127 -68
  11. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts +2 -0
  12. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts.map +1 -0
  13. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js +130 -0
  14. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js.map +1 -0
  15. package/payload/platform/plugins/admin/mcp/dist/index.js +63 -0
  16. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  17. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts +14 -0
  18. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts.map +1 -0
  19. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js +50 -0
  20. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js.map +1 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +28 -0
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -0
  23. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +68 -0
  24. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -0
  25. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +31 -0
  26. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -14
  27. package/payload/platform/plugins/docs/references/platform.md +19 -9
  28. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  29. package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +89 -42
  30. package/payload/platform/scripts/check-roles-doc-completeness.mjs +96 -0
  31. package/payload/platform/scripts/check-specialist-tool-surface.mjs +1 -1
  32. package/payload/platform/scripts/setup-account.sh +6 -0
  33. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/http-server.js +77 -0
  38. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/index.js +30 -0
  40. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +18 -0
  42. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +63 -0
  44. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +5 -0
  46. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +29 -0
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts +80 -0
  50. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts.map +1 -0
  51. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js +306 -0
  52. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js.map +1 -0
  53. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +9 -0
  54. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
  55. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +6 -2
  56. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
  57. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
  58. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  59. package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +1 -0
  60. package/payload/server/public/assets/{admin-DMo5DWKH.js → admin-DjGGOx2K.js} +1 -1
  61. package/payload/server/public/assets/{data-D6IQdm0R.js → data-WBEvtRV-.js} +1 -1
  62. package/payload/server/public/assets/{graph-PkZKVkWD.js → graph-DsOLsIPf.js} +1 -1
  63. package/payload/server/public/data.html +2 -2
  64. package/payload/server/public/graph.html +2 -2
  65. package/payload/server/public/index.html +2 -2
  66. package/payload/server/server.js +6 -0
  67. package/payload/server/public/assets/AdminShell-BcxEZnpu.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.212",
3
+ "version": "0.1.216",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "admin",
3
- "description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform.",
3
+ "description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: admin
3
3
  surface: platform
4
- description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform."
4
+ description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform."
5
5
  tools:
6
6
  - name: system-status
7
7
  publicAllowlist: false
@@ -18,6 +18,9 @@ tools:
18
18
  - name: account-manage
19
19
  publicAllowlist: false
20
20
  adminAllowlist: false
21
+ - name: capabilities-here
22
+ publicAllowlist: false
23
+ adminAllowlist: false
21
24
  - name: account-update
22
25
  publicAllowlist: false
23
26
  adminAllowlist: false
@@ -141,6 +144,7 @@ Tools are available via the `admin` MCP server.
141
144
  - `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
145
  - `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
146
  - `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`.
147
+ - `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
148
  - `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
149
  - `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
150
 
@@ -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,173 @@
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
+ # Task 563 — concurrency hardening.
76
+
77
+ # Concurrent emitter + drainer on the same buffer must not drop records.
78
+ # Spawn an emitter that writes 50 records in a tight loop while two
79
+ # drainer fires race against it. Union of attachments must equal 50.
80
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/lib/hook-emit.sh"
81
+ [[ -f "$LIB" ]] || { fail "lib missing"; }
82
+ CONCUR_TMP=$(mktemp -d)
83
+ export MAXY_HOOK_BUFFER_DIR="$CONCUR_TMP/buffers"
84
+ export MAXY_HOOK_SERVER_LOG="$CONCUR_TMP/server.log"
85
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
86
+ PARENT_C="parent-CONC"
87
+ SUB_C="sub-CONC"
88
+ INPUT_C=$(python3 -c '
89
+ import json, sys
90
+ print(json.dumps({"hook_event_name":"PostToolUse","tool_name":"Agent",
91
+ "session_id":sys.argv[1]}, separators=(",",":")))
92
+ ' "$PARENT_C")
93
+
94
+ # Emitter: 50 records as fast as possible.
95
+ EMIT_OUT="$CONCUR_TMP/emit.log"
96
+ (
97
+ # shellcheck disable=SC1090
98
+ source "$LIB"
99
+ for i in $(seq 1 50); do
100
+ hook_emit_decision "$SUB_C" "h" "Bash" "block" "r$i" "x" 2 1
101
+ done
102
+ echo "done" >"$EMIT_OUT"
103
+ ) &
104
+ EMIT_PID=$!
105
+
106
+ # Two drainer fires racing the emitter.
107
+ DRAIN1="$CONCUR_TMP/drain1.out"
108
+ DRAIN2="$CONCUR_TMP/drain2.out"
109
+ (printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN1" 2>/dev/null) &
110
+ D1=$!
111
+ (printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN2" 2>/dev/null) &
112
+ D2=$!
113
+ wait "$EMIT_PID" "$D1" "$D2"
114
+
115
+ # Drain any leftovers after emitter+drainers settled.
116
+ DRAIN3="$CONCUR_TMP/drain3.out"
117
+ printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN3" 2>/dev/null
118
+ # A second sweep catches buffer files created after drainer 3 started.
119
+ DRAIN4="$CONCUR_TMP/drain4.out"
120
+ printf '%s' "$INPUT_C" | bash "$HOOK" >"$DRAIN4" 2>/dev/null
121
+
122
+ TOTAL_ATTACHED=$(cat "$DRAIN1" "$DRAIN2" "$DRAIN3" "$DRAIN4" | grep -c '^\[hook-propagate\] {' || true)
123
+ UNIQUE_R=$(cat "$DRAIN1" "$DRAIN2" "$DRAIN3" "$DRAIN4" | grep '^\[hook-propagate\] {' | \
124
+ python3 -c 'import json,sys
125
+ seen=set()
126
+ for line in sys.stdin:
127
+ rec=line.split(" ",1)[1]
128
+ d=json.loads(rec)
129
+ seen.add(d["reason"])
130
+ print(len(seen))')
131
+ [[ "$TOTAL_ATTACHED" -eq 50 ]] && pass "concurrent: 50 records attached, no duplicates" \
132
+ || fail "concurrent: expected 50 attached, got $TOTAL_ATTACHED"
133
+ [[ "$UNIQUE_R" -eq 50 ]] && pass "concurrent: 50 unique records, no drops" \
134
+ || fail "concurrent: expected 50 unique reasons, got $UNIQUE_R"
135
+
136
+ # No cursor file should exist (cursor mechanism removed).
137
+ [[ -z "$(ls "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null)" ]] \
138
+ && pass "cursor mechanism removed (no .cursor files)" \
139
+ || fail "cursor file unexpectedly present: $(ls "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null)"
140
+
141
+ rm -rf "$CONCUR_TMP"
142
+
143
+ # Unknown-parent fallback emits observability line and does not corrupt
144
+ # state across concurrent fires.
145
+ UNK_TMP=$(mktemp -d)
146
+ export MAXY_HOOK_BUFFER_DIR="$UNK_TMP/buffers"
147
+ export MAXY_HOOK_SERVER_LOG="$UNK_TMP/server.log"
148
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
149
+ # Stdin with no session_id at all.
150
+ BAD_INPUT='{"hook_event_name":"PostToolUse","tool_name":"Agent"}'
151
+ UNK1="$UNK_TMP/unk1.out"
152
+ UNK2="$UNK_TMP/unk2.out"
153
+ (printf '%s' "$BAD_INPUT" | bash "$HOOK" >"$UNK1" 2>/dev/null) &
154
+ U1=$!
155
+ (printf '%s' "$BAD_INPUT" | bash "$HOOK" >"$UNK2" 2>/dev/null) &
156
+ U2=$!
157
+ wait "$U1" "$U2"
158
+
159
+ grep -q 'parentSession=unknown reason=session-id-missing' "$UNK1" "$UNK2" \
160
+ && pass "unknown parent emits observability line" \
161
+ || fail "unknown parent missing observability line: $(cat "$UNK1" "$UNK2")"
162
+ grep -q 'parentSession=unknown reason=session-id-missing' "$MAXY_HOOK_SERVER_LOG" \
163
+ && pass "unknown parent line logged to server.log" \
164
+ || fail "server.log missing unknown-parent line"
165
+ # No .unknown.cursor file — cursor mechanism is gone.
166
+ [[ ! -e "$MAXY_HOOK_BUFFER_DIR/.unknown.cursor" ]] \
167
+ && pass "no shared .unknown.cursor file" \
168
+ || fail ".unknown.cursor unexpectedly present"
169
+
170
+ rm -rf "$UNK_TMP"
171
+
172
+ echo "----"; echo "$PASS pass, $FAIL fail"
173
+ [[ "$FAIL" -eq 0 ]]
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env bash
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
- # 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).
4
+ # WebSearch are refused with the dispatch hint; subagents pass through.
5
+ #
6
+ # Task 559 discriminator is `parent_tool_use_id` from the Claude Code
7
+ # hook input. Non-empty in-window Task subagent (allow).
8
+ #
9
+ # Task 566 — discriminator extended to `$MAXY_SPECIALIST` for maxy MCP
10
+ # `Agent`-spawned specialists (separate top-level `claude rc` processes
11
+ # with no `parent_tool_use_id` but `MAXY_SPECIALIST=<name>` on env, set
12
+ # by pty-spawner.ts:1601). Calls with neither marker AND no
13
+ # `MAXY_SESSION_ROLE=admin` fail closed with `reason=missing-marker` so a
14
+ # future spawn shape cannot silently regress.
9
15
 
10
16
  set -u
11
17
 
@@ -27,26 +33,40 @@ PASS=0; FAIL=0
27
33
  pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
28
34
  fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
29
35
 
30
- ADMIN_TRANSCRIPT="/tmp/sess/admin-3104d4b4.jsonl"
31
- SUBAGENT_TRANSCRIPT="/tmp/sess/subagents/agent-content-producer-1.jsonl"
36
+ # The hook receives the parent session's transcript_path even on subagent
37
+ # calls in Claude Code 2.1.x. The fixture mirrors production.
38
+ SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
39
+ SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
32
40
 
41
+ # run <tool> <parent_id> <expected_rc> <expected_pattern> <label>
42
+ # Env knobs (default mirror production admin spawn):
43
+ # MAXY_SESSION_ROLE_OVERRIDE — defaults to "admin"; set empty to drop the role
44
+ # MAXY_SPECIALIST_OVERRIDE — defaults to empty; set to a name to simulate
45
+ # an MCP-Agent-spawned specialist
33
46
  run() {
34
- local tool="$1" transcript="$2" expected_rc="$3" expected_pattern="$4" label="$5"
47
+ local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
48
+ local role="${MAXY_SESSION_ROLE_OVERRIDE-admin}"
49
+ local specialist="${MAXY_SPECIALIST_OVERRIDE-}"
35
50
  local input_json
36
- if [[ -n "$transcript" ]]; then
37
- input_json=$(python3 -c '
51
+ input_json=$(python3 -c '
38
52
  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
53
+ payload = {
54
+ "hook_event_name": "PreToolUse",
55
+ "tool_name": sys.argv[1],
56
+ "tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
57
+ "transcript_path": sys.argv[2],
58
+ }
59
+ parent = sys.argv[3]
60
+ if parent:
61
+ payload["parent_tool_use_id"] = parent
62
+ print(json.dumps(payload, separators=(",", ":")))
63
+ ' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
47
64
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
48
65
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
49
- printf '%s' "$input_json" | bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
66
+ printf '%s' "$input_json" | \
67
+ env -u MAXY_SESSION_ROLE -u MAXY_SPECIALIST \
68
+ MAXY_SESSION_ROLE="$role" MAXY_SPECIALIST="$specialist" \
69
+ bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
50
70
  local rc=$?
51
71
  if [[ "$rc" -ne "$expected_rc" ]]; then
52
72
  fail "$label: expected exit $expected_rc, got $rc. Stderr: $(cat "$stderr_file")"
@@ -59,39 +79,86 @@ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":sys.argv[1],"tool_i
59
79
  pass "$label"
60
80
  }
61
81
 
62
- # ── Admin-direct refusals ───────────────────────────────────────────────
63
- run Write "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Write decision=block.*owner=content-producer' \
82
+ # ── Admin-direct refusals (no parent_tool_use_id, no MAXY_SPECIALIST, role=admin) ───
83
+ run Write "" 2 '\[admin-tool-gate\] role=admin parent_tool_use_id=- specialist=- session_role=admin tool=Write decision=block.*owner=content-producer' \
64
84
  "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' \
85
+ run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
66
86
  "Test 2: admin-direct Edit rejected"
67
- run MultiEdit "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=MultiEdit decision=block.*owner=content-producer' \
87
+ run MultiEdit "" 2 '\[admin-tool-gate\] role=admin .*tool=MultiEdit decision=block.*owner=content-producer' \
68
88
  "Test 3: admin-direct MultiEdit rejected"
69
- run Bash "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=Bash decision=block' \
89
+ run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
70
90
  "Test 4: admin-direct Bash rejected"
71
- run WebFetch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebFetch decision=block.*owner=research-assistant' \
91
+ run WebFetch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebFetch decision=block.*owner=research-assistant' \
72
92
  "Test 5: admin-direct WebFetch rejected"
73
- run WebSearch "$ADMIN_TRANSCRIPT" 2 '\[admin-tool-gate\] role=admin tool=WebSearch decision=block.*owner=research-assistant' \
93
+ run WebSearch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebSearch decision=block.*owner=research-assistant' \
74
94
  "Test 6: admin-direct WebSearch rejected"
75
95
 
76
- # ── In-window subagent passthrough (Task 222 regression class) ──────────
77
- run Write "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Write decision=allow' \
96
+ # ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
97
+ run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
78
98
  "Test 7: subagent Write passes through"
79
- run Edit "$SUBAGENT_TRANSCRIPT" 0 '\[admin-tool-gate\] role=subagent tool=Edit decision=allow' \
99
+ run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
80
100
  "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' \
101
+ run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
102
+ "Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
103
+ run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
84
104
  "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"
105
+ run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
106
+ "Test 11: subagent MultiEdit passes through"
91
107
 
92
108
  # ── Unrelated tools untouched ───────────────────────────────────────────
93
- run Read "$ADMIN_TRANSCRIPT" 0 '' \
94
- "Test 13: admin-direct Read passes (Read is admin-keep-list)"
109
+ run Read "" 0 '' \
110
+ "Test 12: admin-direct Read passes (Read is admin-keep-list)"
111
+
112
+ # ── Task 560: admin-direct block emits propagation record ──────────────
113
+ # Admin-direct path: MAXY_SESSION_ROLE=admin, no MAXY_SPECIALIST, no parent.
114
+ PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
115
+ export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
116
+ export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
117
+ SESSION="task560-test-sess"
118
+ PROP_INPUT=$(python3 -c '
119
+ import json, sys
120
+ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
121
+ "tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
122
+ "session_id":sys.argv[2]}, separators=(",",":")))
123
+ ' "$SESSION_TRANSCRIPT" "$SESSION")
124
+ printf '%s' "$PROP_INPUT" | \
125
+ env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
126
+ bash "$HOOK" admin >/dev/null 2>/dev/null
127
+ BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
128
+ if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
129
+ && grep -q '"decision":"block"' "$BUF" \
130
+ && grep -q '"reason":"orchestration-only"' "$BUF"; then
131
+ pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
132
+ else
133
+ fail "Test 13b: propagation record missing/malformed — $(cat "$BUF" 2>/dev/null || echo '<no buffer>')"
134
+ fi
135
+ unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
136
+
137
+ # ── Task 566: maxy-MCP-Agent-spawned specialist passthrough ────────────
138
+ # MCP-Agent specialists are separate top-level claude rc processes with no
139
+ # parent_tool_use_id; pty-spawner stamps MAXY_SPECIALIST=<name> on the env.
140
+ MAXY_SPECIALIST_OVERRIDE=content-producer \
141
+ run Edit "" 0 '\[admin-tool-gate\] role=subagent .*specialist=content-producer .*tool=Edit decision=allow source=mcp-specialist' \
142
+ "Test 14: mcp-specialist Edit passes through (Task 566)"
143
+
144
+ MAXY_SPECIALIST_OVERRIDE=coding-assistant \
145
+ run Bash "" 0 '\[admin-tool-gate\] role=subagent .*specialist=coding-assistant .*tool=Bash decision=allow source=mcp-specialist' \
146
+ "Test 15: mcp-specialist Bash passes through (Task 559 regression class)"
147
+
148
+ MAXY_SPECIALIST_OVERRIDE=research-assistant \
149
+ run WebFetch "" 0 '\[admin-tool-gate\] role=subagent .*specialist=research-assistant .*tool=WebFetch decision=allow source=mcp-specialist' \
150
+ "Test 16: mcp-specialist WebFetch passes through"
151
+
152
+ # ── Task 566: missing-marker fail-closed ───────────────────────────────
153
+ # No parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE not 'admin'.
154
+ MAXY_SESSION_ROLE_OVERRIDE="" MAXY_SPECIALIST_OVERRIDE="" \
155
+ run Edit "" 2 '\[admin-tool-gate\] role=unknown .*tool=Edit decision=block reason=missing-marker' \
156
+ "Test 17: missing-marker fail-closed (no positive role marker)"
157
+
158
+ # ── Task 566: combined parent_tool_use_id + MAXY_SPECIALIST is also subagent ──
159
+ MAXY_SPECIALIST_OVERRIDE=database-operator \
160
+ run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*specialist=database-operator .*tool=Bash decision=allow source=task-subagent\+mcp-specialist' \
161
+ "Test 18: combined parent + specialist resolves as task-subagent+mcp-specialist"
95
162
 
96
163
  echo
97
164
  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=$?