@rubytech/create-maxy-code 0.1.214 → 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 (64) 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 +4 -1
  4. package/payload/platform/plugins/admin/hooks/__tests__/post-tool-use-agent.test.sh +97 -1
  5. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +53 -12
  6. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +30 -0
  7. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +81 -29
  8. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +34 -17
  9. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts +2 -0
  10. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.d.ts.map +1 -0
  11. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js +130 -0
  12. package/payload/platform/plugins/admin/mcp/dist/__tests__/capabilities-here.test.js.map +1 -0
  13. package/payload/platform/plugins/admin/mcp/dist/index.js +63 -0
  14. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts +14 -0
  16. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.d.ts.map +1 -0
  17. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js +50 -0
  18. package/payload/platform/plugins/admin/mcp/dist/lib/catalogue-census.js.map +1 -0
  19. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +28 -0
  20. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +68 -0
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -0
  23. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +31 -0
  24. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -14
  25. package/payload/platform/plugins/docs/references/platform.md +19 -9
  26. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  27. package/payload/platform/scripts/check-architecture-skill-no-drift.mjs +89 -42
  28. package/payload/platform/scripts/check-roles-doc-completeness.mjs +96 -0
  29. package/payload/platform/scripts/check-specialist-tool-surface.mjs +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  32. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/http-server.js +77 -0
  35. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/index.js +30 -0
  37. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +18 -0
  39. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  40. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +63 -0
  41. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +5 -0
  43. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +29 -0
  45. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts +80 -0
  47. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.d.ts.map +1 -0
  48. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js +306 -0
  49. package/payload/platform/services/claude-session-manager/dist/rc-script-spawn.js.map +1 -0
  50. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +9 -0
  51. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +6 -2
  53. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
  54. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
  55. package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
  56. package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +1 -0
  57. package/payload/server/public/assets/{admin-DMo5DWKH.js → admin-DjGGOx2K.js} +1 -1
  58. package/payload/server/public/assets/{data-D6IQdm0R.js → data-WBEvtRV-.js} +1 -1
  59. package/payload/server/public/assets/{graph-PkZKVkWD.js → graph-DsOLsIPf.js} +1 -1
  60. package/payload/server/public/data.html +2 -2
  61. package/payload/server/public/graph.html +2 -2
  62. package/payload/server/public/index.html +2 -2
  63. package/payload/server/server.js +6 -0
  64. 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.214",
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
@@ -72,6 +72,102 @@ echo "$CENSUS_LOG" | grep -qE 'attachmentsEmitted=2' \
72
72
  && pass "census M equals N when stdout open" \
73
73
  || fail "census M wrong: $CENSUS_LOG"
74
74
 
75
- rm -rf "$TMP"
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
+
76
172
  echo "----"; echo "$PASS pass, $FAIL fail"
77
173
  [[ "$FAIL" -eq 0 ]]
