@rubytech/create-maxy-code 0.1.499 → 0.1.500

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 (57) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/admin-authoring-observer.test.sh +135 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +11 -2
  5. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +6 -3
  6. package/payload/platform/plugins/admin/mcp/dist/index.js +52 -107
  7. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  8. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.d.ts +8 -4
  9. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.d.ts.map +1 -1
  10. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.js +67 -50
  11. package/payload/platform/plugins/admin/mcp/dist/skill-resolution.js.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/store-skill-core.d.ts +33 -0
  13. package/payload/platform/plugins/admin/mcp/dist/store-skill-core.d.ts.map +1 -0
  14. package/payload/platform/plugins/admin/mcp/dist/store-skill-core.js +122 -0
  15. package/payload/platform/plugins/admin/mcp/dist/store-skill-core.js.map +1 -0
  16. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +9 -3
  17. package/payload/platform/plugins/contacts/.claude-plugin/plugin.json +1 -1
  18. package/payload/platform/plugins/contacts/PLUGIN.md +1 -1
  19. package/payload/platform/plugins/contacts/mcp/dist/index.js +3 -12
  20. package/payload/platform/plugins/contacts/mcp/dist/index.js.map +1 -1
  21. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-lookup.test.js +105 -2
  22. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-lookup.test.js.map +1 -1
  23. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-lookup.d.ts +11 -1
  24. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-lookup.d.ts.map +1 -1
  25. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-lookup.js +56 -4
  26. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-lookup.js.map +1 -1
  27. package/payload/platform/plugins/docs/references/contacts-guide.md +2 -0
  28. package/payload/platform/plugins/docs/references/outlook-guide.md +4 -0
  29. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -2
  30. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +9 -1
  31. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +6 -0
  32. package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/SKILL.md +1 -1
  33. package/payload/platform/plugins/prompt-optimiser/skills/prompt-optimiser/__tests__/restatement-ladder.test.sh +2 -1
  34. package/payload/platform/services/claude-session-manager/dist/index.js +26 -1
  35. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.d.ts +15 -0
  37. package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.d.ts.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.js +61 -0
  39. package/payload/platform/services/claude-session-manager/dist/skill-registration-drift.js.map +1 -1
  40. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  41. package/payload/server/public/assets/{AdminLoginScreens-Ce5aBBWF.js → AdminLoginScreens-CKg_Dlpv.js} +1 -1
  42. package/payload/server/public/assets/{admin-BWb8me-E.js → admin-wtSUU5N0.js} +1 -1
  43. package/payload/server/public/assets/chat-DRuIxGtE.js +1 -0
  44. package/payload/server/public/assets/data-BdZCvyvN.js +1 -0
  45. package/payload/server/public/assets/{operator-DCNWUKP_.js → operator-DglDYmdV.js} +1 -1
  46. package/payload/server/public/assets/{page-C4TrTU6r.js → page-C-5sTAE-.js} +2 -2
  47. package/payload/server/public/assets/{page-DUgrGrJp.js → page-C8G5p4jr.js} +1 -1
  48. package/payload/server/public/assets/{public-DXJXQPwe.js → public-DqJjXR4p.js} +1 -1
  49. package/payload/server/public/assets/{useVoiceRecorder-Cu6zvuFx.js → useVoiceRecorder-Cj-VZDKO.js} +1 -1
  50. package/payload/server/public/chat.html +3 -3
  51. package/payload/server/public/data.html +3 -3
  52. package/payload/server/public/index.html +4 -4
  53. package/payload/server/public/operator.html +4 -4
  54. package/payload/server/public/public.html +3 -3
  55. package/payload/server/server.js +42 -6
  56. package/payload/server/public/assets/chat-MvnxPJZ5.js +0 -1
  57. package/payload/server/public/assets/data-BjqC4hyM.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.499",
