@rubytech/create-sitedesk-code 0.1.514 → 0.1.515

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 (25) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2016-disabled-agent-routing.md +624 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2016-disabled-agent-routing-design.md +139 -0
  4. package/payload/platform/plugins/admin/mcp/dist/index.js +14 -16
  5. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  6. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.d.ts +14 -0
  7. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.d.ts.map +1 -0
  8. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.js +57 -0
  9. package/payload/platform/plugins/admin/mcp/dist/specialist-registry.js.map +1 -0
  10. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +15 -3
  11. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  12. package/payload/platform/plugins/docs/references/admin-ui.md +14 -2
  13. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +2 -2
  14. package/payload/platform/scripts/__tests__/agents-md-bootstrap.test.sh +47 -0
  15. package/payload/platform/scripts/lib/agents-md-bootstrap.sh +58 -1
  16. package/payload/platform/services/claude-session-manager/dist/config.d.ts +48 -12
  17. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/config.js +65 -15
  19. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/index.js +7 -4
  21. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.js +17 -2
  24. package/payload/platform/services/claude-session-manager/dist/rootless-client-audit.js.map +1 -1
  25. package/payload/server/server.js +101 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.514",
3
+ "version": "0.1.515",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -0,0 +1,624 @@
1
+ # Task 2016 — disabled agents leave the admin routing table
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** A specialist the operator disabled carries no `- **specialists:<name>**:` line in `agents/admin/AGENTS.md`, gets it back on enable, and reads as `disabled` rather than `missing file` in `system-status`.
6
+
7
+ **Architecture:** One rule in one place: `agents-md-bootstrap.sh` treats `<accountDir>/agents-disabled.json` as the operator's intent and withholds a routing line for every name it holds. The disable and enable routes exec that same script after their file move, so prose is right immediately instead of after the next provisioning run. The `system-status` specialist walk moves into its own module that reads the same store and reports `disabled` and `missing file` as different states with separate counts.
8
+
9
+ **Tech Stack:** bash + python3 (the store parser already used by `provision-account-dir.sh`), TypeScript on Node, Hono routes, vitest.
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `platform/docs/superpowers/specs/2026-07-27-task-2016-disabled-agent-routing-design.md`.
14
+ - All paths below are relative to `maxy-code/` inside the worktree.
15
+ - A store entry maps to a routing line by stripping `.md`. One rule, no branch.
16
+ - An unreadable store withholds nothing, matching `provision-account-dir.sh:293`.
17
+ - The existing ` [admin-bootstrap] AGENTS.md specialists=<n> appended=<m> existing=<k>` line stays byte-identical; the current suite asserts its counts.
18
+ - `platform/plugins/admin/skills/platform-architecture/SKILL.md` is generated by `scripts/assemble-architecture-skill.mjs` from the docs corpus. Never hand-edit it. Doc changes go in `platform/plugins/docs/references/admin-ui.md`.
19
+ - Commit trailers: `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>` and `Session: a2b8f556-8297-4184-bf57-59b078a8015d`.
20
+
21
+ ---
22
+
23
+ ## File Structure
24
+
25
+ | File | Responsibility |
26
+ |------|----------------|
27
+ | `platform/scripts/lib/agents-md-bootstrap.sh` (modify) | Reconcile specialist lines against the disabled store; emit the `op=specialist-lines` line. |
28
+ | `platform/scripts/__tests__/agents-md-bootstrap.test.sh` (modify) | Cases for withhold, restore, malformed store. |
29
+ | `platform/plugins/admin/mcp/src/specialist-registry.ts` (create) | Classify AGENTS.md entries as `ok` / `disabled (operator)` / `missing file` and count them. |
30
+ | `platform/plugins/admin/mcp/src/__tests__/specialist-registry.test.ts` (create) | Cover the three statuses and the two counts. |
31
+ | `platform/plugins/admin/mcp/src/index.ts` (modify, 508-538) | Call the module instead of classifying inline. |
32
+ | `platform/ui/server/routes/admin/agents.ts` (modify) | Exec the bootstrap after disable and enable; report `routingReconciled`. |
33
+ | `platform/ui/server/routes/admin/__tests__/agents.test.ts` (modify) | Prove the line disappears on disable and returns on enable. |
34
+ | `platform/plugins/docs/references/admin-ui.md` (modify, 209-241) | Document the reconcile and the new response field. |
35
+
36
+ ---
37
+
38
+ ### Task 1: The bootstrap reconciles against the disabled store
39
+
40
+ **Files:**
41
+ - Modify: `platform/scripts/lib/agents-md-bootstrap.sh`
42
+ - Test: `platform/scripts/__tests__/agents-md-bootstrap.test.sh`
43
+
44
+ **Interfaces:**
45
+ - Consumes: `<accountDir>/agents-disabled.json`, shape `{ "disabled": ["<basename>.md", …] }`, written by `platform/ui/server/routes/admin/agents.ts`.
46
+ - Produces: stdout line ` [agents-md] op=specialist-lines account=<id8> kept=<n> withheld=<comma-list|none|store-unreadable>`. Task 3 relies on this script being callable as `bash <PLATFORM_ROOT>/scripts/lib/agents-md-bootstrap.sh <accountDir>`.
47
+
48
+ - [ ] **Step 1: Write the failing tests**
49
+
50
+ Append to `platform/scripts/__tests__/agents-md-bootstrap.test.sh`, immediately before the final summary block that prints `PASS`/`FAIL` counts:
51
+
52
+ ```bash
53
+ # Case 7 (Task 2016) — a name in the disabled store gets no routing line.
54
+ TMP=$(make_account)
55
+ bash "$BOOTSTRAP" "$TMP" >/dev/null
56
+ printf '{"disabled":["beta-specialist.md"]}\n' > "$TMP/agents-disabled.json"
57
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
58
+ assert_not_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "withhold:beta-line-gone"
59
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:alpha-specialist**: " "withhold:alpha-kept"
60
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:gamma-specialist**: " "withhold:gamma-kept"
61
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=2 withheld=beta-specialist" "withhold:log-line"
62
+
63
+ # Case 8 (Task 2016) — clearing the store restores the line on the next run.
64
+ printf '{"disabled":[]}\n' > "$TMP/agents-disabled.json"
65
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
66
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "restore:beta-back"
67
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=none" "restore:log-line"
68
+ rm -rf "$TMP"
69
+
70
+ # Case 9 (Task 2016) — a malformed store removes nothing and says so.
71
+ TMP=$(make_account)
72
+ bash "$BOOTSTRAP" "$TMP" >/dev/null
73
+ printf '{ not json' > "$TMP/agents-disabled.json"
74
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
75
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:alpha-specialist**: " "badstore:alpha-kept"
76
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "badstore:beta-kept"
77
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=store-unreadable" "badstore:log-line"
78
+ rm -rf "$TMP"
79
+
80
+ # Case 10 (Task 2016) — no store on disk is the cold-install path: nothing withheld.
81
+ TMP=$(make_account)
82
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
83
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=none" "nostore:log-line"
84
+ rm -rf "$TMP"
85
+ ```
86
+
87
+ - [ ] **Step 2: Run the suite to verify the new cases fail**
88
+
89
+ Run: `bash platform/scripts/__tests__/agents-md-bootstrap.test.sh`
90
+ Expected: FAIL. The `withhold:*`, `restore:*`, `badstore:*` and `nostore:*` assertions fail because the script neither removes lines nor prints an `op=specialist-lines` line; every pre-existing case still passes.
91
+
92
+ - [ ] **Step 3: Implement the reconcile**
93
+
94
+ In `platform/scripts/lib/agents-md-bootstrap.sh`, extend the header comment after the existing paragraph about preserved lines:
95
+
96
+ ```bash
97
+ # A name in <account_dir>/agents-disabled.json gets no routing line: an existing
98
+ # one is removed and the append loop skips it. The store is the operator's
99
+ # intent, so this holds even in the drift state where the file is still live
100
+ # (the standing agent-parity check is what reports that drift). An unreadable
101
+ # store withholds nothing, matching provision-account-dir.sh.
102
+ #
103
+ # Second stdout line, same two-space indent:
104
+ # " [agents-md] op=specialist-lines account=<id8> kept=<n> withheld=<comma-list|none|store-unreadable>"
105
+ ```
106
+
107
+ Insert this block after the `if [ ! -f "$AGENTS_MD" ]` bootstrap block (after line 28) and before `_specialists=0`:
108
+
109
+ ```bash
110
+ # --- Disabled-agent reconcile (Task 2016) ------------------------------------
111
+ _store="$ACCOUNT_DIR/agents-disabled.json"
112
+ _withheld_names=""
113
+ _withheld_field="none"
114
+ if [ -f "$_store" ]; then
115
+ if _withheld_names=$(python3 -c '
116
+ import json, sys
117
+ try:
118
+ parsed = json.load(open(sys.argv[1]))
119
+ except Exception:
120
+ raise SystemExit(1)
121
+ names = parsed.get("disabled")
122
+ if not isinstance(names, list):
123
+ raise SystemExit(1)
124
+ for n in sorted(names):
125
+ if isinstance(n, str) and n.endswith(".md") and "/" not in n and ".." not in n:
126
+ print(n[:-3])
127
+ ' "$_store" 2>/dev/null); then
128
+ if [ -n "$_withheld_names" ]; then
129
+ _withheld_field=$(echo "$_withheld_names" | paste -sd, -)
130
+ fi
131
+ else
132
+ _withheld_names=""
133
+ _withheld_field="store-unreadable"
134
+ fi
135
+ fi
136
+
137
+ _is_withheld() {
138
+ [ -n "$_withheld_names" ] || return 1
139
+ printf '%s\n' "$_withheld_names" | grep -qxF -- "$1"
140
+ }
141
+
142
+ if [ -n "$_withheld_names" ]; then
143
+ while IFS= read -r _w; do
144
+ [ -n "$_w" ] || continue
145
+ if grep -qE "^- \*\*specialists:${_w}\*\*:" "$AGENTS_MD"; then
146
+ grep -vE "^- \*\*specialists:${_w}\*\*:" "$AGENTS_MD" > "$AGENTS_MD.tmp" || true
147
+ mv "$AGENTS_MD.tmp" "$AGENTS_MD"
148
+ fi
149
+ done <<< "$_withheld_names"
150
+ fi
151
+ ```
152
+
153
+ In the walk loop, add the skip immediately after `[ -z "$_name" ] && continue` (line 40) so a withheld name is neither counted nor appended:
154
+
155
+ ```bash
156
+ if _is_withheld "$_name"; then continue; fi
157
+ ```
158
+
159
+ After the existing `echo " [admin-bootstrap] …"` line, add:
160
+
161
+ ```bash
162
+ _kept=$(grep -cE '^- \*\*specialists:' "$AGENTS_MD" || true)
163
+ _id8=$(basename "$ACCOUNT_DIR" | cut -c1-8)
164
+ echo " [agents-md] op=specialist-lines account=$_id8 kept=$_kept withheld=$_withheld_field"
165
+ ```
166
+
167
+ - [ ] **Step 4: Run the suite to verify it passes**
168
+
169
+ Run: `bash platform/scripts/__tests__/agents-md-bootstrap.test.sh`
170
+ Expected: every case passes, `FAIL=0`, including the pre-existing cases 1-6 unchanged.
171
+
172
+ - [ ] **Step 5: Commit**
173
+
174
+ ```bash
175
+ git add maxy-code/platform/scripts/lib/agents-md-bootstrap.sh maxy-code/platform/scripts/__tests__/agents-md-bootstrap.test.sh
176
+ git commit -m "$(cat <<'EOF'
177
+ fix(2016): withhold routing lines for disabled specialists
178
+
179
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180
+ Session: a2b8f556-8297-4184-bf57-59b078a8015d
181
+ EOF
182
+ )"
183
+ ```
184
+
185
+ ---
186
+
187
+ ### Task 2: `system-status` separates disabled from missing
188
+
189
+ **Files:**
190
+ - Create: `platform/plugins/admin/mcp/src/specialist-registry.ts`
191
+ - Create: `platform/plugins/admin/mcp/src/__tests__/specialist-registry.test.ts`
192
+ - Modify: `platform/plugins/admin/mcp/src/index.ts:508-538`
193
+
194
+ **Interfaces:**
195
+ - Consumes: `<accountDir>/agents-disabled.json` (same shape as Task 1), `<accountDir>/agents/admin/AGENTS.md`, `<accountDir>/specialists/agents/`.
196
+ - Produces: `classifySpecialists(opts: { agentsMd: string; specialistsAgentsDir: string; accountDir: string; chromeUp: boolean }): SpecialistRegistryReport` where `SpecialistRegistryReport = { lines: string[]; registered: number; missing: number; disabled: number }`.
197
+
198
+ - [ ] **Step 1: Write the failing test**
199
+
200
+ Create `platform/plugins/admin/mcp/src/__tests__/specialist-registry.test.ts`:
201
+
202
+ ```ts
203
+ // Task 2016 — an agent the operator disabled reads as disabled, not as a
204
+ // missing file. A `missing file` line for a name in the store is the failure
205
+ // this suite exists to catch.
206
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
207
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
208
+ import { tmpdir } from "node:os";
209
+ import { join } from "node:path";
210
+ import { classifySpecialists } from "../specialist-registry.js";
211
+
212
+ let accountDir: string;
213
+
214
+ const AGENTS_MD = [
215
+ "# Installed Roles",
216
+ "",
217
+ "- **specialists:live-one**: Does live work.",
218
+ "- **specialists:switched-off**: Was switched off.",
219
+ "- **specialists:never-arrived**: Never delivered.",
220
+ "",
221
+ ].join("\n");
222
+
223
+ beforeEach(() => {
224
+ accountDir = mkdtempSync(join(tmpdir(), "spec-registry-"));
225
+ mkdirSync(join(accountDir, "specialists", "agents"), { recursive: true });
226
+ writeFileSync(join(accountDir, "specialists", "agents", "live-one.md"), "---\nname: live-one\n---\n");
227
+ writeFileSync(
228
+ join(accountDir, "agents-disabled.json"),
229
+ JSON.stringify({ disabled: ["switched-off.md"] }),
230
+ );
231
+ });
232
+
233
+ afterEach(() => rmSync(accountDir, { recursive: true, force: true }));
234
+
235
+ describe("classifySpecialists", () => {
236
+ const run = (chromeUp = true) =>
237
+ classifySpecialists({
238
+ agentsMd: AGENTS_MD,
239
+ specialistsAgentsDir: join(accountDir, "specialists", "agents"),
240
+ accountDir,
241
+ chromeUp,
242
+ });
243
+
244
+ it("reports a live file as ok", () => {
245
+ expect(run().lines).toContain(" live-one: ok");
246
+ });
247
+
248
+ it("reports a store name as disabled, never as a missing file", () => {
249
+ const report = run();
250
+ expect(report.lines).toContain(" switched-off: disabled (operator)");
251
+ expect(report.lines.join("\n")).not.toMatch(/switched-off: missing file/);
252
+ });
253
+
254
+ it("reports an absent file the store does not name as a missing file", () => {
255
+ expect(run().lines).toContain(" never-arrived: missing file");
256
+ });
257
+
258
+ it("counts missing and disabled separately", () => {
259
+ const report = run();
260
+ expect(report.registered).toBe(3);
261
+ expect(report.missing).toBe(1);
262
+ expect(report.disabled).toBe(1);
263
+ });
264
+
265
+ it("keeps the chrome degradation for a live personal-assistant", () => {
266
+ writeFileSync(join(accountDir, "specialists", "agents", "personal-assistant.md"), "---\n");
267
+ const report = classifySpecialists({
268
+ agentsMd: "- **specialists:personal-assistant**: Browses.\n",
269
+ specialistsAgentsDir: join(accountDir, "specialists", "agents"),
270
+ accountDir,
271
+ chromeUp: false,
272
+ });
273
+ expect(report.lines).toContain(" personal-assistant: degraded — chrome not running");
274
+ expect(report.missing).toBe(0);
275
+ });
276
+
277
+ it("treats an unreadable store as naming nothing", () => {
278
+ writeFileSync(join(accountDir, "agents-disabled.json"), "{ not json");
279
+ const report = run();
280
+ expect(report.lines).toContain(" switched-off: missing file");
281
+ expect(report.disabled).toBe(0);
282
+ expect(report.missing).toBe(2);
283
+ });
284
+ });
285
+ ```
286
+
287
+ - [ ] **Step 2: Run the test to verify it fails**
288
+
289
+ Run: `cd platform/plugins/admin/mcp && npx vitest run src/__tests__/specialist-registry.test.ts`
290
+ Expected: FAIL — `Failed to resolve import "../specialist-registry.js"`.
291
+
292
+ - [ ] **Step 3: Write the module**
293
+
294
+ Create `platform/plugins/admin/mcp/src/specialist-registry.ts`:
295
+
296
+ ```ts
297
+ // Task 2016 — the specialist half of `system-status`.
298
+ //
299
+ // Disabling an agent MOVES its file out of `specialists/agents/` (Task 1996),
300
+ // so a walk of AGENTS.md against that directory sees an absent file and, before
301
+ // this module, called it `missing file` — an operator action rendered as an
302
+ // install fault. The disabled store is what tells the two apart.
303
+ import { existsSync, readFileSync } from "node:fs";
304
+ import { join, resolve } from "node:path";
305
+
306
+ export interface SpecialistRegistryReport {
307
+ /** One ` <name>: <status>` line per AGENTS.md entry, in file order. */
308
+ lines: string[];
309
+ registered: number;
310
+ missing: number;
311
+ disabled: number;
312
+ }
313
+
314
+ /** Basenames the operator switched off. An unreadable store names nothing:
315
+ * a corrupt file is not evidence that an agent was disabled, and the absent
316
+ * file stays `missing file`, which is the honest answer when the store cannot
317
+ * be read. */
318
+ function readDisabledStore(accountDir: string): Set<string> {
319
+ const p = resolve(accountDir, "agents-disabled.json");
320
+ if (!existsSync(p)) return new Set();
321
+ try {
322
+ const parsed = JSON.parse(readFileSync(p, "utf-8")) as { disabled?: unknown };
323
+ if (!Array.isArray(parsed.disabled)) return new Set();
324
+ return new Set(parsed.disabled.filter((x): x is string => typeof x === "string"));
325
+ } catch {
326
+ return new Set();
327
+ }
328
+ }
329
+
330
+ export function classifySpecialists(opts: {
331
+ agentsMd: string;
332
+ specialistsAgentsDir: string;
333
+ accountDir: string;
334
+ chromeUp: boolean;
335
+ }): SpecialistRegistryReport {
336
+ const entries = opts.agentsMd.match(/^- \*\*specialists:([^*]+)\*\*/gm) ?? [];
337
+ const disabled = readDisabledStore(opts.accountDir);
338
+ const lines: string[] = [];
339
+ let missingCount = 0;
340
+ let disabledCount = 0;
341
+
342
+ for (const entry of entries) {
343
+ const nameMatch = entry.match(/specialists:([^*]+)/);
344
+ if (!nameMatch) continue;
345
+ const name = nameMatch[1];
346
+ let status: string;
347
+ if (existsSync(join(opts.specialistsAgentsDir, `${name}.md`))) {
348
+ status = name === "personal-assistant" && !opts.chromeUp
349
+ ? "degraded — chrome not running"
350
+ : "ok";
351
+ } else if (disabled.has(`${name}.md`)) {
352
+ status = "disabled (operator)";
353
+ disabledCount++;
354
+ } else {
355
+ status = "missing file";
356
+ missingCount++;
357
+ }
358
+ lines.push(` ${name}: ${status}`);
359
+ }
360
+
361
+ return { lines, registered: lines.length, missing: missingCount, disabled: disabledCount };
362
+ }
363
+ ```
364
+
365
+ - [ ] **Step 4: Run the test to verify it passes**
366
+
367
+ Run: `cd platform/plugins/admin/mcp && npx vitest run src/__tests__/specialist-registry.test.ts`
368
+ Expected: PASS, 6 tests.
369
+
370
+ - [ ] **Step 5: Call the module from `index.ts`**
371
+
372
+ In `platform/plugins/admin/mcp/src/index.ts`, add the import beside the other local imports (near line 23):
373
+
374
+ ```ts
375
+ import { classifySpecialists } from "./specialist-registry.js";
376
+ ```
377
+
378
+ Replace the body of the `else` branch at lines 515-534 (everything from `const content = readFileSync(agentsMdPath, "utf-8");` to the closing brace of that `else`) with:
379
+
380
+ ```ts
381
+ const content = readFileSync(agentsMdPath, "utf-8");
382
+ const report = classifySpecialists({
383
+ agentsMd: content,
384
+ specialistsAgentsDir,
385
+ accountDir,
386
+ chromeUp,
387
+ });
388
+ if (report.registered === 0) {
389
+ checks.specialists = "not configured (AGENTS.md empty)";
390
+ } else {
391
+ checks.specialists =
392
+ `${report.registered} registered, missing=${report.missing} disabled=${report.disabled}\n` +
393
+ report.lines.join("\n");
394
+ }
395
+ ```
396
+
397
+ - [ ] **Step 6: Build the plugin**
398
+
399
+ Run: `cd platform && npm run build:admin`
400
+ Expected: exit 0, no TypeScript errors.
401
+
402
+ - [ ] **Step 7: Commit**
403
+
404
+ ```bash
405
+ git add maxy-code/platform/plugins/admin/mcp/src/specialist-registry.ts maxy-code/platform/plugins/admin/mcp/src/__tests__/specialist-registry.test.ts maxy-code/platform/plugins/admin/mcp/src/index.ts
406
+ git commit -m "$(cat <<'EOF'
407
+ fix(2016): system-status reports a disabled agent as disabled, not missing
408
+
409
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410
+ Session: a2b8f556-8297-4184-bf57-59b078a8015d
411
+ EOF
412
+ )"
413
+ ```
414
+
415
+ ---
416
+
417
+ ### Task 3: Disable and enable reconcile the routing prose immediately
418
+
419
+ **Files:**
420
+ - Modify: `platform/ui/server/routes/admin/agents.ts`
421
+ - Modify: `platform/ui/server/routes/admin/__tests__/agents.test.ts`
422
+ - Modify: `platform/plugins/docs/references/admin-ui.md:209-241`
423
+
424
+ **Interfaces:**
425
+ - Consumes: `bash <PLATFORM_ROOT>/scripts/lib/agents-md-bootstrap.sh <accountDir>` from Task 1.
426
+ - Produces: `POST /disable` returns `{ok, moved, routingReconciled}`; `POST /enable` returns `{ok, restored, routingReconciled}`.
427
+
428
+ - [ ] **Step 1: Write the failing test**
429
+
430
+ In `platform/ui/server/routes/admin/__tests__/agents.test.ts`, the harness already builds a temp `platformRoot` in its `beforeEach`. Add, after the existing `templates/specialists/agents` setup in that same `beforeEach`, a copy of the real bootstrap script so the route execs shipped code rather than a stub:
431
+
432
+ ```ts
433
+ // Task 2016 — the disable path execs the real bootstrap out of PLATFORM_ROOT.
434
+ // Five levels up from __tests__ is maxy-code/platform, the same walk
435
+ // no-retired-opus-string.test.ts:18 uses.
436
+ mkdirSync(resolve(platformRoot, 'scripts', 'lib'), { recursive: true })
437
+ cpSync(
438
+ resolve(__dirname, '..', '..', '..', '..', '..', 'scripts', 'lib', 'agents-md-bootstrap.sh'),
439
+ resolve(platformRoot, 'scripts', 'lib', 'agents-md-bootstrap.sh'),
440
+ )
441
+ ```
442
+
443
+ Add `cpSync` to the `node:fs` import list at the top of the file.
444
+
445
+ Then add this case at the end of the existing `describe('disable and enable (Task 1996)', …)` block, so it reuses that block's `writeShipped`, `disable` and `enable` helpers (which already target `/api/admin/agents/<slug>/…?accountId=acct-B`):
446
+
447
+ ```ts
448
+ // Task 2016 — the routing prose has to follow the file. Before this, the
449
+ // append-only bootstrap kept advertising a disabled specialist and the admin
450
+ // dispatched to a file that was no longer there.
451
+ it('drops the routing line on disable and restores it on enable', async () => {
452
+ writeShipped('citation-auditor', { name: 'citation-auditor', description: 'Audits citations.', tools: 'Read' })
453
+ writeShipped('librarian', { name: 'librarian', description: 'Keeps the library.', tools: 'Read' })
454
+ mkdirSync(resolve(accountDir, 'agents', 'admin'), { recursive: true })
455
+ writeFileSync(
456
+ resolve(accountDir, 'agents', 'admin', 'AGENTS.md'),
457
+ '# Installed Roles\n\n- **specialists:citation-auditor**: Audits citations.\n- **specialists:librarian**: Keeps the library.\n',
458
+ )
459
+ const agentsMd = () => readFileSync(resolve(accountDir, 'agents', 'admin', 'AGENTS.md'), 'utf-8')
460
+
461
+ const off = await disable('citation-auditor')
462
+ expect(off.status).toBe(200)
463
+ expect(await off.json()).toMatchObject({ ok: true, moved: true, routingReconciled: true })
464
+ expect(agentsMd()).not.toContain('- **specialists:citation-auditor**:')
465
+ expect(agentsMd()).toContain('- **specialists:librarian**:')
466
+
467
+ const on = await enable('citation-auditor')
468
+ expect(on.status).toBe(200)
469
+ expect(await on.json()).toMatchObject({ ok: true, restored: true, routingReconciled: true })
470
+ expect(agentsMd()).toContain('- **specialists:citation-auditor**:')
471
+ })
472
+ ```
473
+
474
+ - [ ] **Step 2: Run the test to verify it fails**
475
+
476
+ Run: `cd platform/ui && npx vitest run server/routes/admin/__tests__/agents.test.ts -t 'routing prose'`
477
+ Expected: FAIL — the response has no `routingReconciled` field and AGENTS.md still contains the `citation-auditor` line.
478
+
479
+ - [ ] **Step 3: Implement the reconcile call**
480
+
481
+ In `platform/ui/server/routes/admin/agents.ts`, add to the imports:
482
+
483
+ ```ts
484
+ import { execFileSync } from 'node:child_process'
485
+ ```
486
+
487
+ Add this helper immediately above the `POST /:slug/disable` handler:
488
+
489
+ ```ts
490
+ /** Re-derive the admin's routing prose from disk after a disable or an enable.
491
+ *
492
+ * `agents-md-bootstrap.sh` is the single implementation of what belongs in
493
+ * AGENTS.md, so the route calls it rather than editing the file itself. It runs
494
+ * after the move, so enable needs no restore path of its own: the file is live
495
+ * again and the script's append loop puts the line back.
496
+ *
497
+ * A failure here does not fail the request. The move and the store write already
498
+ * happened and dispatch is already stopped, so reporting the whole disable as
499
+ * failed would be false. The caller is told the truth instead, through
500
+ * `routingReconciled`. */
501
+ function reconcileRoutingProse(accountDir: string, accountId: string, op: string, slug: string): boolean {
502
+ const script = resolve(PLATFORM_ROOT, 'scripts', 'lib', 'agents-md-bootstrap.sh')
503
+ try {
504
+ execFileSync('bash', [script, accountDir], { encoding: 'utf-8', timeout: 10_000 })
505
+ return true
506
+ } catch (err) {
507
+ const msg = err instanceof Error ? err.message : String(err)
508
+ console.error(`[admin/agents] op=agents-md-reconcile source=${op} accountId=${accountId.slice(0, 8)} slug=${slug} outcome=failed reason="${msg}"`)
509
+ return false
510
+ }
511
+ }
512
+ ```
513
+
514
+ In the disable handler, replace the two closing lines (currently `console.log(… outcome=ok moved=${moved})` and `return c.json({ ok: true, moved })`) with:
515
+
516
+ ```ts
517
+ const routingReconciled = reconcileRoutingProse(resolved.accountDir, resolved.accountId, 'disable', slug)
518
+ console.log(`[admin/agents] op=disable accountId=${resolved.accountId.slice(0, 8)} slug=${slug} outcome=ok moved=${moved} routingReconciled=${routingReconciled}`)
519
+ return c.json({ ok: true, moved, routingReconciled })
520
+ ```
521
+
522
+ In the enable handler, replace its two closing lines the same way:
523
+
524
+ ```ts
525
+ const routingReconciled = reconcileRoutingProse(resolved.accountDir, resolved.accountId, 'enable', slug)
526
+ console.log(`[admin/agents] op=enable accountId=${resolved.accountId.slice(0, 8)} slug=${slug} outcome=ok restored=${restored} routingReconciled=${routingReconciled}`)
527
+ return c.json({ ok: true, restored, routingReconciled })
528
+ ```
529
+
530
+ - [ ] **Step 4: Run the tests to verify they pass**
531
+
532
+ Run: `cd platform/ui && npx vitest run server/routes/admin/__tests__/agents.test.ts`
533
+ Expected: PASS, including every pre-existing case in that file.
534
+
535
+ - [ ] **Step 5: Update the reference doc**
536
+
537
+ In `platform/plugins/docs/references/admin-ui.md`, after the paragraph ending `a corrupt file is not evidence that an agent was disabled.` (line 224), add:
538
+
539
+ ```markdown
540
+ Disabling also reconciles the admin's routing prose. `agents/admin/AGENTS.md`
541
+ carries one `- **specialists:<name>**:` line per dispatchable specialist, and
542
+ `agents-md-bootstrap.sh` used to only ever append, so a disabled agent stayed
543
+ advertised and the admin dispatched to a file that was no longer there. The
544
+ script now withholds a line for every name in the store, and both routes exec it
545
+ after the move, so the prose is right immediately rather than after the next
546
+ provisioning run. It emits `[agents-md] op=specialist-lines account=<id8>
547
+ kept=<n> withheld=<comma-list|none|store-unreadable>`.
548
+ ```
549
+
550
+ In the same file, replace the last sentence of the disable row (`Same \`accountId\` contract as delete.`) with:
551
+
552
+ ```markdown
553
+ Returns `routingReconciled`, which is `false` when the AGENTS.md reconcile threw — the move and the store write already happened, so the request is not failed, and the caller is told rather than left assuming the prose is correct. Same `accountId` contract as delete.
554
+ ```
555
+
556
+ And append to the enable row, after `404 only when neither directory nor store knows the agent.`:
557
+
558
+ ```markdown
559
+ Carries the same `routingReconciled` field; the restored file is what puts the routing line back.
560
+ ```
561
+
562
+ - [ ] **Step 6: Commit**
563
+
564
+ ```bash
565
+ git add maxy-code/platform/ui/server/routes/admin/agents.ts maxy-code/platform/ui/server/routes/admin/__tests__/agents.test.ts maxy-code/platform/plugins/docs/references/admin-ui.md
566
+ git commit -m "$(cat <<'EOF'
567
+ fix(2016): disable and enable reconcile AGENTS.md immediately
568
+
569
+ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
570
+ Session: a2b8f556-8297-4184-bf57-59b078a8015d
571
+ EOF
572
+ )"
573
+ ```
574
+
575
+ ---
576
+
577
+ ### Task 4: Whole-surface verification
578
+
579
+ **Files:** none modified. This task runs the checks that prove the three surfaces agree.
580
+
581
+ - [ ] **Step 1: Shell suite**
582
+
583
+ Run: `bash platform/scripts/__tests__/agents-md-bootstrap.test.sh`
584
+ Expected: `FAIL=0`.
585
+
586
+ - [ ] **Step 2: Admin MCP suite and type check**
587
+
588
+ Run: `cd platform && npm run build:admin && cd plugins/admin/mcp && npx vitest run`
589
+ Expected: build exit 0, every test file passes.
590
+
591
+ - [ ] **Step 3: Route suite and type check**
592
+
593
+ Run: `cd platform/ui && npx tsc --noEmit && npx vitest run server/routes/admin/__tests__/agents.test.ts`
594
+ Expected: 0 type errors, all tests pass.
595
+
596
+ - [ ] **Step 4: End-to-end observability check on a temp account**
597
+
598
+ ```bash
599
+ ACCT=$(mktemp -d)
600
+ mkdir -p "$ACCT/specialists/agents" "$ACCT/agents/admin"
601
+ printf -- '---\nname: citation-auditor\ndescription: "Audits citations."\n---\n' > "$ACCT/specialists/agents/citation-auditor.md"
602
+ printf -- '---\nname: librarian\ndescription: "Keeps the library."\n---\n' > "$ACCT/specialists/agents/librarian.md"
603
+ bash platform/scripts/lib/agents-md-bootstrap.sh "$ACCT"
604
+ mkdir -p "$ACCT/specialists/agents-disabled"
605
+ mv "$ACCT/specialists/agents/citation-auditor.md" "$ACCT/specialists/agents-disabled/"
606
+ printf '{"disabled":["citation-auditor.md"]}\n' > "$ACCT/agents-disabled.json"
607
+ bash platform/scripts/lib/agents-md-bootstrap.sh "$ACCT"
608
+ grep -c 'specialists:citation-auditor' "$ACCT/agents/admin/AGENTS.md" || echo "no routing line — correct"
609
+ ```
610
+
611
+ Expected: the second bootstrap prints `withheld=citation-auditor kept=1`, and the grep finds no line.
612
+
613
+ - [ ] **Step 5: Commit nothing**
614
+
615
+ This task changes no files. If any check failed, fix it in the task that owns the file and re-run.
616
+
617
+ ---
618
+
619
+ ## Self-review
620
+
621
+ - Spec section "Surface 1" → Task 1. "Surface 2" → Task 2. "Surface 3" → Task 3. "Tests" → the test steps inside Tasks 1-3 plus Task 4. "Out of scope" → nothing implemented against it.
622
+ - Success criterion 1 (disabling removes the line) → Task 1 Step 1 case 7, Task 3 Step 1. Criterion 2 (re-enabling restores it) → Task 1 case 8, Task 3 Step 1. Criterion 3 (`system-status` separates the two) → Task 2 Step 1.
623
+ - Names used consistently across tasks: `classifySpecialists`, `SpecialistRegistryReport`, `reconcileRoutingProse`, `routingReconciled`, `op=specialist-lines`, `op=agents-md-reconcile`.
624
+ - No placeholders: every code step carries the code, every run step carries the command and the expected result.