@@ -1,14 +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
- # pass through.
4
+ # WebSearch are refused with the dispatch hint; subagents pass through.
6
5
  #
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`.
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.
12
15
 
13
16
  set -u
14
17
 
@@ -35,8 +38,15 @@ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
35
38
  SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
36
39
  SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
37
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
38
46
  run() {
39
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-}"
40
50
  local input_json
41
51
  input_json=$(python3 -c '
42
52
  import json, sys
@@ -53,7 +63,10 @@ print(json.dumps(payload, separators=(",", ":")))
53
63
  ' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
54
64
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
55
65
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
56
- 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"
57
70
  local rc=$?
58
71
  if [[ "$rc" -ne "$expected_rc" ]]; then
59
72
  fail "$label: expected exit $expected_rc, got $rc. Stderr: $(cat "$stderr_file")"
@@ -66,8 +79,8 @@ print(json.dumps(payload, separators=(",", ":")))
66
79
  pass "$label"
67
80
  }
68
81
 
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' \
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' \
71
84
  "Test 1: admin-direct Write rejected with content-producer dispatch hint"
72
85
  run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
73
86
  "Test 2: admin-direct Edit rejected"
@@ -97,7 +110,7 @@ run Read "" 0 '' \
97
110
  "Test 12: admin-direct Read passes (Read is admin-keep-list)"
98
111
 
99
112
  # ── Task 560: admin-direct block emits propagation record ──────────────
100
- # Uses the Task 559 classifier (no parent_tool_use_id → admin-direct).
113
+ # Admin-direct path: MAXY_SESSION_ROLE=admin, no MAXY_SPECIALIST, no parent.
101
114
  PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
102
115
  export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
103
116
  export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
@@ -108,7 +121,9 @@ print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
108
121
  "tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
109
122
  "session_id":sys.argv[2]}, separators=(",",":")))
110
123
  ' "$SESSION_TRANSCRIPT" "$SESSION")
111
- printf '%s' "$PROP_INPUT" | bash "$HOOK" admin >/dev/null 2>/dev/null
124
+ printf '%s' "$PROP_INPUT" | \
125
+ env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
126
+ bash "$HOOK" admin >/dev/null 2>/dev/null
112
127
  BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
113
128
  if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
114
129
  && grep -q '"decision":"block"' "$BUF" \
@@ -119,6 +134,32 @@ else
119
134
  fi
120
135
  unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
121
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"
162
+
122
163
  echo
123
164
  echo "──────── pre-tool-use admin-tool-gate test summary ────────"
124
165
  echo "PASS: $PASS"
@@ -9,6 +9,27 @@
9
9
  : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
10
10
  : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
11
11
  : "${MAXY_HOOK_STDERR_CAP:=4096}"
12
+ # Task 563 — mkdir-based mutex serialises emitter append vs drainer rename.
13
+ # 100 attempts × 10ms = ~1s ceiling. Portable across Linux (Pi) and macOS
14
+ # (tests) without flock(1).
15
+ : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
16
+ : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
17
+
18
+ # _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
19
+ _hook_acquire_lock() {
20
+ local lockdir="$1" i=0
21
+ while ! mkdir "$lockdir" 2>/dev/null; do
22
+ i=$((i+1))
23
+ [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
24
+ sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
25
+ done
26
+ return 0
27
+ }
28
+
29
+ # _hook_release_lock <lockdir>
30
+ _hook_release_lock() {
31
+ rmdir "$1" 2>/dev/null || true
32
+ }
12
33
 
13
34
  # hook_emit_decision <agentId> <hookName> <toolName> <decision> <reason>
14
35
  # <stderrPayload> <exitCode> <durationMs>
@@ -52,12 +73,21 @@ PY
52
73
 
53
74
  mkdir -p "$MAXY_HOOK_BUFFER_DIR" 2>/dev/null
54
75
  local buf="$MAXY_HOOK_BUFFER_DIR/$agentId.jsonl"
76
+ local lock="${buf}.lock"
77
+ if ! _hook_acquire_lock "$lock"; then
78
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
79
+ printf '[hook-propagate-error] reason=lock-timeout agentId=%s hook=%s\n' \
80
+ "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
81
+ return 0
82
+ fi
55
83
  if ! printf '%s\n' "$rec" >>"$buf" 2>/dev/null; then
84
+ _hook_release_lock "$lock"
56
85
  mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
57
86
  printf '[hook-propagate-error] reason=buffer-write agentId=%s hook=%s\n' \
58
87
  "$agentId" "$hookName" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null
59
88
  return 0
60
89
  fi
90
+ _hook_release_lock "$lock"
61
91
 
62
92
  local bytesIn="${#stderrPayload}"
63
93
  local bytesOut="${#rec}"
@@ -1,15 +1,33 @@
1
1
  #!/usr/bin/env bash
2
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.
3
+ # Drains every subagent hook-decision buffer in the dir, prints each record
4
+ # as a [hook-propagate] line to stdout (Claude Code attaches the stdout to
5
+ # the parent JSONL as a hook_success attachment), and emits one
6
+ # [hook-propagate-census] line. N != M in the census is the propagation
7
+ # regression signal.
8
+ #
9
+ # Task 563 — concurrency hardening:
10
+ # - Atomic claim before read. Each buffer is renamed under a mkdir mutex
11
+ # to a per-fire `claimed/<basename>-<epoch>-<pid>.jsonl` path. The lock
12
+ # is held only for the rename syscall; reading happens against the
13
+ # renamed inode, which no emitter can address. The race window between
14
+ # the drainer's read and the buffer rotation is closed.
15
+ # - No cursor. The cursor file was load-bearing only because consumed
16
+ # buffers could be re-seen; with atomic claim the file's presence in
17
+ # the directory is the unambiguous signal. Dropping the cursor closes
18
+ # two further races (concurrent-drainer cursor write, shared
19
+ # `.unknown.cursor` across broken-stdin fires).
20
+ # - Unknown-parent observability. When stdin's `session_id` is missing
21
+ # or unparseable, an explicit `[hook-propagate] parentSession=unknown
22
+ # reason=session-id-missing` line is emitted to both stdout and
23
+ # server.log so the broken-stdin path is no longer silent.
8
24
 