3
+ "version": "0.1.500",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -196,7 +196,7 @@ Tools are available via the `admin` MCP server.
196
196
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the 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`.
197
197
  - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent`.** 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. 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 `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
198
198
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit.** 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 this hook 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.
199
- - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook.** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if neither fits, author inline freestyle is the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). The directive also carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. The directive is also **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
199
+ - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook.** Injects the standing prompt-optimiser restatement directive plus the per-turn four-rung routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool, where the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if the deliverable recurs and no specialist or skill owns it, author its owning skill via `skill-builder` (or `agent-builder` for a worker agent) and produce through it; **(4)** only if it is a genuine one-off, author inline as the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). The directive also carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. The directive is also **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
200
200
  - `hooks/preference-consult-directive.sh` — **UserPromptSubmit hook.** Injects a standing `<preference-adherence>` block naming the two-layer preference architecture (layer 1 the fixed block already in front of the agent every turn; layer 2 the account's own saved `Preference` records, read with `profile-read`, holding signature policy, header, naming and styling) and two directives: consult the relevant layer-2 preferences with `profile-read` before any customer-facing document deliverable, and store finished deliverables under `documents/` or the project folder because `output/` is scratch of last resort. Prompt-independent and always exit 0 (fail-open). Breadcrumb (stderr): `[pref-wrapper] op=inject`. This is the advisory companion to the `preference-consult-gate` hard gate.
201
201
  - `hooks/datetime-inject.sh` — **UserPromptSubmit hook.** Injects a fresh `<datetime>` block as `additionalContext` on every turn (`refresh=per-turn`), so the agent always has the current instant — date, time, timezone, weekday, and the raw UTC ISO — without hand-computing it and without depending on Claude Code's spawn-time "Today's date" line going stale. The instant is computed fresh each turn; the timezone is **not** computed here — it is the operator's own zone, resolved from their `UserProfile.timezone` on the graph once per admin spawn and stamped into `MAXY_ACCOUNT_TZ` (see `platform/services/claude-session-manager/src/account-timezone.ts`). When `MAXY_ACCOUNT_TZ` is empty (a non-admin turn, an unset profile timezone, or a neo4j hiccup at spawn) the block falls back to the server's own zone and labels the source, so a reader can always tell which clock the agent saw. This is the current-time surface the `datetime` skill points at. **Fail-open** on every path (missing `python3` or `node`, any formatting error → exit 0, empty stdout — the turn is never blocked). The hook appends `<ts> [datetime-inject] injected tz=<zone> source=<graph|server-fallback> refresh=per-turn session=<id>` to `$LOG_DIR/datetime-inject.log` so "did the agent have the right time this turn, and from which clock?" is answerable from logs without reproduction.
202
202
  - `hooks/prompt-optimiser-compliance.sh` — **Stop hook.** After each admin turn, reads the just-finished turn from `transcript_path` and appends `<ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"` to `$LOG_DIR/prompt-optimiser-directive.log` (and stderr) when the routing directive fired, the prompt was non-trivial (not a slash-command, not a one-word confirmation), and the turn took **no route** — no `Agent` dispatch, no `Skill` load, no `ToolSearch`, no `mcp__*` tool call. This is the standing compliance signal that surfaces the session-`da0b12d4` failure class (agent answers a capability question from memory) as a visible event instead of a silent stale answer. Directive-fired is detected by the marker `PROMPT-OPTIMISER DIRECTIVE` in the turn slice, so it is robust to the CC-version difference in how `UserPromptSubmit` `additionalContext` is recorded (`attachment`/`hook_success` vs `hook_additional_context`). **Known limitation:** "direct continuation of the prior turn" is not detectable from the transcript, so a continuation turn that legitimately needs no route can be flagged; treat the log as a review signal, not a gate. **Fail-open:** no python3, no `transcript_path`, or an unreadable transcript → exit 0, no output. Lives in the same log as the directive breadcrumb, so a single `grep` interleaves "fired" and "no-route" into one per-session timeline; cross-check via `platform/scripts/logs-read.sh <sessionKey>`. This is a lightweight transcript read, not a per-turn spawn (contrast the turn recorder below).
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # Task 1961 — the classless inline-write line is the recurring-deliverable
3
+ # arming signal.
4
+ #
5
+ # admin-authoring-observer.sh emits, on stderr, one line when the admin
6
+ # process (MAXY_SPECIALIST empty) writes under <accountDir>/output/:
7
+ # [admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>
8
+ #
9
+ # priorSpecialistSpawnInTurn=false is the arming signal: an inline output
10
+ # write with no specialist dispatched earlier in the turn. This suite pins
11
+ # fixture transcripts via ADMIN_AUTHORING_TRANSCRIPT_PATH and asserts:
12
+ # - false when the transcript has no specialists: Task after the last user turn
13
+ # - true when it does
14
+ # - unknown when no transcript is available
15
+ # - no line at all for writes outside output/, or from a specialist process
16
+ set -u
17
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/admin-authoring-observer.sh"
18
+ [[ -x "$HOOK" ]] || { echo "FAIL: $HOOK not executable"; exit 1; }
19
+
20
+ PASS=0; FAIL=0
21
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
22
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
23
+
24
+ TMP=$(mktemp -d)
25
+ trap 'rm -rf "$TMP"' EXIT
26
+ ACCT="$TMP/account"
27
+ mkdir -p "$ACCT/output"
28
+
29
+ # Fixture transcripts.
30
+ NO_SPECIALIST="$TMP/no-specialist.jsonl"
31
+ cat >"$NO_SPECIALIST" <<'JSON'
32
+ {"type":"user","message":{"role":"user","content":"draft a quote for the Smith job"}}
33
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"On it."}]}}
34
+ JSON
35
+
36
+ WITH_SPECIALIST="$TMP/with-specialist.jsonl"
37
+ cat >"$WITH_SPECIALIST" <<'JSON'
38
+ {"type":"user","message":{"role":"user","content":"draft a quote for the Smith job"}}
39
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{"subagent_type":"specialists:content-producer"}}]}}
40
+ JSON
41
+
42
+ # A Task whose subagent_type is not a specialist: must not count as a spawn.
43
+ NON_SPECIALIST_TASK="$TMP/non-specialist-task.jsonl"
44
+ cat >"$NON_SPECIALIST_TASK" <<'JSON'
45
+ {"type":"user","message":{"role":"user","content":"draft a quote for the Smith job"}}
46
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{"subagent_type":"general-purpose"}}]}}
47
+ JSON
48
+
49
+ # A specialist dispatched in a PRIOR turn must not arm the current turn: the
50
+ # hook walks forward only from the last real-user turn.
51
+ PRIOR_TURN_SPECIALIST="$TMP/prior-turn-specialist.jsonl"
52
+ cat >"$PRIOR_TURN_SPECIALIST" <<'JSON'
53
+ {"type":"user","message":{"role":"user","content":"earlier request"}}
54
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{"subagent_type":"specialists:content-producer"}}]}}
55
+ {"type":"user","message":{"role":"user","content":"draft a quote for the Smith job"}}
56
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"On it."}]}}
57
+ JSON
58
+
59
+ # input <tool_name> <file_path> — builds the PostToolUse payload the hook parses.
60
+ mk_input() {
61
+ python3 -c '
62
+ import json, sys
63
+ print(json.dumps({
64
+ "hook_event_name": "PostToolUse",
65
+ "tool_name": sys.argv[1],
66
+ "tool_input": {"file_path": sys.argv[2]},
67
+ "transcript_path": ""
68
+ }, separators=(",", ":")))
69
+ ' "$1" "$2"
70
+ }
71
+
72
+ # run <transcript-or-empty> <MAXY_SPECIALIST> <tool_name> <file_path> — fires the
73
+ # hook from inside the account dir (its output/ scope keys off $PWD) and returns
74
+ # stderr.
75
+ run() {
76
+ local transcript="$1" specialist="$2" tool="$3" fp="$4"
77
+ ( cd "$ACCT" \
78
+ && mk_input "$tool" "$fp" \
79
+ | ADMIN_AUTHORING_TRANSCRIPT_PATH="$transcript" MAXY_SPECIALIST="$specialist" \
80
+ bash "$HOOK" 2>&1 1>/dev/null )
81
+ }
82
+
83
+ ARM_RE='\[admin-authoring\] inline-write path=output/quote\.html priorSpecialistSpawnInTurn='
84
+
85
+ # 1. Arming signal: inline output write, no specialist in the turn.
86
+ OUT=$(run "$NO_SPECIALIST" "" Write "output/quote.html")
87
+ echo "$OUT" | grep -qE "${ARM_RE}false" \
88
+ && pass "arming: priorSpecialistSpawnInTurn=false on inline output write" \
89
+ || fail "arming false expected, got: $OUT"
90
+
91
+ # 2. Owned: a specialist was dispatched earlier in the same turn.
92
+ OUT=$(run "$WITH_SPECIALIST" "" Write "output/quote.html")
93
+ echo "$OUT" | grep -qE "${ARM_RE}true" \
94
+ && pass "owned: priorSpecialistSpawnInTurn=true when specialist dispatched" \
95
+ || fail "true expected, got: $OUT"
96
+
97
+ # 3. Unknown: no transcript available — line still fires, value is not the arming one.
98
+ OUT=$(run "$TMP/does-not-exist.jsonl" "" Write "output/quote.html")
99
+ echo "$OUT" | grep -qE "${ARM_RE}unknown" \
100
+ && pass "unknown: priorSpecialistSpawnInTurn=unknown with no transcript" \
101
+ || fail "unknown expected, got: $OUT"
102
+
103
+ # 4. Edit is observed like Write.
104
+ OUT=$(run "$NO_SPECIALIST" "" Edit "output/quote.html")
105
+ echo "$OUT" | grep -qE "${ARM_RE}false" \
106
+ && pass "Edit under output/ is observed" \
107
+ || fail "Edit expected arming line, got: $OUT"
108
+
109
+ # 4a. A non-specialist Task in the turn does not count as a spawn — still armed.
110
+ OUT=$(run "$NON_SPECIALIST_TASK" "" Write "output/quote.html")
111
+ echo "$OUT" | grep -qE "${ARM_RE}false" \
112
+ && pass "arming: non-specialist Task does not disarm" \
113
+ || fail "false expected for non-specialist Task, got: $OUT"
114
+
115
+ # 4b. A specialist in a PRIOR turn does not disarm the current turn.
116
+ OUT=$(run "$PRIOR_TURN_SPECIALIST" "" Write "output/quote.html")
117
+ echo "$OUT" | grep -qE "${ARM_RE}false" \
118
+ && pass "arming: prior-turn specialist does not disarm current turn" \
119
+ || fail "false expected for prior-turn specialist, got: $OUT"
120
+
121
+ # 5. Scope guard — write outside output/ emits no line.
122
+ OUT=$(run "$NO_SPECIALIST" "" Write "notes/scratch.txt")
123
+ echo "$OUT" | grep -q '\[admin-authoring\]' \
124
+ && fail "write outside output/ must emit nothing, got: $OUT" \
125
+ || pass "scope guard: write outside output/ emits no line"
126
+
127
+ # 6. Scope guard — a specialist process (MAXY_SPECIALIST set) emits no line.
128
+ OUT=$(run "$NO_SPECIALIST" "content-producer" Write "output/quote.html")
129
+ echo "$OUT" | grep -q '\[admin-authoring\]' \
130
+ && fail "specialist process must emit nothing, got: $OUT" \
131
+ || pass "scope guard: specialist process emits no line"
132
+
133
+ echo "----"
134
+ echo "PASS=$PASS FAIL=$FAIL"
135
+ [[ "$FAIL" -eq 0 ]]
@@ -65,7 +65,7 @@ else
65
65
  fi
