@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
@@ -0,0 +1,14 @@
1
+ export interface SpecialistRegistryReport {
2
+ /** One ` <name>: <status>` line per AGENTS.md entry, in file order. */
3
+ lines: string[];
4
+ registered: number;
5
+ missing: number;
6
+ disabled: number;
7
+ }
8
+ export declare function classifySpecialists(opts: {
9
+ agentsMd: string;
10
+ specialistsAgentsDir: string;
11
+ accountDir: string;
12
+ chromeUp: boolean;
13
+ }): SpecialistRegistryReport;
14
+ //# sourceMappingURL=specialist-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specialist-registry.d.ts","sourceRoot":"","sources":["../src/specialist-registry.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACnB,GAAG,wBAAwB,CA4B3B"}
@@ -0,0 +1,57 @@
1
+ // Task 2016 — the specialist half of `system-status`.
2
+ //
3
+ // Disabling an agent MOVES its file out of `specialists/agents/` (Task 1996),
4
+ // so a walk of AGENTS.md against that directory sees an absent file and, before
5
+ // this module, called it `missing file` — an operator action rendered as an
6
+ // install fault. The disabled store is what tells the two apart.
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ /** Basenames the operator switched off. An unreadable store names nothing: a
10
+ * corrupt file is not evidence that an agent was disabled, and the absent file
11
+ * stays `missing file`, which is the honest answer when the store cannot be
12
+ * read. */
13
+ function readDisabledStore(accountDir) {
14
+ const p = resolve(accountDir, "agents-disabled.json");
15
+ if (!existsSync(p))
16
+ return new Set();
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
19
+ if (!Array.isArray(parsed.disabled))
20
+ return new Set();
21
+ return new Set(parsed.disabled.filter((x) => typeof x === "string"));
22
+ }
23
+ catch {
24
+ return new Set();
25
+ }
26
+ }
27
+ export function classifySpecialists(opts) {
28
+ const entries = opts.agentsMd.match(/^- \*\*specialists:([^*]+)\*\*/gm) ?? [];
29
+ const disabled = readDisabledStore(opts.accountDir);
30
+ const lines = [];
31
+ let missingCount = 0;
32
+ let disabledCount = 0;
33
+ for (const entry of entries) {
34
+ const nameMatch = entry.match(/specialists:([^*]+)/);
35
+ if (!nameMatch)
36
+ continue;
37
+ const name = nameMatch[1];
38
+ let status;
39
+ if (existsSync(join(opts.specialistsAgentsDir, `${name}.md`))) {
40
+ status =
41
+ name === "personal-assistant" && !opts.chromeUp
42
+ ? "degraded — chrome not running"
43
+ : "ok";
44
+ }
45
+ else if (disabled.has(`${name}.md`)) {
46
+ status = "disabled (operator)";
47
+ disabledCount++;
48
+ }
49
+ else {
50
+ status = "missing file";
51
+ missingCount++;
52
+ }
53
+ lines.push(` ${name}: ${status}`);
54
+ }
55
+ return { lines, registered: lines.length, missing: missingCount, disabled: disabledCount };
56
+ }
57
+ //# sourceMappingURL=specialist-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"specialist-registry.js","sourceRoot":"","sources":["../src/specialist-registry.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,4EAA4E;AAC5E,iEAAiE;AACjE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAU1C;;;YAGY;AACZ,SAAS,iBAAiB,CAAC,UAAkB;IAC3C,MAAM,CAAC,GAAG,OAAO,CAAC,UAAU,EAAE,sBAAsB,CAAC,CAAC;IACtD,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,GAAG,EAAE,CAAC;IACrC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAA2B,CAAC;QAC9E,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC;YAAE,OAAO,IAAI,GAAG,EAAE,CAAC;QACtD,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC;IACpF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,GAAG,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAKnC;IACC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,kCAAkC,CAAC,IAAI,EAAE,CAAC;IAC9E,MAAM,QAAQ,GAAG,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACpD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,YAAY,GAAG,CAAC,CAAC;IACrB,IAAI,aAAa,GAAG,CAAC,CAAC;IAEtB,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;QACrD,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,IAAI,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QAC1B,IAAI,MAAc,CAAC;QACnB,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,oBAAoB,EAAE,GAAG,IAAI,KAAK,CAAC,CAAC,EAAE,CAAC;YAC9D,MAAM;gBACJ,IAAI,KAAK,oBAAoB,IAAI,CAAC,IAAI,CAAC,QAAQ;oBAC7C,CAAC,CAAC,+BAA+B;oBACjC,CAAC,CAAC,IAAI,CAAC;QACb,CAAC;aAAM,IAAI,QAAQ,CAAC,GAAG,CAAC,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC;YACtC,MAAM,GAAG,qBAAqB,CAAC;YAC/B,aAAa,EAAE,CAAC;QAClB,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,cAAc,CAAC;YACxB,YAAY,EAAE,CAAC;QACjB,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,KAAK,MAAM,EAAE,CAAC,CAAC;IACrC,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC;AAC7F,CAAC"}
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:86e768fb27dbcb38fc304ace12dbca3462943fcb58435cea6925ebf2469a0647
4
+ content-hash: sha256:414b3a1a40f8b871635c0746cd30d4ea4203fd9baa8f4727c435c75c01f9bc2a
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -3176,6 +3176,18 @@ and removes anything named in it, so an upgrade does not silently re-deliver a
3176
3176
  disabled agent. An unreadable store withholds nothing and says so: a corrupt