9
25
  set -uo pipefail
10
26
 
11
27
  : "${MAXY_HOOK_BUFFER_DIR:=$HOME/.maxy-code/logs/hook-decisions}"
12
28
  : "${MAXY_HOOK_SERVER_LOG:=$HOME/.maxy-code/logs/server.log}"
29
+ : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
30
+ : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
13
31
 
14
32
  if [ -t 0 ]; then
15
33
  # No stdin — no correlation possible. Fail-open.
@@ -19,61 +37,95 @@ INPUT=$(cat)
19
37
  PARENT_SESSION=$(printf '%s' "$INPUT" | python3 -c '
20
38
  import sys, json
21
39
  try:
22
- print(json.load(sys.stdin).get("session_id", "") or "unknown")
40
+ sid = json.load(sys.stdin).get("session_id") or ""
41
+ print(sid if sid else "unknown")
23
42
  except Exception:
24
43
  print("unknown")
25
44
  ' 2>/dev/null || echo "unknown")
26
45
 
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
46
+ if [[ "$PARENT_SESSION" == "unknown" ]]; then
47
+ # Conservative play (Task 563 brief option C): emit observability and
48
+ # exit without draining. A broken-stdin fire has no business processing
49
+ # buffers — the next valid parent fire will pick them up.
50
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
51
+ printf '[hook-propagate] parentSession=unknown reason=session-id-missing\n'
52
+ printf '[hook-propagate] parentSession=unknown reason=session-id-missing\n' \
53
+ >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
54
+ exit 0
34
55
  fi
35
- NOW_EPOCH=$(date +%s)
36
56
 
57
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR/claimed" "$MAXY_HOOK_BUFFER_DIR/consumed" 2>/dev/null
58
+
59
+ # Sweep orphaned `.<session>.cursor` files left behind by an older
60
+ # version of this script. The cursor mechanism is gone; without this
61
+ # they would persist forever as dir noise.
62
+ rm -f "$MAXY_HOOK_BUFFER_DIR"/.*.cursor 2>/dev/null || true
63
+
64
+ NOW_EPOCH=$(date +%s)
37
65
  N_OBSERVED=0
38
66
  N_EMITTED=0
67
+
68
+ _drain_acquire_lock() {
69
+ local lockdir="$1" i=0
70
+ while ! mkdir "$lockdir" 2>/dev/null; do
71
+ i=$((i+1))
72
+ [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
73
+ sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true
74
+ done
75
+ return 0
76
+ }
77
+
39
78
  shopt -s nullglob
40
79
  for buf in "$MAXY_HOOK_BUFFER_DIR"/*.jsonl; do
41
80
  [[ -f "$buf" ]] || continue
42
81
  case "$(basename "$buf")" in
43
82
  .*) continue;;
44
83
  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
84
+ lock="${buf}.lock"
85
+ if ! _drain_acquire_lock "$lock"; then
86
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
87
+ printf '[hook-propagate-error] reason=lock-timeout parentSession=%s buffer=%s\n' \
88
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
47
89
  continue
48
90
  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
91
+ # Re-check existence under lock a peer drainer may have claimed it
92
+ # between our `for` iteration and our lock acquisition.
93
+ if [[ ! -f "$buf" ]]; then
94
+ rmdir "$lock" 2>/dev/null || true
95
+ continue
96
+ fi
97
+ CLAIMED="$MAXY_HOOK_BUFFER_DIR/claimed/$(basename "$buf" .jsonl)-${NOW_EPOCH}-$$.jsonl"
98
+ if ! mv "$buf" "$CLAIMED" 2>/dev/null; then
99
+ rmdir "$lock" 2>/dev/null || true
100
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
101
+ printf '[hook-propagate-error] reason=claim-mv parentSession=%s buffer=%s\n' \
102
+ "$PARENT_SESSION" "$(basename "$buf")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
103
+ continue
104
+ fi
105
+ rmdir "$lock" 2>/dev/null || true
106
+
107
+ # Ground-truth observed count: non-empty lines in the claimed file. The
108
+ # printf-success N_EMITTED is incremented independently below, so a
51
109
  # printf failure (SIGPIPE on a closed downstream pipe, EIO on stdout)
52
110
  # produces N_OBSERVED > N_EMITTED — the propagation regression signal
53
111
  # the census promises. The two counters must not be bumped together.
54
- BUF_LINES=$(grep -c . "$buf" 2>/dev/null || echo 0)
112
+ BUF_LINES=$(grep -c . "$CLAIMED" 2>/dev/null || echo 0)
55
113
  N_OBSERVED=$((N_OBSERVED + BUF_LINES))
56
114
  while IFS= read -r line || [[ -n "$line" ]]; do
57
115
  [[ -z "$line" ]] && continue
58
116
  if printf '[hook-propagate] %s\n' "$line" 2>/dev/null; then
59
117
  N_EMITTED=$((N_EMITTED+1))
60
118
  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
119
+ done <"$CLAIMED"
120
+ TARGET="$MAXY_HOOK_BUFFER_DIR/consumed/$(basename "$CLAIMED")"
121
+ if ! mv "$CLAIMED" "$TARGET" 2>/dev/null; then
64
122
  mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
65
123
  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
124
+ "$PARENT_SESSION" "$(basename "$CLAIMED")" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
67
125
  fi
68
126
  done
69
127
  shopt -u nullglob
70
128
 
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
129
  printf '[hook-propagate-census] parentSession=%s subagentHooksObserved=%d attachmentsEmitted=%d\n' \
78
130
  "$PARENT_SESSION" "$N_OBSERVED" "$N_EMITTED"
79
131
 
@@ -43,24 +43,25 @@ except Exception:
43
43
  # ---------------------------------------------------------------------------
44
44
  if [ "$AGENT_TYPE" = "admin" ]; then
45
45
 
46
- # ── Orchestration-only authoring + research block (Task 529, 559) ────────
46
+ # ── Orchestration-only authoring + research block (Task 529, 559, 566) ───
47
47
  # The admin seat is orchestration + clarification + delivery only.
48
48
  # Write/Edit/MultiEdit/Bash/WebFetch/WebSearch are specialist-owned —
49
49
  # content-producer for authoring, research-assistant for research, the
50
50
  # shell tools for whichever specialist owns the workflow that needs them.
51
51
  # In-window Task subagents must keep these tools (Task 222 regression
52
- # class).
52
+ # class). Maxy MCP `Agent`-spawned specialists are separate top-level
53
+ # `claude rc` processes — they have no `parent_tool_use_id` but pty-spawner
54
+ # stamps `MAXY_SPECIALIST=<name>` on the child env (pty-spawner.ts:1601).
53
55
  #
54
- # Discriminator (Task 559): Claude Code passes `parent_tool_use_id` in
55
- # the hook input JSON when the call originates from a subagent (the id
56
- # of the parent's Task tool_use). Admin-direct calls have it absent or
57
- # empty. Earlier versions used the `transcript_path` glob, but Claude
58
- # Code 2.1.x passes the parent session's transcript_path to subagent
59
- # hooks, so the glob never matched and every specialist subagent Bash
60
- # was blocked with the admin orchestration-only wording.
56
+ # Discriminator (union of positive markers, never path-shape inference):
57
+ # parent_tool_use_id non-empty → in-window Task subagent (allow)
58
+ # $MAXY_SPECIALIST non-empty → maxy MCP Agent specialist (allow)
59
+ # $MAXY_SESSION_ROLE = admin → admin-direct (block, orchestration-only)
60
+ # none of the above → fail closed (missing-marker)
61
61
  #
62
- # Admin direct (no parent_tool_use_id) exit 2 with dispatch hint.
63
- # Subagent (parent_tool_use_id non-empty) exit 0 (passthrough).
62
+ # Adding a future spawn shape that lands neither marker surfaces as a loud
63
+ # `reason=missing-marker` block, not a silent passthrough or a silent
64
+ # admin-direct block on legitimate specialist work.
64
65
  case "$TOOL_NAME" in
65
66
  Write|Edit|MultiEdit|Bash|WebFetch|WebSearch)
66
67
  PARENT_TOOL_USE_ID=$(echo "$INPUT" | python3 -c '
@@ -72,11 +73,18 @@ except Exception:
72
73
  print("")
73
74
  ' 2>/dev/null)
74
75
  SPECIALIST="${MAXY_SPECIALIST:-}"
75
- if [ -n "$PARENT_TOOL_USE_ID" ]; then
76
- # In-window subagent passthrough. Tool name resolves on the
77
- # subagent's own frontmatter `tools:` allowlist downstream.
78
- echo "[admin-tool-gate] role=subagent parent_tool_use_id=${PARENT_TOOL_USE_ID} specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=allow source=hook-passthrough" >&2
79
- else
76
+ SESSION_ROLE="${MAXY_SESSION_ROLE:-}"
77
+ if [ -n "$PARENT_TOOL_USE_ID" ] || [ -n "$SPECIALIST" ]; then
78
+ # Subagent passthrough. Tool name resolves on the subagent's
79
+ # own frontmatter `tools:` allowlist downstream.
80
+ SOURCE="task-subagent"
81
+ if [ -n "$SPECIALIST" ] && [ -n "$PARENT_TOOL_USE_ID" ]; then
82
+ SOURCE="task-subagent+mcp-specialist"
83
+ elif [ -n "$SPECIALIST" ]; then
84
+ SOURCE="mcp-specialist"
85
+ fi
86
+ echo "[admin-tool-gate] role=subagent parent_tool_use_id=${PARENT_TOOL_USE_ID:--} specialist=${SPECIALIST:--} session_role=${SESSION_ROLE:--} tool=${TOOL_NAME} decision=allow source=${SOURCE}" >&2
87
+ elif [ "$SESSION_ROLE" = "admin" ]; then
80
88
  # Admin direct invocation — refuse with dispatch hint.
81
89
  case "$TOOL_NAME" in
82
90
  Write|Edit|MultiEdit)
@@ -89,11 +97,20 @@ except Exception:
89
97
  OWNER="research-assistant"
90
98
  ;;
91
99
  esac
92
- echo "[admin-tool-gate] role=admin parent_tool_use_id=- specialist=${SPECIALIST:--} tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
100
+ echo "[admin-tool-gate] role=admin parent_tool_use_id=- specialist=- session_role=admin tool=${TOOL_NAME} decision=block reason=orchestration-only owner=${OWNER}" >&2
93
101
  hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
94
102
  "orchestration-only" \
95
103
  "Blocked: \`${TOOL_NAME}\` is not callable from the admin seat. The admin seat is orchestration + clarification + delivery only — authoring, shell work, and web research are owned by specialists.
96
104
  Dispatch the work to ${OWNER} via the Agent tool."
105
+ else
106
+ # No positive marker — fail closed. A new spawn shape that lands
107
+ # neither parent_tool_use_id nor MAXY_SPECIALIST and is not the
108
+ # admin seat should surface visibly, not silently pass or silently
109
+ # block under the wrong reason.
110
+ echo "[admin-tool-gate] role=unknown parent_tool_use_id=- specialist=- session_role=${SESSION_ROLE:--} tool=${TOOL_NAME} decision=block reason=missing-marker" >&2
111
+ hook_block_with_emit "$SESSION_ID" "admin-tool-gate" "${TOOL_NAME}" \
112
+ "missing-marker" \
113
+ "Blocked: \`${TOOL_NAME}\` call carries no positive role marker (no parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE is not 'admin'). Failing closed so a new spawn shape cannot silently bypass orchestration gating."
97
114
  fi
98
115
  ;;
99
116
  esac
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=capabilities-here.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capabilities-here.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/capabilities-here.test.ts"],"names":[],"mappings":""}