66
66
  rm -rf "$FAKEBIN"
67
67
 
68
- # Case 4 — both generated lists present: the ladder carries all three tiers, both
68
+ # Case 4 — both generated lists present: the ladder carries all four rungs, both
69
69
  # full lists, and the relocated outcome-only-brief contract. The hook reads the
70
70
  # lists from $PWD/agents/admin, so run it from a temp account dir.
71
71
  WD=$(mktemp -d); mkdir -p "$WD/agents/admin"
@@ -78,11 +78,20 @@ if printf '%s' "$ctx" | grep -q "specialists:content-producer" \
78
78
  && printf '%s' "$ctx" | grep -qE '(^|[^0-9])1\)' \
79
79
  && printf '%s' "$ctx" | grep -qE '(^|[^0-9])2\)' \
80
80
  && printf '%s' "$ctx" | grep -qE '(^|[^0-9])3\)' \
81
+ && printf '%s' "$ctx" | grep -qE '(^|[^0-9])4\)' \
81
82
  && printf '%s' "$ctx" | grep -qiE "freestyle|last resort"; then
82
- echo "PASS: both lists injected in three-tier ladder"; PASS=$((PASS+1))
83
+ echo "PASS: both lists injected in four-rung ladder"; PASS=$((PASS+1))
83
84
  else