3177
3177
  file is not evidence that an agent was disabled.
3178
3178
 
3179
+ Disabling also reconciles the admin's routing prose. `agents/admin/AGENTS.md`
3180
+ carries one `- **specialists:<name>**:` line per dispatchable specialist, and
3181
+ `agents-md-bootstrap.sh` used to only ever append, so a disabled agent stayed
3182
+ advertised and the admin dispatched to a file that was no longer there. The
3183
+ script now withholds a line for every name in the store, and both routes exec it
3184
+ after the move, so the prose is right immediately rather than after the next
3185
+ provisioning run. It emits `[agents-md] op=specialist-lines account=<id8>
3186
+ kept=<n> withheld=<comma-list|none|store-unreadable>`. `system-status` reads the
3187
+ same store, so a disabled agent reports `disabled (operator)` rather than
3188
+ `missing file`, and the header counts the two separately
3189
+ (`<n> registered, missing=<x> disabled=<y>`).
3190
+
3179
3191
  **Account scope.** The route (`server/routes/admin/agents.ts`) never infers an
3180
3192
  account from device state. The two reads resolve from the caller's admin session
3181
3193
  (`requireAdminSession` + the shared `accountDirForSession` in
@@ -3191,8 +3203,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
3191
3203
  |-------|-----------|
3192
3204
  | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
3193
3205
  | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
3194
- | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
3195
- | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
3206
+ | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. 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. |
3207
+ | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. Carries the same `routingReconciled` field; the restored file is what puts the routing line back. |
3196
3208
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
3197
3209
  | `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |
3198
3210
 
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-07-27 (0.1.515)
13
+
14
+ - Disabling an agent now takes effect straight away: it is dropped from the routing table, reported as disabled rather than missing, and the agents file is reconciled on both disable and enable.
15
+ - Scheduled WhatsApp messages and live incoming messages now work out which account they belong to through the same code, so a scheduled send cannot land on a different account than a live reply would.
16
+ - When an account folder cannot be read at startup, the log now reports the actual error and names the files that failed to parse, instead of reporting that there are no accounts.
17
+
12
18
  ## 2026-07-27 (0.1.514)
13
19
 
14
20
  - Three delete and danger buttons had no visible hover state, because their hover colour resolved to white. They now have a real hover step on the colour ramp.
@@ -223,6 +223,18 @@ and removes anything named in it, so an upgrade does not silently re-deliver a
223
223
  disabled agent. An unreadable store withholds nothing and says so: a corrupt
224
224
  file is not evidence that an agent was disabled.
225
225
 
226
+ Disabling also reconciles the admin's routing prose. `agents/admin/AGENTS.md`
227
+ carries one `- **specialists:<name>**:` line per dispatchable specialist, and
228
+ `agents-md-bootstrap.sh` used to only ever append, so a disabled agent stayed
229
+ advertised and the admin dispatched to a file that was no longer there. The
230
+ script now withholds a line for every name in the store, and both routes exec it
231
+ after the move, so the prose is right immediately rather than after the next
232
+ provisioning run. It emits `[agents-md] op=specialist-lines account=<id8>
233
+ kept=<n> withheld=<comma-list|none|store-unreadable>`. `system-status` reads the
234
+ same store, so a disabled agent reports `disabled (operator)` rather than
235
+ `missing file`, and the header counts the two separately
236
+ (`<n> registered, missing=<x> disabled=<y>`).
237
+
226
238
  **Account scope.** The route (`server/routes/admin/agents.ts`) never infers an
227
239
  account from device state. The two reads resolve from the caller's admin session
228
240
  (`requireAdminSession` + the shared `accountDirForSession` in
@@ -238,8 +250,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
238
250
  |-------|-----------|
239
251
  | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
240
252
  | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
241
- | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
242
- | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
253
+ | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. 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. |
254
+ | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. Carries the same `routingReconciled` field; the restored file is what puts the routing line back. |
243
255
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
244
256
  | `POST /api/admin/agents/:slug/project?accountId=` | Re-projects the named account's on-disk agent into the graph. Same `accountId` contract as delete. |
245
257
 
@@ -114,7 +114,7 @@ The platform enforces this at multiple levels:
114
114
  |------|-----------|------|
115
115
  | **Self phone** | The WhatsApp account's own phone number (the phone that scanned the QR code). Self-chat only. | `phonesMatch(senderPhone, selfPhone)` in `access-control.ts` |
116
116
  | **Admin phones** | Phones listed in `adminPhones` in `account.json`. The user's personal phone is auto-registered on QR link; additional phones are added via `whatsapp-config action: "add-admin-phone"`. Their admin session is scoped to the **house** account. `adminPhones` is authoritative only on the account that owns the WhatsApp paired socket (the house): `add-admin-phone` is refused on any other account, and a list left on a non-socket (sub-)account is inert and purged at boot with `[admin-identity] op=purge-nonsocket-adminphone`. To answer "is this phone a manager?" read the house account's `adminPhones` + `accountManagers` only — never a sub-account's list. | `isAdminPhone()` in `access-control.ts` — iterates `adminPhones` |
117
- | **Account managers** | Phones in the `accountManagers` map (`account.json`, phone → sub-account binding), added via `whatsapp-config action: "add-account-manager"`. Scoped to the **bound sub-account**, not the house. Disjoint from `adminPhones` — a phone is a house admin or a sub-account manager, never both. Each binding has a **mode**: `active` (default) is a full admin session that can reply and manage the sub-account; `passive` is a constrained requirement-intake spawn (only `work-create`, no reply). | `managedAccountFor()` in `access-control.ts` resolves the sub-account + mode **once**; `checkDmAccess` returns `effectiveAccountId` + `passive`, and `ensureChannelSession` consumes them without re-resolving |
117
+ | **Account managers** | Phones in the `accountManagers` map (`account.json`, phone → sub-account binding), added via `whatsapp-config action: "add-account-manager"`. Scoped to the **bound sub-account**, not the house. Disjoint from `adminPhones` — a phone is a house admin or a sub-account manager, never both. Each binding has a **mode**: `active` (default) is a full admin session that can reply and manage the sub-account; `passive` is a constrained requirement-intake spawn (only `work-create`, no reply). Like `adminPhones`, the map is authoritative only on the account that owns the paired socket: `add-account-manager` is refused on any other account with `reason: "not-socket-account"` (a binding that also names the house reports `house-account-binding` instead — the value check runs first), and a map left on a non-socket account is inert and purged at boot with `[admin-identity] op=purge-nonsocket-accountmanager`. That boot purge stands down, logging `resolver disagreement`, when the account the manager loads its config from is not the `role:"house"` account, because there the "non-socket" map is the live one. | `managedAccountFor()` in `access-control.ts` resolves the sub-account + mode **once**; `checkDmAccess` returns `effectiveAccountId` + `passive`, and `ensureChannelSession` consumes them without re-resolving |
118
118
  | **Public/unknown** | Any phone that is not self, not in `adminPhones`, and not in `accountManagers`. Subject to DM policy gating. | Everything else in `checkDmAccess()` |
119
119
 
120
120
  **Critical distinction:** The *self phone* is the paired device's number (the WhatsApp account SiteDesk controls). The *admin phone* is the user's personal phone (the phone they message *from*). These are typically different numbers. If the user's personal phone is not in `adminPhones` or `accountManagers`, their messages route as public — not admin.
@@ -139,7 +139,7 @@ A passive-bound phone that DMs a requirement gets exactly one `:Task` filed unde
139
139
 
140
140
  **Single source of truth + fail-closed.** The effective account is resolved **exactly once**, in the gate: `checkDmAccess` returns `effectiveAccountId` (the house account for owner/`adminPhones`/public, the bound sub-account for a manager), and that value threads through the inbound payload → gateway → `ensureChannelSession`, which spawns into it **without re-reading** the `accountManagers` map. There is no second resolution and no `?? accountId` house fallback: if a manager's bound sub-account is not a valid account, the inbound is **rejected** (no session spawned, no reply), never routed to the house. This closes the escalation where a divergence between two independent map reads handed a scoped manager a house-owner admin session. Observable signals: `op=account-manager-route … effectiveAccount=… source=gate` on a routed manager inbound; `op=account-manager-reject … reason=unresolved-effective-account` on the fail-closed drop; a standing `op=escalation-tripwire` belt that can only fire if a future change reintroduces the divergence. The belt compares the manager's effective account against the **house** UUID, not against the account inbound persists to — on an install that sets `channelRoutingAccountId` those differ, and a manager bound to the routing target is a legitimate binding that must admit. The binding write path now refuses a house-account binding at write time, so no new one can be created; a binding written before that refusal existed is still on disk and is what the belt catches.
141
141
 
142
- **The scheduler path fails closed too.** A scheduled dispatch (`POST /api/channel/schedule-inject`) reaches the same spawn machine without going through the gate, resolving its own effective account via `effectiveAccountFor`. That resolver carries the same single-source + fail-closed shape: a non-manager destination scopes to the house (an owner/admin's real scope), a valid manager scopes to the bound sub-account, and a manager whose bound sub-account is not a valid account resolves to nothing — the route **rejects** (`op=schedule-account-manager-reject … reason=unresolved-effective-account`, HTTP 403, no spawn, no reply), never routing to the house. There is no `?? accountId` fallback on this path either. Telegram scheduled dispatch is house-only by construction (no account-manager routing), so there is nothing to fail closed there.
142
+ **The scheduler path fails closed too, and resolves the same account inbound does.** A scheduled dispatch (`POST /api/channel/schedule-inject`) reaches the same spawn machine without going through the gate, resolving its own effective account via `effectiveAccountFor`. That resolver carries the same single-source + fail-closed shape: a non-manager destination scopes to the **channel account**, a valid manager scopes to the bound sub-account, and a manager whose bound sub-account is not a valid account resolves to nothing — the route **rejects** (`op=schedule-account-manager-reject … reason=unresolved-effective-account`, HTTP 403, no spawn, no reply), never routing to the house. There is no `?? accountId` fallback on this path either. The channel account is the `channelRoutingAccountId` target when the house names a valid one and the house itself otherwise, which is exactly what a live inbound scopes to — the session id hashes the account, so a scheduled firing to a phone and that phone's own reply must resolve the same one or they become two sessions for one person. The scheduled resolver is not handed the house at all, so it cannot return it as a scope. One window is still open: the live path resolves the channel account once when the connection starts, so a repoint made while the connection is up is seen by the scheduled path first. Telegram scheduled dispatch is house-only by construction (no account-manager routing), so there is nothing to fail closed there, and Telegram scopes to the house rather than the channel account on both of its paths.
143
143
 
144
144
  **Schedule-time destination validation is a distinct, earlier layer.** The `effectiveAccountFor` resolution above runs at *fire* time to scope the spawn. Separately, when a schedule is *created or updated* (and re-checked every heartbeat by the standing audit), the scheduler validates the WhatsApp `agentDispatch` destination against the same house authority. It reads the authoritative `adminPhones` + `accountManagers` from the socket-owning **house** account — never the event's own `account.json`, whose manager map is inert — via the one shared `validateAgentDestination`. The destination's registered account (the house for an admin, the bound sub-account for a manager) must equal the account that created the schedule; the house/owner may target any registered admin or manager, a sub-account is locked to its own manager(s). A refusal names the true cause: `not-registered` (in no house list) or `cross-account destinationAccount=… scheduleAccount=…` (registered to a different account). Create-time and fire-time emit the identical reason codes, so a schedule that passes creation but later drifts is explained rather than silently dropped. This closes the bug where the validator read a sub-account's own (empty) manager map and refused a genuinely registered manager whose binding lives on the house.
145
145
 
@@ -102,6 +102,53 @@ assert_eq "$AGENTS_LINES" "$CORE_FILES" "doctrine:agents-md-line-count-equals-co
102
102
  assert_not_contains "$TMP/agents/admin/AGENTS.md" "premium--special" "doctrine:premium-plugin-agent-skipped"
103
103
  rm -rf "$TMP"
104
104
 
105
+ # Case 5 (Task 2016) — a name in the disabled store gets no routing line.
106
+ TMP=$(make_account)
107
+ bash "$BOOTSTRAP" "$TMP" >/dev/null
108
+ printf '{"disabled":["beta-specialist.md"]}\n' > "$TMP/agents-disabled.json"
109
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
110
+ assert_not_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "withhold:beta-line-gone"
111
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:alpha-specialist**: " "withhold:alpha-kept"
112
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:gamma-specialist**: " "withhold:gamma-kept"
113
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=2 withheld=beta-specialist" "withhold:log-line"
114
+
115
+ # Case 6 (Task 2016) — clearing the store restores the line on the next run.
116
+ printf '{"disabled":[]}\n' > "$TMP/agents-disabled.json"
117
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
118
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "restore:beta-back"
119
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=none" "restore:log-line"
120
+ rm -rf "$TMP"
121
+
122
+ # Case 7 (Task 2016) — a malformed store removes nothing and says so. A corrupt
123
+ # file is not evidence that an agent was disabled.
124
+ TMP=$(make_account)
125
+ bash "$BOOTSTRAP" "$TMP" >/dev/null
126
+ printf '{ not json' > "$TMP/agents-disabled.json"
127
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
128
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:alpha-specialist**: " "badstore:alpha-kept"
129
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "badstore:beta-kept"
130
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=store-unreadable" "badstore:log-line"
131
+ rm -rf "$TMP"
132
+
133
+ # Case 8 (Task 2016) — a store entry that breaks the `[a-z][a-z0-9-]*` name
134
+ # convention is ignored, never interpolated into the matcher. `.*` would
135
+ # otherwise match every specialist line and delete the whole routing table.
136
+ TMP=$(make_account)
137
+ bash "$BOOTSTRAP" "$TMP" >/dev/null
138
+ printf '{"disabled":[".*.md"]}\n' > "$TMP/agents-disabled.json"
139
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
140
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:alpha-specialist**: " "badname:alpha-kept"
141
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:beta-specialist**: " "badname:beta-kept"
142
+ assert_contains "$TMP/agents/admin/AGENTS.md" "- **specialists:gamma-specialist**: " "badname:gamma-kept"
143
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=none" "badname:log-line"
144
+ rm -rf "$TMP"
145
+
146
+ # Case 9 (Task 2016) — no store on disk is the cold-install path.
147
+ TMP=$(make_account)
148
+ OUT=$(bash "$BOOTSTRAP" "$TMP")
149
+ assert_matches "$OUT" "op=specialist-lines account=[^ ]+ kept=3 withheld=none" "nostore:log-line"
150
+ rm -rf "$TMP"
151
+
105
152
  echo
106
153
  echo "PASS: $PASS, FAIL: $FAIL"
107
154
  if [ "$FAIL" -gt 0 ]; then
@@ -10,9 +10,19 @@
10
10
  # matches `^- **specialists:<name>**:` are preserved verbatim (operator-tuned
11
11
  # lines never get clobbered); missing entries are appended.
12
12
  #
13
- # Emits one stdout observability line, two-space indented to match the
13
+ # A name in <account_dir>/agents-disabled.json (Task 2016) gets no routing line:
14
+ # an existing one is removed and the walk below skips it. The store is the
15
+ # operator's intent, so this holds even in the drift state where the file is
16
+ # still live — the standing agent-parity check is what reports that drift. An
17
+ # unreadable store withholds nothing, matching provision-account-dir.sh: a
18
+ # corrupt file is not evidence that an agent was disabled. A store entry that
19
+ # breaks the name convention below is ignored rather than interpolated: `.*`
20
+ # would otherwise match every routing line and take the whole table with it.
21
+ #
22
+ # Emits two stdout observability lines, two-space indented to match the
14
23
  # surrounding setup-account.sh log style:
15
24
  # " [admin-bootstrap] AGENTS.md specialists=<n> appended=<m> existing=<k>"
25
+ # " [agents-md] op=specialist-lines account=<id8> kept=<n> withheld=<comma-list|none|store-unreadable>"
16
26
  #
17
27
  # Names must follow the `[a-z][a-z0-9-]*` convention so direct interpolation
18
28
  # in the regex matcher is safe.
@@ -27,6 +37,48 @@ if [ ! -f "$AGENTS_MD" ]; then
27
37
  printf '# Installed Roles\n\nDispatch via the Agent tool with `subagent_type: "specialists:{name}"`.\n\n' > "$AGENTS_MD"
28
38
  fi
29
39
 
40
+ # --- Disabled-agent reconcile (Task 2016) ------------------------------------
41
+ _store="$ACCOUNT_DIR/agents-disabled.json"
42
+ _withheld_names=""
43
+ _withheld_field="none"
44
+ if [ -f "$_store" ]; then
45
+ if _withheld_names=$(python3 -c '
46
+ import json, re, sys
47
+ try:
48
+ parsed = json.load(open(sys.argv[1]))
49
+ except Exception:
50
+ raise SystemExit(1)
51
+ names = parsed.get("disabled")
52
+ if not isinstance(names, list):
53
+ raise SystemExit(1)
54
+ for n in sorted(names):
55
+ if isinstance(n, str) and n.endswith(".md") and re.fullmatch(r"[a-z][a-z0-9-]*", n[:-3]):
56
+ print(n[:-3])
57
+ ' "$_store" 2>/dev/null); then
58
+ if [ -n "$_withheld_names" ]; then
59
+ _withheld_field=$(echo "$_withheld_names" | paste -sd, -)
60
+ fi
61
+ else
62
+ _withheld_names=""
63
+ _withheld_field="store-unreadable"
64
+ fi
65
+ fi
66
+
67
+ _is_withheld() {
68
+ [ -n "$_withheld_names" ] || return 1
69
+ printf '%s\n' "$_withheld_names" | grep -qxF -- "$1"
70
+ }
71
+
72
+ if [ -n "$_withheld_names" ]; then
73
+ while IFS= read -r _w; do
74
+ [ -n "$_w" ] || continue
75
+ if grep -qE "^- \*\*specialists:${_w}\*\*:" "$AGENTS_MD"; then
76
+ grep -vE "^- \*\*specialists:${_w}\*\*:" "$AGENTS_MD" > "$AGENTS_MD.tmp" || true
77
+ mv "$AGENTS_MD.tmp" "$AGENTS_MD"
78
+ fi
79
+ done <<< "$_withheld_names"
80
+ fi
81
+
30
82
  _specialists=0
31
83
  _appended=0
32
84
  _existing=0
@@ -38,6 +90,7 @@ for specialist in "$ACCOUNT_DIR/specialists/agents/"*.md; do
38
90
  _name=$(sed -n 's/^name: *//p' "$specialist" | head -1)
39
91
  _desc=$(sed -n 's/^description: *"*//p' "$specialist" | sed 's/"$//' | head -1)
40
92
  [ -z "$_name" ] && continue
93
+ if _is_withheld "$_name"; then continue; fi
41
94
  _specialists=$((_specialists + 1))
42
95
  if grep -qE "^- \*\*specialists:${_name}\*\*:" "$AGENTS_MD"; then
43
96
  _existing=$((_existing + 1))
@@ -47,3 +100,7 @@ for specialist in "$ACCOUNT_DIR/specialists/agents/"*.md; do
47
100
  _appended=$((_appended + 1))
48
101
  done
49
102
  echo " [admin-bootstrap] AGENTS.md specialists=$_specialists appended=$_appended existing=$_existing"
103
+
104
+ _kept=$(grep -cE '^- \*\*specialists:' "$AGENTS_MD" || true)
105
+ _id8=$(basename "$ACCOUNT_DIR" | cut -c1-8)
106
+ echo " [agents-md] op=specialist-lines account=$_id8 kept=$_kept withheld=$_withheld_field"
@@ -74,12 +74,24 @@ export type AccountDirDiagnosis = {
74
74
  kind: 'resolved';
75
75
  dir: string;
76
76
  }
77
- /** No directory under `accountsRoot` holds an `account.json` including the
78
- * cases where the root is absent or unreadable. A genuinely empty registry. */
77
+ /** No directory under `accountsRoot` holds an `account.json`, and the root is
78
+ * either absent (`ENOENT`) or present and holding none. A genuinely empty
79
+ * registry, which is the installer's job to fill. `ENOENT` also covers a
80
+ * symlink at `data/accounts` pointing nowhere, which reads as absent. */
79
81
  | {
80
82
  kind: 'no-accounts';
81
83
  accountsRoot: string;
82
84
  }
85
+ /** `accountsRoot` could not be enumerated: `readdirSync` failed with a code
86
+ * other than `ENOENT`. Task 2031 — the registry is unknown, not empty, so
87
+ * its count is not zero and the installer remedy does not apply. An ownership
88
+ * or permission mismatch on `data/accounts` is the routine outcome of moving
89
+ * an install between machines. */
90
+ | {
91
+ kind: 'accounts-root-unreadable';
92
+ accountsRoot: string;
93
+ errno: string;
94
+ }
83
95
  /** No account carries `role:"house"` and more than one candidate exists, so
84
96
  * the pre-migration single-account tolerance cannot apply. */
85
97
  | {
@@ -97,8 +109,9 @@ export type AccountDirDiagnosis = {
97
109
  * managed-service model: the single `role:"house"` account, or the sole
98
110
  * account in the pre-migration window (before `setup-account.sh` stamps the
99
111
  * role). Mirrors the installer's `resolveInstallAccountId` so the runtime reads
100
- * the same account the installer wrote. Reports no accounts and the two genuine
101
- * drift conditions (zero-house with >1 candidate, multiple houses) as distinct
112
+ * the same account the installer wrote. Reports no accounts, an unreadable
113
+ * accounts root, and the two genuine drift conditions (zero-house with >1
114
+ * candidate, multiple houses) as distinct
102
115
  * kinds; the caller surfaces `account-dir-unresolved` naming the kind and
103
116
  * refuses to start, and the boot census (`[account-registry] op=census`) makes
104
117
  * the drift visible in journalctl.
@@ -114,17 +127,18 @@ export declare function diagnoseAccountDir(platformRoot: string): AccountDirDiag
114
127
  export declare function resolveAccountDir(platformRoot: string): string | null;
115
128
  /** The `account-dir-unresolved` boot-refusal message for a non-resolving
116
129
  * diagnosis. Every branch keeps the `account-dir-unresolved` token so existing
117
- * greps still hit, and carries `reason=<kind>` so the three failures are
130
+ * greps still hit, and carries `reason=<kind>` so the four failures are
118
131
  * distinguishable from the log line alone. Only `no-accounts` names the
119
132
  * installer: on the two drift branches re-running it adds another account and
120
- * deepens the fault. Throws on a resolved diagnosis there is no failure to
121
- * describe, and silently returning prose would let a caller print it. */
133
+ * deepens the fault, and on an unreadable root it cannot repair the read.
134
+ * Throws on a resolved diagnosis there is no failure to describe, and
135
+ * silently returning prose would let a caller print it. */
122
136
  export declare function formatAccountDirUnresolved(d: AccountDirDiagnosis): string;
123
- /** Registry census — house / client / total counts of the valid accounts under
124
- * `accountsRoot`. A dir counts when it holds a parseable `account.json`;
125
- * `clients` counts `role:"client"`, and an account with any other or absent
126
- * role (the pre-migration unlabelled window) is neither house nor client but
127
- * still counted in `total`. Reads roles fresh — house designation is
137
+ /** Registry census — house / client / total counts of the accounts under
138
+ * `accountsRoot`. A dir counts when it holds an `account.json`, parseable or
139
+ * not; `clients` counts `role:"client"`, and an account with any other or
140
+ * absent role (the pre-migration unlabelled window) is neither house nor
141
+ * client but still counted in `total`. Reads roles fresh — house designation is
128
142
  * runtime-mutable via the lifecycle tools. Mirrors `resolveAccountDir`'s inline
129
143
  * scan (the manager has no rootDir-clean import path for the account-enumeration
130
144
  * lib). A missing accounts root reads as an empty registry, not a throw. */
@@ -132,12 +146,34 @@ export interface RegistryCensus {
132
146
  houses: number;
133
147
  clients: number;
134
148
  total: number;
149
+ /** Task 2031 — the `account.json` files whose role could not be read, each
150
+ * with the reason. Counted in `total`, the same way a role-less `{}` already
151
+ * is, so this census and `diagnoseAccountDir` agree on how many accounts
152
+ * exist. `detail` carries the actual error rather than assuming one, so the
153
+ * log never claims a file's JSON is malformed when the file could not be
154
+ * opened at all. */
155
+ unreadable: Array<{
156
+ path: string;
157
+ detail: string;
158
+ }>;
159
+ /** Task 2031 — the errno when `accountsRoot` itself could not be enumerated,
160
+ * else null. `ENOENT` is not one: an absent root is an empty registry, so
161
+ * its zeros are facts. For any other code the counts are all zero and are
162
+ * NOT facts, and `formatRegistryCensus` renders the failure in their place. */
163
+ rootErrno: string | null;
135
164
  }
136
165
  export declare function censusAccountsRoot(accountsRoot: string): RegistryCensus;
137
166
  /** The standing census log line body. `houses != 1` is the drift signature the
138
167
  * boot emitter checks. Pure formatter so the emitter and its test share one
139
168
  * wording. */
140
169
  export declare function formatRegistryCensus(c: RegistryCensus): string;
170
+ /** The `op=census-drift` FATAL body, or null when there is no drift to report.
171
+ * Pure so the boot emitter and its test share one rule. `houses` is a fact only
172
+ * when the root was readable: on a read failure every count is zero, and
173
+ * FATAL-logging "exactly one house is required" against those zeros would
174
+ * assert a house count nobody measured (Task 2031). That case is already
175
+ * reported by `formatRegistryCensus` on the `op=census` line. */
176
+ export declare function formatCensusDrift(c: RegistryCensus, accountsRoot: string): string | null;
141
177
  /** True iff `accountId` is the install's house account, by the same resolution
142
178
  * the boot path uses (single role:"house", or the sole pre-migration account).
143
179
  * Returns false on drift (zero-house-with->1-candidate, or multiple houses),
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -137,8 +137,9 @@ function resolveLanIPv4() {
137
137
  * managed-service model: the single `role:"house"` account, or the sole
138
138
  * account in the pre-migration window (before `setup-account.sh` stamps the
139
139
  * role). Mirrors the installer's `resolveInstallAccountId` so the runtime reads
140
- * the same account the installer wrote. Reports no accounts and the two genuine
141
- * drift conditions (zero-house with >1 candidate, multiple houses) as distinct
140
+ * the same account the installer wrote. Reports no accounts, an unreadable
141
+ * accounts root, and the two genuine drift conditions (zero-house with >1
142
+ * candidate, multiple houses) as distinct
142
143
  * kinds; the caller surfaces `account-dir-unresolved` naming the kind and
143
144
  * refuses to start, and the boot census (`[account-registry] op=census`) makes
144
145
  * the drift visible in journalctl.
@@ -155,10 +156,16 @@ export function diagnoseAccountDir(platformRoot) {
155
156
  try {
156
157
  entries = readdirSync(accountsRoot);
157
158
  }
158
- catch {
159
- // absent or unreadable root indistinguishable from an empty registry to
160
- // the operator, and the installer wording is right for both.
161
- return { kind: 'no-accounts', accountsRoot };
159
+ catch (err) {
160
+ // Task 2031 `ENOENT` is the only code that means the root is absent, and
161
+ // an absent root is an empty registry. Every other code means something is
162
+ // at that path and could not be read, where reporting "no account.json
163
+ // exists, run the installer" states the opposite of the filesystem and
164
+ // prescribes a remedy that cannot repair a read failure.
165
+ const code = err.code;
166
+ if (code === 'ENOENT')
167
+ return { kind: 'no-accounts', accountsRoot };
168
+ return { kind: 'accounts-root-unreadable', accountsRoot, errno: code ?? 'unknown' };
162
169
  }
163
170
  const candidates = [];
164
171
  const houses = [];
@@ -200,11 +207,12 @@ export function resolveAccountDir(platformRoot) {
200
207
  }
201
208
  /** The `account-dir-unresolved` boot-refusal message for a non-resolving
202
209
  * diagnosis. Every branch keeps the `account-dir-unresolved` token so existing
203
- * greps still hit, and carries `reason=<kind>` so the three failures are
210
+ * greps still hit, and carries `reason=<kind>` so the four failures are
204
211
  * distinguishable from the log line alone. Only `no-accounts` names the
205
212
  * installer: on the two drift branches re-running it adds another account and
206
- * deepens the fault. Throws on a resolved diagnosis there is no failure to
207
- * describe, and silently returning prose would let a caller print it. */
213
+ * deepens the fault, and on an unreadable root it cannot repair the read.
214
+ * Throws on a resolved diagnosis there is no failure to describe, and
215
+ * silently returning prose would let a caller print it. */
208
216
  export function formatAccountDirUnresolved(d) {
209
217
  const prefix = `[claude-session-manager] account-dir-unresolved reason=${d.kind}:`;
210
218
  switch (d.kind) {
@@ -213,6 +221,12 @@ export function formatAccountDirUnresolved(d) {
213
221
  case 'no-accounts':
214
222
  return (`${prefix} no <installDir>/data/accounts/<uuid>/account.json under ${d.accountsRoot}. ` +
215
223
  'The installer (setup-account.sh / writeInstallDefaults) must run before manager start.');
224
+ case 'accounts-root-unreadable':
225
+ return (`${prefix} ${d.accountsRoot} exists but could not be read (errno=${d.errno}). ` +
226
+ 'The registry is unknown, not empty: check ownership and permissions on that ' +
227
+ 'directory and its parents, or retry if the errno is transient, then start the ' +
228
+ 'manager again. Accounts already there are untouched, ' +
229
+ 'so do not run the installer, which cannot repair a read failure.');
216
230
  case 'zero-house-ambiguous':
217
231
  return (`${prefix} ${d.candidateIds.length} accounts under ${d.accountsRoot} and none carries role:"house": ` +
218
232
  `${d.candidateIds.join(', ')}. Exactly one must be stamped role:"house" before the manager can start.`);
@@ -227,12 +241,19 @@ export function censusAccountsRoot(accountsRoot) {
227
241
  try {
228
242
  entries = readdirSync(accountsRoot);
229
243
  }
230
- catch {
231
- return { houses: 0, clients: 0, total: 0 };
244
+ catch (err) {
245
+ // Task 2031 same split as `diagnoseAccountDir`: only `ENOENT` means the
246
+ // root is absent, which is an empty registry. Any other code leaves the
247
+ // registry unknown, and reporting houses=0 clients=0 total=0 for it states
248
+ // the opposite of the filesystem on a line that re-emits every 300s.
249
+ const code = err.code;
250
+ const rootErrno = code === 'ENOENT' ? null : (code ?? 'unknown');
251
+ return { houses: 0, clients: 0, total: 0, unreadable: [], rootErrno };
232
252
  }
233
253
  let houses = 0;
234
254
  let clients = 0;
235
255
  let total = 0;
256
+ const unreadable = [];
236
257
  for (const entry of entries) {
237
258
  const configPath = join(accountsRoot, entry, 'account.json');
238
259
  if (!existsSync(configPath))
@@ -241,8 +262,17 @@ export function censusAccountsRoot(accountsRoot) {
241
262
  try {
242
263
  cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
243
264
  }
244
- catch {
245
- // unparseable account.json excluded from the registry entirely
265
+ catch (err) {
266
+ // Task 2031 — an account.json whose role cannot be read is an account
267
+ // whose role is unknown, not an account that is absent.
268
+ // `diagnoseAccountDir` already counts it as a candidate; excluding it here
269
+ // made one boot emit total=1 beside a diagnosis naming two accounts. The
270
+ // cause (parse failure or read failure) is carried, not assumed.
271
+ total++;
272
+ unreadable.push({
273
+ path: configPath,
274
+ detail: err instanceof Error ? err.message : String(err),
275
+ });
246
276
  continue;
247
277
  }
248
278
  total++;
@@ -251,13 +281,33 @@ export function censusAccountsRoot(accountsRoot) {
251
281
  else if (cfg.role === 'client')
252
282
  clients++;
253
283
  }
254
- return { houses, clients, total };
284
+ return { houses, clients, total, unreadable, rootErrno: null };
255
285
  }
256
286
  /** The standing census log line body. `houses != 1` is the drift signature the
257
287
  * boot emitter checks. Pure formatter so the emitter and its test share one
258
288
  * wording. */
259
289
  export function formatRegistryCensus(c) {
260
- return `op=census houses=${c.houses} clients=${c.clients} total=${c.total}`;
290
+ // Task 2031 an unreadable root has no counts to state, so the line reports
291
+ // the read failure instead of three zeros that would read as an empty
292
+ // registry.
293
+ if (c.rootErrno !== null)
294
+ return `op=census root-unreadable errno=${c.rootErrno}`;
295
+ return (`op=census houses=${c.houses} clients=${c.clients} total=${c.total} ` +
296
+ `unreadable=${c.unreadable.length}`);
297
+ }
298
+ /** The `op=census-drift` FATAL body, or null when there is no drift to report.
299
+ * Pure so the boot emitter and its test share one rule. `houses` is a fact only
300
+ * when the root was readable: on a read failure every count is zero, and
301
+ * FATAL-logging "exactly one house is required" against those zeros would
302
+ * assert a house count nobody measured (Task 2031). That case is already
303
+ * reported by `formatRegistryCensus` on the `op=census` line. */
304
+ export function formatCensusDrift(c, accountsRoot) {
305
+ if (c.rootErrno !== null)
306
+ return null;
307
+ if (c.houses === 1)
308
+ return null;
309
+ return (`op=census-drift FATAL houses=${c.houses} clients=${c.clients} total=${c.total} ` +
310
+ `accountsRoot=${accountsRoot} — exactly one role:"house" account is required`);
261
311
  }
262
312
  /** True iff `accountId` is the install's house account, by the same resolution
263
313
  * the boot path uses (single role:"house", or the sole pre-migration account).