84
85
  echo "FAIL: ladder/lists missing from additionalContext: $ctx" >&2; FAIL=$((FAIL+1))
85
86
  fi
87
+ # Task 1959: recurring-deliverable rung sits before the freestyle last resort.
88
+ if printf '%s' "$ctx" | grep -qi "skill-builder" \
89
+ && printf '%s' "$ctx" | grep -qiE "recurs|issues repeatedly|repeatable" \
90
+ && printf '%s' "$ctx" | grep -qi "agent-builder"; then
91
+ echo "PASS: recurring-deliverable rung present in ladder"; PASS=$((PASS+1))
92
+ else
93
+ echo "FAIL: recurring-deliverable rung missing from ladder: $ctx" >&2; FAIL=$((FAIL+1))
94
+ fi
86
95
  # Relocated outcome-only-brief contract.
87
96
  if printf '%s' "$ctx" | grep -qi "outcome" \
88
97
  && printf '%s' "$ctx" | grep -qi "binding constraints" \
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  # UserPromptSubmit hook: inject the standing prompt-optimiser directive plus the
3
- # per-turn three-tier routing ladder as additionalContext on every turn, so the
3
+ # per-turn four-rung routing ladder as additionalContext on every turn, so the
4
4
  # agent restates each non-trivial prompt and routes it before acting. Mirrors
5
5
  # doctrine.sh: emit hookSpecificOutput.additionalContext via python3, fail-open
6
6
  # with exit 0.
7
7
  #
8
8
  # The ladder ranks, in order: (1) delegate to a specialist that owns the
9
9
  # deliverable via the Agent tool; (2) only if none fits, load an admin-usable
10
- # skill; (3) only if neither fits, author inline freestyle is the named last
10
+ # skill; (3) only if the deliverable recurs and no specialist or skill owns it,
11
+ # author its owning skill via skill-builder (or agent-builder) and produce
12
+ # through it; (4) only if it is a genuine one-off, author inline as the last
11
13
  # resort. The full agent roster and admin-usable skills list are re-emitted every
12
14
  # turn by reading two generated files from the account dir; the hook never walks
13
15
  # the plugins tree per turn (Task 698).
@@ -102,7 +104,8 @@ IFS= read -r -d '' DIRECTIVE_MID <<'DIRECTIVE_EOF' || true
102
104
  DIRECTIVE_EOF
103
105
 
104
106
  IFS= read -r -d '' DIRECTIVE_TAIL <<'DIRECTIVE_EOF' || true
105
- 3) Only if neither a specialist nor an admin skill fits, author it directly. Freestyle inline authoring is the last resort, not a peer of the two routes above.
107
+ 3) Only if neither a specialist nor an admin skill already owns it, decide whether the deliverable recurs. A deliverable the business issues repeatedly, or one the operator gives a fixed template, numbering, naming, or filing rule for, is authored once as its own skill: load skill-builder with skill-load (or agent-builder for a worker agent), create the owning skill, then produce the deliverable through it. This rung owns every repeatable business document or process; hand-building a recurring deliverable here is not a route.
108
+ 4) Only if the deliverable is a genuine one-off, author it directly. Freestyle inline authoring is the last resort, not a peer of the routes above.
106
109
  DIRECTIVE_EOF
107
110
 
108
111
  DIRECTIVE=$(printf '%s\n%s\n%s\n%s\n%s' \
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { readFile, writeFile } from "node:fs/promises";
6
6
  import { resolve, join } from "node:path";
7
7
  import { execFile, execFileSync } from "node:child_process";
8
- import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
9
9
  import { writeAdminEntry, removeAdminFromAccount, logUsersAudit, formatAuditRowIds } from "../../../../lib/admins-write/dist/index.js";
10
10
  import { setAccessPassword as setAccessPasswordHash, clearAccessPassword as clearAccessPasswordHash, accessPasswordCollides } from "../../../../lib/admin-access-password/dist/index.js";
11
11
  import { resolveCredentialTarget } from "./credential-identity.js";
@@ -20,6 +20,7 @@ import { homedir, hostname as osHostname } from "node:os";
20
20
  import QRCode from "qrcode";
21
21
  import { getSession, closeDriver } from "./lib/neo4j.js";
22
22
  import { findSkillOwners, searchSkills, computePluginReadHint, loadSkill, parseRequiredInputs, adminAuthoringSkillBlock, skillSlugFromReadArgs, AUTHORING_DISPATCH_HINT } from "./skill-resolution.js";
23
+ import { storeSkillToAccount } from "./store-skill-core.js";
23
24
  import { resolvePublicHostname } from "./lib/public-hostname.js";
24
25
  import { resolveAdminHostname } from "./lib/admin-hostname.js";
25
26
  import { publishSite } from "./tools/publish-site.js";
@@ -1808,15 +1809,16 @@ eagerTool(server, "cpu-triage-read", "Read a cpu-triage run started by cpu-triag
1808
1809
  }
1809
1810
  return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1810
1811
  });
1811
- // operator-authored skills are written to disk as a plugin so the
1812
- // existing plugin-manifest loader picks them up (parsePluginFrontmatter +
1813
- // assemblePublicPluginContent + loadEmbeddedPlugins all read from
1814
- // <PLATFORM_ROOT>/plugins/<dir>/). Canonical = <accountDir>/plugins/<plugin>
1815
- // survives installer rmSync (data/ excluded); the mirror under
1816
- // <PLATFORM_ROOT>/plugins/<plugin> is rehydrated at admin session start by
1817
- // autoDeliverUserPlugins. The agent supplies pluginName/skillName/body
1818
- // path is computed by this tool from ACCOUNT_ID. Symmetric write counterpart
1819
- // to plugin-read.
1812
+ // Task 1962 — operator-authored skills are written as a plugin under the
1813
+ // per-account root <accountDir>/plugins/<plugin>/ ONLY. That copy survives the
1814
+ // installer wipe (data/ is excluded) and is loaded directly by the spawn
1815
+ // manifest (computeActivePlugins) and the admin skill resolvers, which walk the
1816
+ // account root. Nothing is written to the immutable PLATFORM_ROOT/plugins tree
1817
+ // (chattr +i) and there is no platform mirror. The write+resolve core lives in
1818
+ // store-skill-core.ts and fails closed if the written skill does not resolve,
1819
+ // so success is never reported for a skill that cannot load. The agent supplies
1820
+ // pluginName/skillName/body — the path is computed from ACCOUNT_ID. Symmetric
1821
+ // write counterpart to plugin-read.
1820
1822
  eagerTool(server, "store-skill", "Save an operator-authored skill on disk as part of an admin-managed plugin. " +
1821
1823
  "The skill becomes immediately discoverable by the admin agent (and the public agent if publicEmbed=true). " +
1822
1824
  "Path is computed internally from the active account; the agent supplies content + names only. " +
@@ -1850,97 +1852,31 @@ eagerTool(server, "store-skill", "Save an operator-authored skill on disk as par
1850
1852
  }
1851
1853
  }
1852
1854
  const accountDir = getAccountDir();
1853
- const canonicalPluginDir = resolve(accountDir, "plugins", pluginName);
1854
- const mirrorPluginDir = resolve(PLATFORM_ROOT, "plugins", pluginName);
1855
- const canonicalPluginMd = resolve(canonicalPluginDir, "PLUGIN.md");
1856
- const mirrorPluginMd = resolve(mirrorPluginDir, "PLUGIN.md");
1857
- // Collision: mirror dir exists from a shipped plugin (no canonical mirror).
1858
- // Refuse rather than silently shadow.
1859
- if (existsSync(mirrorPluginMd) && !existsSync(canonicalPluginMd)) {
1860
- console.error(`[store-skill] ERROR pluginName=${pluginName} skillName=${skillName} reason=collision-with-shipped path=${mirrorPluginDir}`);
1861
- return { content: [{ type: "text", text: `pluginName "${pluginName}" collides with a shipped plugin. Choose a different pluginName.` }], isError: true };
1862
- }
1863
- const skillDir = resolve(canonicalPluginDir, "skills", skillName);
1864
- const action = existsSync(resolve(skillDir, "SKILL.md")) ? "overwrite" : "create";
1865
- try {
1866
- mkdirSync(canonicalPluginDir, { recursive: true });
1867
- // Compose PLUGIN.md only on first write. embed=["admin","public"] +
1868
- // optional=false matches assemblePublicPluginContent / loadEmbeddedPlugins
1869
- // expectations. metadata is single-line JSON (parsePluginFrontmatter at
1870
- // plugin-manifest.ts:67 expects single-line) — multi-line silently falls
1871
- // back to platform={}.
1872
- if (!existsSync(canonicalPluginMd)) {
1873
- const pluginMd = `---
1874
- name: ${pluginName}
1875
- description: "Operator-authored plugin"
1876
- tools: []
1877
- hidden: []
1878
- requires: []
1879
- metadata: {"platform":{"embed":["admin","public"],"optional":false}}
1880
- ---
1881
-
1882
- # ${pluginName}
1883
-
1884
- Operator-authored plugin. Skills under \`skills/\` were created via the admin skill-builder.
1885
- `;
1886
- writeFileSync(canonicalPluginMd, pluginMd);
1887
- }
1888
- // Reset skill dir before write so removed reference files don't linger.
1889
- if (existsSync(skillDir)) {
1890
- rmSync(skillDir, { recursive: true, force: true });
1891
- }
1892
- mkdirSync(skillDir, { recursive: true });
1893
- // Normalise description — strip newlines and tabs to keep YAML
1894
- // frontmatter on a single line. parsePluginFrontmatter uses a
1895
- // single-line regex; an embedded newline (or `\n---\n`) would
1896
- // otherwise corrupt the frontmatter parse and silently drop body
1897
- // content from the agent prompt.
1898
- const singleLineDescription = description.replace(/[\r\n\t]+/g, " ").trim();
1899
- const escapedDescription = singleLineDescription.replace(/"/g, '\\"');
1900
- const skillMd = `---
1901
- name: ${skillName}
1902
- description: "${escapedDescription}"
1903
- publicEmbed: ${publicEmbed}
1904
- ---
1905
-
1906
- ${body}
1907
- `;
1908
- writeFileSync(resolve(skillDir, "SKILL.md"), skillMd);
1909
- let refCount = 0;
1910
- if (references.length > 0) {
1911
- const refsDir = resolve(skillDir, "references");
1912
- mkdirSync(refsDir, { recursive: true });
1913
- for (const ref of references) {
1914
- writeFileSync(resolve(refsDir, ref.filename), ref.content);
1915
- refCount++;
1916
- }
1917
- }
1918
- // Write the `.user-mirror` marker to CANONICAL first — cpSync will
1919
- // carry it into the platform-side mirror as part of the same recursive
1920
- // copy. This eliminates the post-cpSync writeFileSync race: if cpSync
1921
- // succeeds, the marker is already in target; if cpSync fails partway,
1922
- // canonical's marker is intact and the next autoDeliverUserPlugins
1923
- // call retries the copy. autoDeliverUserPlugins relies on the marker
1924
- // arriving with the source tree — never writes its own marker.
1925
- writeFileSync(resolve(canonicalPluginDir, ".user-mirror"), `pluginName=${pluginName}\nsource=${canonicalPluginDir}\n`);
1926
- // Mirror to <PLATFORM_ROOT>/plugins/<pluginName>/ so the change is
1927
- // visible to the loader without a session restart.
1928
- mkdirSync(mirrorPluginDir, { recursive: true });
1929
- cpSync(canonicalPluginDir, mirrorPluginDir, { recursive: true, force: true });
1930
- const bodyBytes = Buffer.byteLength(body, "utf-8");
1931
- console.log(`[store-skill] write pluginName=${pluginName} skillName=${skillName} publicEmbed=${publicEmbed} path=${canonicalPluginDir} bodyBytes=${bodyBytes} referenceCount=${refCount} action=${action}`);
1932
- return {
1933
- content: [{
1934
- type: "text",
1935
- text: `Skill "${skillName}" saved to plugin "${pluginName}" (${action}). publicEmbed=${publicEmbed}, references=${refCount}. Active immediately for the admin agent${publicEmbed ? "; surfaces in the public agent on next session start" : ""}.`,
1936
- }],
1937
- };
1938
- }
1939
- catch (err) {
1940
- const msg = err instanceof Error ? err.message : String(err);
1941
- console.error(`[store-skill] ERROR pluginName=${pluginName} skillName=${skillName} reason=${msg.slice(0, 120)} path=${canonicalPluginDir}`);
1942
- return { content: [{ type: "text", text: `Failed to write skill: ${msg}` }], isError: true };
1943
- }
1855
+ const result = await storeSkillToAccount({
1856
+ accountDir,
1857
+ platformRoot: PLATFORM_ROOT,
1858
+ pluginName,
1859
+ skillName,
1860
+ description,
1861
+ publicEmbed,
1862
+ body,
1863
+ references,
1864
+ });
1865
+ if (!result.ok) {
1866
+ const isCollision = result.reason === "collision-with-shipped";
1867
+ console.error(`[store-skill] op=store-skill root=account path=${result.canonicalPluginDir} resolved=false reason=${result.reason} pluginName=${pluginName} skillName=${skillName} detail=${result.detail.slice(0, 120)}`);
1868
+ const text = isCollision
1869
+ ? `pluginName "${pluginName}" collides with a shipped plugin. Choose a different pluginName.`
1870
+ : `Skill "${skillName}" was written to "${pluginName}" but did not register: ${result.detail}. Not activating.`;
1871
+ return { content: [{ type: "text", text }], isError: true };
1872
+ }
1873
+ console.log(`[store-skill] op=store-skill root=account path=${result.canonicalPluginDir} resolved=true pluginName=${pluginName} skillName=${skillName} publicEmbed=${publicEmbed} bodyBytes=${result.bodyBytes} referenceCount=${result.refCount} action=${result.action}`);
1874
+ return {
1875
+ content: [{
1876
+ type: "text",
1877
+ text: `Skill "${skillName}" saved to plugin "${pluginName}" (${result.action}). publicEmbed=${publicEmbed}, references=${result.refCount}. Active immediately for the admin agent${publicEmbed ? "; surfaces in the public agent on next session start" : ""}.`,
1878
+ }],
1879
+ };
1944
1880
  });
1945
1881
  eagerTool(server, "plugin-read", "Read a plugin definition (PLUGIN.md) or one of its reference files.", {
1946
1882
  pluginName: z.string().describe("Name of the plugin directory (e.g. 'sales', 'business-assistant')"),
@@ -1960,14 +1896,23 @@ eagerTool(server, "plugin-read", "Read a plugin definition (PLUGIN.md) or one of
1960
1896
  console.log(`[admin-skill-gate] role=admin skill=${authoringSlug} decision=block reason=content-producer-owned`);
1961
1897
  return { content: [{ type: "text", text: AUTHORING_DISPATCH_HINT }], isError: true };
1962
1898
  }
1963
- const pluginPath = resolve(PLATFORM_ROOT, "plugins", pluginName, resolvedFile);
1899
+ // Task 1962 operator-authored plugins live under the account root, not
1900
+ // the platform tree. Prefer the platform copy; fall back to the account
1901
+ // root so an operator plugin's PLUGIN.md / SKILL.md / references resolve.
1902
+ const accountPluginsDir = resolve(getAccountDir(), "plugins");
1903
+ const platformPath = resolve(PLATFORM_ROOT, "plugins", pluginName, resolvedFile);
1904
+ const accountPath = resolve(accountPluginsDir, pluginName, resolvedFile);
1905
+ const pluginPath = existsSync(platformPath) ? platformPath : accountPath;
1906
+ const pluginRootDir = existsSync(platformPath)
1907
+ ? resolve(PLATFORM_ROOT, "plugins", pluginName)
1908
+ : resolve(accountPluginsDir, pluginName);
1964
1909
  if (!existsSync(pluginPath)) {
1965
1910
  // when the agent's pluginName/file combination misses
1966
1911
  // a skill that DOES live under `plugins/*/skills/<slug>/SKILL.md`,
1967
1912
  // surface the corrected pluginName AND the canonical file path so
1968
1913
  // the next call is deterministic. Both wrong-`file` and wrong-`pluginName`
1969
1914
  // shapes feed through `computePluginReadHint`.
1970
- const hint = computePluginReadHint(PLATFORM_ROOT, pluginName, resolvedFile);
1915
+ const hint = computePluginReadHint(PLATFORM_ROOT, pluginName, resolvedFile, accountPluginsDir);
1971
1916
  const hintText = hint ? ` Did you mean pluginName="${hint.pluginName}", file="${hint.file}"?` : "";
1972
1917
  console.log(`[plugin-read] ${pluginName}/${resolvedFile} not-found hint=${hint ? hint.pluginName : "none"}`);
1973
1918
  return {
@@ -1984,7 +1929,7 @@ eagerTool(server, "plugin-read", "Read a plugin definition (PLUGIN.md) or one of
1984
1929
  // Append file inventory on default calls (PLUGIN.md) so agents can discover
1985
1930
  // skill and reference paths without guessing
1986
1931
  if (resolvedFile === "PLUGIN.md") {
1987
- const pluginRoot = resolve(PLATFORM_ROOT, "plugins", pluginName);
1932
+ const pluginRoot = pluginRootDir;
1988
1933
  const skills = [];
1989
1934
  const references = [];
1990
1935
  const scanDir = (base, prefix, target) => {
@@ -2044,7 +1989,7 @@ lifelineTool(server, "skill-find", "Find which plugin owns a skill by name. Walk
2044
1989
  if (!ACCOUNT_ID)
2045
1990
  return refuseNoAccount("skill-find");
2046
1991
  const start = Date.now();
2047
- const result = findSkillOwners(PLATFORM_ROOT, skillName);
1992
+ const result = findSkillOwners(PLATFORM_ROOT, skillName, resolve(getAccountDir(), "plugins"));
2048
1993
  const ms = Date.now() - start;
2049
1994
  console.log(`[skill-find] skillName=${skillName} result=${result.status} candidates=${result.candidates.length} ms=${ms}`);
2050
1995
  if (result.status === "unique") {
@@ -2085,7 +2030,7 @@ eagerTool(server, "skill-search", "Find skills by free-text intent when you do n
2085
2030
  if (!ACCOUNT_ID)
2086
2031
  return refuseNoAccount("skill-search");
2087
2032
  const start = Date.now();
2088
- const hits = searchSkills(PLATFORM_ROOT, query);
2033
+ const hits = searchSkills(PLATFORM_ROOT, query, 5, resolve(getAccountDir(), "plugins"));
2089
2034
  const ms = Date.now() - start;
2090
2035
  const top = hits.length > 0 ? `${hits[0].pluginName}:${hits[0].skillName}` : "none";
2091
2036
  console.log(`[skill-search] query=${JSON.stringify(query)} hits=${hits.length} top=${top} ms=${ms}`);
@@ -2134,7 +2079,7 @@ eagerTool(server, "skill-load", "Load a plugin skill's SKILL.md body by skill na
2134
2079
  }
2135
2080
  const start = Date.now();
2136
2081
  try {
2137
- const outcome = await loadSkill(PLATFORM_ROOT, skillName);
2082
+ const outcome = await loadSkill(PLATFORM_ROOT, skillName, resolve(getAccountDir(), "plugins"));
2138
2083
  if (outcome.status === "ambiguous") {
2139
2084
  const ms = Date.now() - start;
2140
2085
  console.log(`[skill-load] skillName=${skillName} result=ambiguous pluginName=none file=none bodyBytes=0 ms=${ms}`);