@rubytech/create-maxy-code 0.1.477 → 0.1.479

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 (204) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +673 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +1244 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
  5. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
  6. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
  7. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +177 -0
  8. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
  9. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
  10. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
  11. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +97 -0
  12. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
  13. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  14. package/payload/platform/lib/graph-style/dist/index.js +22 -0
  15. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  16. package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
  17. package/payload/platform/lib/graph-style/src/index.ts +20 -0
  18. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +44 -0
  19. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/audit.d.ts +56 -0
  21. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/audit.js +34 -0
  23. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +70 -1
  25. package/payload/platform/lib/storage-broker/src/audit.ts +111 -0
  26. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  27. package/payload/platform/plugins/admin/PLUGIN.md +7 -1
  28. package/payload/platform/plugins/admin/mcp/dist/index.js +72 -0
  29. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts +94 -0
  31. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.d.ts.map +1 -0
  32. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js +229 -0
  33. package/payload/platform/plugins/admin/mcp/dist/tools/cpu-triage.js.map +1 -0
  34. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +14 -2
  35. package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
  36. package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
  37. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
  38. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
  39. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
  50. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
  61. package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
  62. package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
  63. package/payload/platform/plugins/email/PLUGIN.md +2 -1
  64. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts +2 -0
  65. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.d.ts.map +1 -0
  66. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js +92 -0
  67. package/payload/platform/plugins/email/mcp/dist/__tests__/confirm-sent-copy.test.js.map +1 -0
  68. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js +112 -12
  69. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js.map +1 -1
  70. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts +2 -0
  71. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.d.ts.map +1 -0
  72. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js +54 -0
  73. package/payload/platform/plugins/email/mcp/dist/__tests__/email-send-sent-copy.test.js.map +1 -0
  74. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js +170 -1
  75. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js.map +1 -1
  76. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts +2 -0
  77. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.d.ts.map +1 -0
  78. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js +86 -0
  79. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-note.test.js.map +1 -0
  80. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts +2 -0
  81. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.d.ts.map +1 -0
  82. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js +164 -0
  83. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-copy-sweep.test.js.map +1 -0
  84. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts +2 -0
  85. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.d.ts.map +1 -0
  86. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js +82 -0
  87. package/payload/platform/plugins/email/mcp/dist/__tests__/sent-ledger.test.js.map +1 -0
  88. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +95 -0
  89. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  90. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +223 -0
  91. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  92. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts +18 -0
  93. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.d.ts.map +1 -0
  94. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js +59 -0
  95. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-note.js.map +1 -0
  96. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts +48 -0
  97. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.d.ts.map +1 -0
  98. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js +79 -0
  99. package/payload/platform/plugins/email/mcp/dist/lib/sent-copy-sweep.js.map +1 -0
  100. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts +44 -0
  101. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.d.ts.map +1 -0
  102. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js +63 -0
  103. package/payload/platform/plugins/email/mcp/dist/lib/sent-ledger.js.map +1 -0
  104. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts +2 -0
  105. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.d.ts.map +1 -0
  106. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js +55 -0
  107. package/payload/platform/plugins/email/mcp/dist/scripts/sent-copy-sweep.js.map +1 -0
  108. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  109. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +64 -13
  110. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  111. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.d.ts.map +1 -1
  112. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js +11 -1
  113. package/payload/platform/plugins/email/mcp/dist/tools/email-reply.js.map +1 -1
  114. package/payload/platform/plugins/email/mcp/dist/tools/email-send.d.ts.map +1 -1
  115. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js +16 -3
  116. package/payload/platform/plugins/email/mcp/dist/tools/email-send.js.map +1 -1
  117. package/payload/platform/plugins/email/references/email-reference.md +36 -0
  118. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
  120. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
  121. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
  122. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts +2 -0
  123. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.d.ts.map +1 -0
  124. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js +18 -0
  125. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/sent-sweep-throttle.test.js.map +1 -0
  126. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
  127. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
  128. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
  129. package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
  130. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts +13 -0
  131. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.d.ts.map +1 -0
  132. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js +10 -0
  133. package/payload/platform/plugins/scheduling/mcp/dist/lib/sent-sweep-throttle.js.map +1 -0
  134. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +175 -0
  135. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  136. package/payload/platform/scripts/cpu-triage-run.sh +117 -0
  137. package/payload/platform/scripts/cpu-triage.sh +23 -4
  138. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts +40 -0
  139. package/payload/platform/services/claude-session-manager/dist/activity-range.d.ts.map +1 -0
  140. package/payload/platform/services/claude-session-manager/dist/activity-range.js +68 -0
  141. package/payload/platform/services/claude-session-manager/dist/activity-range.js.map +1 -0
  142. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts +40 -8
  143. package/payload/platform/services/claude-session-manager/dist/activity-rows.d.ts.map +1 -1
  144. package/payload/platform/services/claude-session-manager/dist/activity-rows.js +74 -7
  145. package/payload/platform/services/claude-session-manager/dist/activity-rows.js.map +1 -1
  146. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  147. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +2 -0
  148. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  149. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/http-server.js +9 -0
  151. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  152. package/payload/server/{chunk-56WJMBQQ.js → chunk-3XLLTG6R.js} +14 -0
  153. package/payload/server/{chunk-JECAP3Z2.js → chunk-XGNRSM57.js} +3 -0
  154. package/payload/server/maxy-edge.js +1 -1
  155. package/payload/server/public/activity.html +8 -6
  156. package/payload/server/public/assets/{AdminLoginScreens-mEKP4pDi.js → AdminLoginScreens-DkxQZnvB.js} +1 -1
  157. package/payload/server/public/assets/AdminShell-Cv_nIFGe.js +2 -0
  158. package/payload/server/public/assets/Checkbox-CsTWK8mm.js +1 -0
  159. package/payload/server/public/assets/activity-BbnTiVBT.js +1 -0
  160. package/payload/server/public/assets/{admin-BVhgfgs1.js → admin-BZY3i_Ff.js} +1 -1
  161. package/payload/server/public/assets/{browser-D-HMmHcM.js → browser-BbIiSFKr.js} +1 -1
  162. package/payload/server/public/assets/{calendar-tGWvFyFn.js → calendar-BvxeTHvQ.js} +1 -1
  163. package/payload/server/public/assets/chat-DUKxufRk.js +1 -0
  164. package/payload/server/public/assets/chevron-left-BtfJD6LL.js +1 -0
  165. package/payload/server/public/assets/data-hl1wa-Ax.js +1 -0
  166. package/payload/server/public/assets/{graph-zEw610xK.js → graph-CDSGRoeb.js} +1 -1
  167. package/payload/server/public/assets/graph-labels-C5qLwnWA.js +1 -0
  168. package/payload/server/public/assets/{maximize-2-BlTjXT_Y.js → maximize-2-CAzH8dED.js} +1 -1
  169. package/payload/server/public/assets/{operator-C7oIw2PG.js → operator-BK7r8DAB.js} +1 -1
  170. package/payload/server/public/assets/{page-CTP7OFZa.js → page-B5YngOrK.js} +1 -1
  171. package/payload/server/public/assets/{page-DL6Zsdvk.js → page-BsCS0Dmc.js} +1 -1
  172. package/payload/server/public/assets/{public-r1A9dqR_.js → public-C1Pz-IBs.js} +1 -1
  173. package/payload/server/public/assets/{rotate-ccw-CzkkKx4-.js → rotate-ccw-UDln4Tou.js} +1 -1
  174. package/payload/server/public/assets/tasks-B-cLsn3p.js +1 -0
  175. package/payload/server/public/assets/{time-entry-format-j669DgXj.js → time-entry-format-DPOBIE18.js} +1 -1
  176. package/payload/server/public/assets/{triangle-alert-BPgCgIef.js → triangle-alert-Dz-CZKo5.js} +1 -1
  177. package/payload/server/public/assets/{useCopyFeedback-BmLa2aes.js → useCopyFeedback-wtUHGohk.js} +1 -1
  178. package/payload/server/public/assets/{useSelectionMode-DnnM7A8-.js → useSelectionMode-sph7AQT5.js} +1 -1
  179. package/payload/server/public/assets/useSubAccountSwitcher-BRIluIKf.css +1 -0
  180. package/payload/server/public/assets/{useVoiceRecorder-u2dwZfen.js → useVoiceRecorder-DN4jYQ0A.js} +1 -1
  181. package/payload/server/public/brand-defaults.css +1 -0
  182. package/payload/server/public/browser.html +5 -5
  183. package/payload/server/public/calendar.html +6 -6
  184. package/payload/server/public/chat.html +12 -12
  185. package/payload/server/public/data.html +11 -11
  186. package/payload/server/public/graph.html +11 -11
  187. package/payload/server/public/index.html +14 -14
  188. package/payload/server/public/operator.html +14 -14
  189. package/payload/server/public/privacy.html +1 -0
  190. package/payload/server/public/public.html +12 -12
  191. package/payload/server/public/tasks.html +6 -5
  192. package/payload/server/public/vnc-popout.html +1 -0
  193. package/payload/server/server.js +894 -384
  194. package/payload/server/{src-JYPKMWJR.js → src-XQ63FPRE.js} +3 -1
  195. package/payload/server/public/assets/AdminShell-BwSaZ88d.js +0 -2
  196. package/payload/server/public/assets/Checkbox-CN4uQ80w.js +0 -1
  197. package/payload/server/public/assets/activity-CIQ8ozNZ.js +0 -1
  198. package/payload/server/public/assets/chat-DkttVxAz.js +0 -1
  199. package/payload/server/public/assets/chevron-left-CZ4ez9G5.js +0 -1
  200. package/payload/server/public/assets/data-P-mcmnNi.js +0 -1
  201. package/payload/server/public/assets/graph-labels-qnBleOE6.js +0 -1
  202. package/payload/server/public/assets/tasks-fIJwYFWG.js +0 -1
  203. package/payload/server/public/assets/useSubAccountSwitcher-C_E8h07P.css +0 -1
  204. /package/payload/server/public/assets/{useSubAccountSwitcher-CFa6ZAT1.js → useSubAccountSwitcher-v8LyBzZN.js} +0 -0
@@ -3,9 +3,28 @@ export interface Stray {
3
3
  kind: StrayKind;
4
4
  detail: string;
5
5
  }
6
+ /** CONCURRENCY CONTRACT — read before implementing these callbacks.
7
+ *
8
+ * reconcileStorage invokes all five through ONE Promise.all. They overlap,
9
+ * including the two registeredNames calls, which run at the same time as each
10
+ * other. So no callback may share, with any other callback or with a second
11
+ * invocation of itself, a resource that admits one operation at a time.
12
+ *
13
+ * The resource that has actually caused this is a Neo4j session: it rejects a
14
+ * query issued while one of its own is in flight. A caller that opened one
15
+ * session in the enclosing scope and used it in registeredNames made the
16
+ * second read reject, which left the standing storage audit reporting nothing
17
+ * for 802 consecutive ticks on maxy-code and 295 on sitedesk-code (Task 1813).
18
+ * Open the session inside the callback, one per invocation, and close it
19
+ * there; driver sessions come from a pool, so this is cheap.
20
+ *
21
+ * The contract is stated here rather than on reconcileStorage because this
22
+ * interface is what a caller writing an implementation is looking at, and the
23
+ * natural thing to write is the thing that breaks. */
6
24
  export interface AuditDeps {
7
25
  cfD1(): Promise<string[]>;
8
26
  cfR2(): Promise<string[]>;
27
+ /** Called twice, concurrently, once per kind. See the contract above. */
9
28
  registeredNames(kind: "d1" | "r2"): Promise<string[]>;
10
29
  accountSecretsFiles(): Promise<Array<{
11
30
  accountId: string;
@@ -16,6 +35,13 @@ export interface AuditDeps {
16
35
  export declare function reconcileStorage(deps: AuditDeps): Promise<{
17
36
  strays: Stray[];
18
37
  }>;
38
+ /** Same concurrency contract as AuditDeps above: reconcilePages invokes both
39
+ * callbacks through one Promise.all, so neither may share a single-operation
40
+ * resource with the other.
41
+ *
42
+ * That registeredProjects happens to issue exactly one query today is not the
43
+ * property that makes a shared session safe here, and relying on it would put
44
+ * the 1813 defect one added read away. */
19
45
  export interface PagesAuditDeps {
20
46
  cfProjects(): Promise<string[]>;
21
47
  registeredProjects(): Promise<string[]>;
@@ -27,4 +53,34 @@ export interface PagesReconcile {
27
53
  phantoms: string[];
28
54
  }
29
55
  export declare function reconcilePages(deps: PagesAuditDeps): Promise<PagesReconcile>;
56
+ export interface DataPortalObject {
57
+ key: string;
58
+ }
59
+ export interface DataPortalRow {
60
+ objectKey: string;
61
+ ingested: number;
62
+ uploadedAt: string;
63
+ }
64
+ export interface DataPortalReconcile {
65
+ /** DISTINCT keys, which is not the same quantity as the length of the list
66
+ * the read returned. The caller logs the lengths — "how many did I see" —
67
+ * and these are "how many are distinct". They agree on a healthy store
68
+ * (manifest.objectKey is UNIQUE and R2 keys are unique by construction), and
69
+ * giving them one name would conflate two facts that diverge exactly when
70
+ * the store is unhealthy, which is the case this audit is for. */
71
+ distinctObjects: number;
72
+ distinctRows: number;
73
+ orphanObjects: string[];
74
+ phantomRows: string[];
75
+ /** Null when nothing is uningested, or when no uningested row carries a
76
+ * parseable timestamp. Rendered `na` by the caller. */
77
+ oldestUningestedHrs: number | null;
78
+ }
79
+ /** Pure over two lists and an injected clock, so the age arithmetic is
80
+ * deterministic under test rather than dependent on when the suite runs.
81
+ *
82
+ * Unlike its two siblings this takes lists rather than callbacks: both sides
83
+ * are read per account by a caller that must catch each independently, so
84
+ * there is no fan-out here to carry a concurrency contract. */
85
+ export declare function reconcileDataPortal(objects: DataPortalObject[], rows: DataPortalRow[], nowMs: number): DataPortalReconcile;
30
86
  //# sourceMappingURL=audit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF"}
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,SAAS,GAAG,iBAAiB,GAAG,iBAAiB,GAAG,oBAAoB,CAAC;AAErF,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;;;;;uDAiBuD;AACvD,MAAM,WAAW,SAAS;IACxB,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,IAAI,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1B,yEAAyE;IACzE,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACtD,mBAAmB,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,CA8BpF;AAmBD;;;;;;2CAM2C;AAC3C,MAAM,WAAW,cAAc;IAC7B,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAChC,kBAAkB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAclF;AAoBD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;;uEAKmE;IACnE,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;4DACwD;IACxD,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;;;;gEAKgE;AAChE,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,gBAAgB,EAAE,EAC3B,IAAI,EAAE,aAAa,EAAE,EACrB,KAAK,EAAE,MAAM,GACZ,mBAAmB,CA6BrB"}
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.reconcileStorage = reconcileStorage;
9
9
  exports.reconcilePages = reconcilePages;
10
+ exports.reconcileDataPortal = reconcileDataPortal;
10
11
  // Keys that must never remain in a sub-account's cloudflare.env after
11
12
  // remediation. Each is an account-wide capability.
12
13
  const ACCOUNT_WIDE_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
@@ -54,4 +55,37 @@ async function reconcilePages(deps) {
54
55
  phantoms: [...regSet].filter((name) => !cfSet.has(name)),
55
56
  };
56
57
  }
58
+ /** Pure over two lists and an injected clock, so the age arithmetic is
59
+ * deterministic under test rather than dependent on when the suite runs.
60
+ *
61
+ * Unlike its two siblings this takes lists rather than callbacks: both sides
62
+ * are read per account by a caller that must catch each independently, so
63
+ * there is no fan-out here to carry a concurrency contract. */
64
+ function reconcileDataPortal(objects, rows, nowMs) {
65
+ const objectKeys = new Set(objects.map((o) => o.key));
66
+ const rowKeys = new Set(rows.map((r) => r.objectKey));
67
+ // Date.parse of a truthy non-ISO value is NaN, which slips past a `??` guard
68
+ // and renders as `oldestUningestedHrs=NaN` — the defect the availability
69
+ // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
70
+ // the minimum; if that leaves none, the answer is null, never NaN.
71
+ const uningestedMs = rows
72
+ .filter((r) => r.ingested === 0)
73
+ .map((r) => Date.parse(r.uploadedAt))
74
+ .filter((ms) => !Number.isNaN(ms));
75
+ // Reduced, never `Math.min(...uningestedMs)`. A spread passes one argument per
76
+ // element and blows the call stack — measured: fine at 125k, RangeError at
77
+ // 130k. One element per uningested row, and an ingestion sweep that has
78
+ // stalled accumulates those without bound. A stalled sweep is one of the three
79
+ // conditions this audit exists to reveal, so the arithmetic must not be what
80
+ // goes dark once the backlog it is watching grows large. The throw would also
81
+ // escape the caller's per-side catches and cost every later account its line.
82
+ const oldestMs = uningestedMs.reduce((min, ms) => (ms < min ? ms : min), Infinity);
83
+ return {
84
+ distinctObjects: objectKeys.size,
85
+ distinctRows: rowKeys.size,
86
+ orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
87
+ phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
88
+ oldestUningestedHrs: uningestedMs.length === 0 ? null : (nowMs - oldestMs) / 3_600_000,
89
+ };
90
+ }
57
91
  //# sourceMappingURL=audit.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { reconcileStorage, reconcilePages, type AuditDeps } from "../audit.js";
3
+ import { reconcileStorage, reconcilePages, reconcileDataPortal, type AuditDeps } from "../audit.js";
4
4
 
5
5
  const clean: AuditDeps = {
6
6
  cfD1: async () => ["gls-leads"],
@@ -78,3 +78,72 @@ test("reconcilePages is clean when every live project has exactly one owner", as
78
78
  assert.deepEqual(r.orphans, []);
79
79
  assert.deepEqual(r.phantoms, []);
80
80
  });
81
+
82
+ // --- Data portal reconcile (Task 1704) ------------------------------------
83
+
84
+ const row = (objectKey: string, ingested: number, uploadedAt: string) => ({
85
+ objectKey,
86
+ ingested,
87
+ uploadedAt,
88
+ });
89
+
90
+ test("reconcileDataPortal counts an object with no row as an orphan", () => {
91
+ const r = reconcileDataPortal([{ key: "alice/a.pdf" }], [], 0);
92
+ assert.deepEqual(r.orphanObjects, ["alice/a.pdf"]);
93
+ assert.deepEqual(r.phantomRows, []);
94
+ assert.equal(r.distinctObjects, 1);
95
+ assert.equal(r.distinctRows, 0);
96
+ });
97
+
98
+ test("reconcileDataPortal counts a row with no object as a phantom", () => {
99
+ const r = reconcileDataPortal([], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
100
+ assert.deepEqual(r.phantomRows, ["alice/a.pdf"]);
101
+ assert.deepEqual(r.orphanObjects, []);
102
+ });
103
+
104
+ test("reconcileDataPortal counts a matched pair as neither", () => {
105
+ const r = reconcileDataPortal(
106
+ [{ key: "alice/a.pdf" }],
107
+ [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")],
108
+ 0,
109
+ );
110
+ assert.deepEqual(r.orphanObjects, []);
111
+ assert.deepEqual(r.phantomRows, []);
112
+ });
113
+
114
+ test("oldestUningestedHrs comes from the oldest ingested=0 row", () => {
115
+ const now = Date.parse("2026-07-20T12:00:00Z");
116
+ const r = reconcileDataPortal(
117
+ [],
118
+ [row("a", 0, "2026-07-20T09:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
119
+ now,
120
+ );
121
+ assert.equal(r.oldestUningestedHrs, 3);
122
+ });
123
+
124
+ test("an ingested row is excluded from the backlog age", () => {
125
+ const now = Date.parse("2026-07-20T12:00:00Z");
126
+ const r = reconcileDataPortal(
127
+ [],
128
+ [row("a", 1, "2026-07-01T00:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
129
+ now,
130
+ );
131
+ assert.equal(r.oldestUningestedHrs, 1);
132
+ });
133
+
134
+ test("oldestUningestedHrs is null when nothing is uningested", () => {
135
+ const r = reconcileDataPortal([], [row("a", 1, "2026-07-20T00:00:00Z")], 0);
136
+ assert.equal(r.oldestUningestedHrs, null);
137
+ });
138
+
139
+ test("an unparseable uploadedAt never yields NaN", () => {
140
+ const now = Date.parse("2026-07-20T12:00:00Z");
141
+ const only = reconcileDataPortal([], [row("a", 0, "not-a-date")], now);
142
+ assert.equal(only.oldestUningestedHrs, null);
143
+ const mixed = reconcileDataPortal(
144
+ [],
145
+ [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")],
146
+ now,
147
+ );
148
+ assert.equal(mixed.oldestUningestedHrs, 1);
149
+ });
@@ -15,9 +15,28 @@ export interface Stray {
15
15
  // remediation. Each is an account-wide capability.
16
16
  const ACCOUNT_WIDE_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
17
17
 
18
+ /** CONCURRENCY CONTRACT — read before implementing these callbacks.
19
+ *
20
+ * reconcileStorage invokes all five through ONE Promise.all. They overlap,
21
+ * including the two registeredNames calls, which run at the same time as each
22
+ * other. So no callback may share, with any other callback or with a second
23
+ * invocation of itself, a resource that admits one operation at a time.
24
+ *
25
+ * The resource that has actually caused this is a Neo4j session: it rejects a
26
+ * query issued while one of its own is in flight. A caller that opened one
27
+ * session in the enclosing scope and used it in registeredNames made the
28
+ * second read reject, which left the standing storage audit reporting nothing
29
+ * for 802 consecutive ticks on maxy-code and 295 on sitedesk-code (Task 1813).
30
+ * Open the session inside the callback, one per invocation, and close it
31
+ * there; driver sessions come from a pool, so this is cheap.
32
+ *
33
+ * The contract is stated here rather than on reconcileStorage because this
34
+ * interface is what a caller writing an implementation is looking at, and the
35
+ * natural thing to write is the thing that breaks. */
18
36
  export interface AuditDeps {
19
37
  cfD1(): Promise<string[]>;
20
38
  cfR2(): Promise<string[]>;
39
+ /** Called twice, concurrently, once per kind. See the contract above. */
21
40
  registeredNames(kind: "d1" | "r2"): Promise<string[]>;
22
41
  accountSecretsFiles(): Promise<Array<{ accountId: string; path: string; contents: string }>>;
23
42
  }
@@ -71,6 +90,13 @@ export async function reconcileStorage(deps: AuditDeps): Promise<{ strays: Stray
71
90
  // Names are reported, not just counted: a count tells an operator something is
72
91
  // wrong, the name tells them what to adopt.
73
92
 
93
+ /** Same concurrency contract as AuditDeps above: reconcilePages invokes both
94
+ * callbacks through one Promise.all, so neither may share a single-operation
95
+ * resource with the other.
96
+ *
97
+ * That registeredProjects happens to issue exactly one query today is not the
98
+ * property that makes a shared session safe here, and relying on it would put
99
+ * the 1813 defect one added read away. */
74
100
  export interface PagesAuditDeps {
75
101
  cfProjects(): Promise<string[]>;
76
102
  registeredProjects(): Promise<string[]>;
@@ -98,3 +124,88 @@ export async function reconcilePages(deps: PagesAuditDeps): Promise<PagesReconci
98
124
  phantoms: [...regSet].filter((name) => !cfSet.has(name)),
99
125
  };
100
126
  }
127
+
128
+ // --- Data portal reconcile (Task 1704) -------------------------------------
129
+ //
130
+ // Third instance of the same no-event argument, one level down from the two
131
+ // above: those reconcile ownership records, this reconciles the objects
132
+ // themselves against the manifest rows that make them ingestible.
133
+ //
134
+ // orphan object — an object in R2 with no manifest row. The upload returned
135
+ // 200, the file is stored, and ingestion will never see it.
136
+ // phantom row — a manifest row whose object is absent. Ingestion will pull
137
+ // a missing file, and nothing says so until it runs.
138
+ //
139
+ // Keys are returned rather than only counted so the arithmetic is testable and
140
+ // a future caller has them, but the standing audit logs COUNTS ONLY: a key here
141
+ // is `<ownerId>/<filename>`, a real person's filename, and the brand journal is
142
+ // not where those belong. That is the same ownership discipline the in-process
143
+ // audit path owes for holding the account-wide credential and bypassing
144
+ // objectGate entirely.
145
+
146
+ export interface DataPortalObject {
147
+ key: string;
148
+ }
149
+
150
+ export interface DataPortalRow {
151
+ objectKey: string;
152
+ ingested: number;
153
+ uploadedAt: string;
154
+ }
155
+
156
+ export interface DataPortalReconcile {
157
+ /** DISTINCT keys, which is not the same quantity as the length of the list
158
+ * the read returned. The caller logs the lengths — "how many did I see" —
159
+ * and these are "how many are distinct". They agree on a healthy store
160
+ * (manifest.objectKey is UNIQUE and R2 keys are unique by construction), and
161
+ * giving them one name would conflate two facts that diverge exactly when
162
+ * the store is unhealthy, which is the case this audit is for. */
163
+ distinctObjects: number;
164
+ distinctRows: number;
165
+ orphanObjects: string[];
166
+ phantomRows: string[];
167
+ /** Null when nothing is uningested, or when no uningested row carries a
168
+ * parseable timestamp. Rendered `na` by the caller. */
169
+ oldestUningestedHrs: number | null;
170
+ }
171
+
172
+ /** Pure over two lists and an injected clock, so the age arithmetic is
173
+ * deterministic under test rather than dependent on when the suite runs.
174
+ *
175
+ * Unlike its two siblings this takes lists rather than callbacks: both sides
176
+ * are read per account by a caller that must catch each independently, so
177
+ * there is no fan-out here to carry a concurrency contract. */
178
+ export function reconcileDataPortal(
179
+ objects: DataPortalObject[],
180
+ rows: DataPortalRow[],
181
+ nowMs: number,
182
+ ): DataPortalReconcile {
183
+ const objectKeys = new Set(objects.map((o) => o.key));
184
+ const rowKeys = new Set(rows.map((r) => r.objectKey));
185
+
186
+ // Date.parse of a truthy non-ISO value is NaN, which slips past a `??` guard
187
+ // and renders as `oldestUningestedHrs=NaN` — the defect the availability
188
+ // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
189
+ // the minimum; if that leaves none, the answer is null, never NaN.
190
+ const uningestedMs = rows
191
+ .filter((r) => r.ingested === 0)
192
+ .map((r) => Date.parse(r.uploadedAt))
193
+ .filter((ms) => !Number.isNaN(ms));
194
+
195
+ // Reduced, never `Math.min(...uningestedMs)`. A spread passes one argument per
196
+ // element and blows the call stack — measured: fine at 125k, RangeError at
197
+ // 130k. One element per uningested row, and an ingestion sweep that has
198
+ // stalled accumulates those without bound. A stalled sweep is one of the three
199
+ // conditions this audit exists to reveal, so the arithmetic must not be what
200
+ // goes dark once the backlog it is watching grows large. The throw would also
201
+ // escape the caller's per-side catches and cost every later account its line.
202
+ const oldestMs = uningestedMs.reduce((min, ms) => (ms < min ? ms : min), Infinity);
203
+
204
+ return {
205
+ distinctObjects: objectKeys.size,
206
+ distinctRows: rowKeys.size,
207
+ orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
208
+ phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
209
+ oldestUningestedHrs: uningestedMs.length === 0 ? null : (nowMs - oldestMs) / 3_600_000,
210
+ };
211
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "admin",
3
- "description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), admin-hostname (the admin-host twin of public-hostname — resolves the host that serves /calendar and the rest of the admin shell behind sign-in from the same ingress + alias + operator files, so agents point the operator at the admin /calendar URL instead of the public host that 404s it), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, the multi-account managed-service lifecycle tools (account_list, account_create, account_delete, account_purge, account_channel_routing, account_channel_routing_set — one Rubytech operator hosting N client accounts on one house install; create/delete/purge are operator-only and purge is two-step with a typed confirmation; account_create requires the client business owner's real name (ownerName), refusing a blank or the placeholder \"Owner\" and seeding it onto the sub-account's owner AdminUser and owner Person, and requires the owner's IANA timezone (timezone) — refusing a blank or a non-IANA value — seeding it onto the owner UserProfile marked owner-set so scheduling reads the owner's real zone from the start rather than the box's server default, while a managing house admin (its userId) is optional — when supplied it is validated as a live house admin and recorded as the sub-account's managingAdminUserId, and when omitted the sub-account is unmanaged and its passive-intake tasks route to the install's primary admin; account_reassign_admin repoints a client sub-account's managing admin to another live house admin (the repair path for a dangling managingAdminUserId, or to record one on an unmanaged account); account_roster is the canonical per-account identity view — one row per sub-account giving business name, role, the managing house admin resolved to a name with status (assigned/absent/dangling), the business owner from the graph with status (named/nameless), and the WhatsApp account-manager binding if any, each field carrying what it means and does not (managing admin is the responsible house team member not the owner; account manager is WhatsApp routing only with no admin rights), so the agent never stitches admin-list + account.json + whatsapp-config; account_set_owner_name names (or corrects) a client sub-account's business owner by writing the owned Person — the canonical name source — refusing a blank or the placeholder \"Owner\", the guarded repair for a nameless owner (the standing identity-audit op=nameless-owned-person detector); account_channel_routing_set repoints the install's single WhatsApp/Telegram inbound stream to a different sub-account), admin-add (refuses on a client sub-account, where admin access is already install-wide so a per-operator seat grants nothing — the sub-account's one identity, its business owner, is set at account creation; the house account, where operators hold PIN logins and admins[] rows, is unchanged), admin-remove (refuses to remove a house admin who still manages one or more client sub-accounts, naming them so the operator reassigns each with account_reassign_admin first — never silently orphans them), admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), skill-search (free-text intent → skill resolver: ranks every plugin skill's SKILL.md name+description against a query and returns the top owners with their canonical skills/<slug>/ path, so intent→skill mapping survives the available-skills menu leaving context after a compaction), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform.",
3
+ "description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), admin-hostname (the admin-host twin of public-hostname — resolves the host that serves /calendar and the rest of the admin shell behind sign-in from the same ingress + alias + operator files, so agents point the operator at the admin /calendar URL instead of the public host that 404s it), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, the multi-account managed-service lifecycle tools (account_list, account_create, account_delete, account_purge, account_channel_routing, account_channel_routing_set — one Rubytech operator hosting N client accounts on one house install; create/delete/purge are operator-only and purge is two-step with a typed confirmation; account_create requires the client business owner's real name (ownerName), refusing a blank or the placeholder \"Owner\" and seeding it onto the sub-account's owner AdminUser and owner Person, and requires the owner's IANA timezone (timezone) — refusing a blank or a non-IANA value — seeding it onto the owner UserProfile marked owner-set so scheduling reads the owner's real zone from the start rather than the box's server default, while a managing house admin (its userId) is optional — when supplied it is validated as a live house admin and recorded as the sub-account's managingAdminUserId, and when omitted the sub-account is unmanaged and its passive-intake tasks route to the install's primary admin; account_reassign_admin repoints a client sub-account's managing admin to another live house admin (the repair path for a dangling managingAdminUserId, or to record one on an unmanaged account); account_roster is the canonical per-account identity view — one row per sub-account giving business name, role, the managing house admin resolved to a name with status (assigned/absent/dangling), the business owner from the graph with status (named/nameless), and the WhatsApp account-manager binding if any, each field carrying what it means and does not (managing admin is the responsible house team member not the owner; account manager is WhatsApp routing only with no admin rights), so the agent never stitches admin-list + account.json + whatsapp-config; account_set_owner_name names (or corrects) a client sub-account's business owner by writing the owned Person — the canonical name source — refusing a blank or the placeholder \"Owner\", the guarded repair for a nameless owner (the standing identity-audit op=nameless-owned-person detector); account_channel_routing_set repoints the install's single WhatsApp/Telegram inbound stream to a different sub-account), admin-add (refuses on a client sub-account, where admin access is already install-wide so a per-operator seat grants nothing — the sub-account's one identity, its business owner, is set at account creation; the house account, where operators hold PIN logins and admins[] rows, is unchanged), admin-remove (refuses to remove a house admin who still manages one or more client sub-accounts, naming them so the operator reassigns each with account_reassign_admin first — never silently orphans them), admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, cpu-triage-start and cpu-triage-read (the two-call device CPU diagnostic — start spawns a detached sampling run across at least one full 120s reconcile cycle and returns a run handle immediately, read returns running/complete/failed with the per-core distribution; no admin tool blocks through a sampling window, and no spot reading is obtainable), plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), skill-search (free-text intent → skill resolver: ranks every plugin skill's SKILL.md name+description against a query and returns the top owners with their canonical skills/<slug>/ path, so intent→skill mapping survives the available-skills menu leaving context after a compaction), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: admin
3
3
  surface: platform
4
- description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), admin-hostname (the admin-host twin of public-hostname — resolves the host that serves /calendar and the rest of the admin shell behind sign-in from the same ingress + alias + operator files, so agents point the operator at the admin /calendar URL instead of the public host that 404s it), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, the multi-account managed-service lifecycle tools (account_list, account_create, account_delete, account_purge, account_channel_routing, account_channel_routing_set — one Rubytech operator hosting N client accounts on one house install; create/delete/purge are operator-only and purge is two-step with a typed confirmation; account_create requires the client business owner's real name (ownerName), refusing a blank or the placeholder "Owner" and seeding it onto the sub-account's owner AdminUser and owner Person, and requires the owner's IANA timezone (timezone) — refusing a blank or a non-IANA value — seeding it onto the owner UserProfile marked owner-set so scheduling reads the owner's real zone from the start rather than the box's server default, while a managing house admin (its userId) is optional — when supplied it is validated as a live house admin and recorded as the sub-account's managingAdminUserId, and when omitted the sub-account is unmanaged and its passive-intake tasks route to the install's primary admin; account_reassign_admin repoints a client sub-account's managing admin to another live house admin (the repair path for a dangling managingAdminUserId, or to record one on an unmanaged account); account_roster is the canonical per-account identity view — one row per sub-account giving business name, role, the managing house admin resolved to a name with status (assigned/absent/dangling), the business owner from the graph with status (named/nameless), and the WhatsApp account-manager binding if any, each field carrying what it means and does not (managing admin is the responsible house team member not the owner; account manager is WhatsApp routing only with no admin rights), so the agent never stitches admin-list + account.json + whatsapp-config; account_set_owner_name names (or corrects) a client sub-account's business owner by writing the owned Person — the canonical name source — refusing a blank or the placeholder "Owner", the guarded repair for a nameless owner (the standing identity-audit op=nameless-owned-person detector); account_channel_routing_set repoints the install's single WhatsApp/Telegram inbound stream to a different sub-account), admin-add (refuses on a client sub-account, where admin access is already install-wide so a per-operator seat grants nothing — the sub-account's one identity, its business owner, is set at account creation; the house account, where operators hold PIN logins and admins[] rows, is unchanged), admin-remove (refuses to remove a house admin who still manages one or more client sub-accounts, naming them so the operator reassigns each with account_reassign_admin first — never silently orphans them), admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), skill-search (free-text intent → skill resolver: ranks every plugin skill's SKILL.md name+description against a query and returns the top owners with their canonical skills/<slug>/ path, so intent→skill mapping survives the available-skills menu leaving context after a compaction), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform."
4
+ description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), admin-hostname (the admin-host twin of public-hostname — resolves the host that serves /calendar and the rest of the admin shell behind sign-in from the same ingress + alias + operator files, so agents point the operator at the admin /calendar URL instead of the public host that 404s it), publish-site (move an extracted static-site tree under <accountDir>/sites/<slug>/, emit one canonical path slug, and refresh llms.txt at the site root — typed refusal taxonomy replaces the prior skill-prose contract), brand-settings, account-manage, capabilities-here (deterministic install-introspection — returns enabledPlugins, tier, models, installed platform + premium specialists, registered Claude Code agent types, and the brand block as a single JSON object; the canonical tool for grounding any claim about what is enabled on the current account), account-update, the multi-account managed-service lifecycle tools (account_list, account_create, account_delete, account_purge, account_channel_routing, account_channel_routing_set — one Rubytech operator hosting N client accounts on one house install; create/delete/purge are operator-only and purge is two-step with a typed confirmation; account_create requires the client business owner's real name (ownerName), refusing a blank or the placeholder "Owner" and seeding it onto the sub-account's owner AdminUser and owner Person, and requires the owner's IANA timezone (timezone) — refusing a blank or a non-IANA value — seeding it onto the owner UserProfile marked owner-set so scheduling reads the owner's real zone from the start rather than the box's server default, while a managing house admin (its userId) is optional — when supplied it is validated as a live house admin and recorded as the sub-account's managingAdminUserId, and when omitted the sub-account is unmanaged and its passive-intake tasks route to the install's primary admin; account_reassign_admin repoints a client sub-account's managing admin to another live house admin (the repair path for a dangling managingAdminUserId, or to record one on an unmanaged account); account_roster is the canonical per-account identity view — one row per sub-account giving business name, role, the managing house admin resolved to a name with status (assigned/absent/dangling), the business owner from the graph with status (named/nameless), and the WhatsApp account-manager binding if any, each field carrying what it means and does not (managing admin is the responsible house team member not the owner; account manager is WhatsApp routing only with no admin rights), so the agent never stitches admin-list + account.json + whatsapp-config; account_set_owner_name names (or corrects) a client sub-account's business owner by writing the owned Person — the canonical name source — refusing a blank or the placeholder "Owner", the guarded repair for a nameless owner (the standing identity-audit op=nameless-owned-person detector); account_channel_routing_set repoints the install's single WhatsApp/Telegram inbound stream to a different sub-account), admin-add (refuses on a client sub-account, where admin access is already install-wide so a per-operator seat grants nothing — the sub-account's one identity, its business owner, is set at account creation; the house account, where operators hold PIN logins and admins[] rows, is unchanged), admin-remove (refuses to remove a house admin who still manages one or more client sub-accounts, naming them so the operator reassigns each with account_reassign_admin first — never silently orphans them), admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, cpu-triage-start and cpu-triage-read (the two-call device CPU diagnostic — start spawns a detached sampling run across at least one full 120s reconcile cycle and returns a run handle immediately, read returns running/complete/failed with the per-core distribution; no admin tool blocks through a sampling window, and no spot reading is obtainable), plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), skill-search (free-text intent → skill resolver: ranks every plugin skill's SKILL.md name+description against a query and returns the top owners with their canonical skills/<slug>/ path, so intent→skill mapping survives the available-skills menu leaving context after a compaction), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, wifi, and action-approval tools (action-pending, action-approve, action-reject, action-edit) for managing the Maxy platform."
5
5
  tools:
6
6
  - name: system-status
7
7
  publicAllowlist: false
@@ -75,6 +75,12 @@ tools:
75
75
  - name: logs-read
76
76
  publicAllowlist: false
77
77
  adminAllowlist: false
78
+ - name: cpu-triage-start
79
+ publicAllowlist: false
80
+ adminAllowlist: true
81
+ - name: cpu-triage-read
82
+ publicAllowlist: false
83
+ adminAllowlist: true
78
84
  - name: plugin-read
79
85
  publicAllowlist: false
80
86
  adminAllowlist: false
@@ -24,6 +24,7 @@ import { resolvePublicHostname } from "./lib/public-hostname.js";
24
24
  import { resolveAdminHostname } from "./lib/admin-hostname.js";
25
25
  import { publishSite } from "./tools/publish-site.js";
26
26
  import { resolveCapabilitiesHere } from "./tools/capabilities-here.js";
27
+ import { startCpuTriage, readCpuTriage } from "./tools/cpu-triage.js";
27
28
  import { runCatalogueCensus } from "./lib/catalogue-census.js";
28
29
  import { lifelineTool } from "../../../../lib/mcp-lifeline/dist/index.js";
29
30
  import { listAccountsFromDir, isHouseAccount, archiveAccountDir, provisionClientAccount, findHouseAccount, getChannelRoutingAccountId, setChannelRoutingAccountId, seedAccountGraphRoot, isPlaceholderOwnerName, isBlankBusinessName, isValidTimeZone, refuseClientAdminAdd, readHouseAdminUserIds, setManagingAdmin, findClientAccountsManagedBy, reassignManagingAdmin, ACCOUNT_NODE_COUNT_CYPHER, ACCOUNT_PURGE_CYPHER, classifyManagingAdmin, ownerNameStatus, readManagingAdminUserId, setOwnerName, } from "./tools/account-lifecycle.js";
@@ -1736,6 +1737,77 @@ eagerTool(server, "logs-read", "Read recent logs. Stream logs (type=agent-stream
1736
1737
  return { content: [{ type: "text", text: `Failed to read logs: ${err instanceof Error ? err.message : String(err)}` }] };
1737
1738
  }
1738
1739
  });
1740
+ // ---------------------------------------------------------------------------
1741
+ // cpu-triage — device CPU diagnostic, split across two calls.
1742
+ //
1743
+ // The measurement is only trustworthy across at least one full 120s reconcile
1744
+ // cycle: the same process measured 0.2% in three consecutive 25s windows and
1745
+ // 10.8% in a 20s window, all correct, because the load is a sawtooth. Every
1746
+ // other admin tool completes inside 12s, so the run is detached and the caller
1747
+ // polls rather than blocking through a sampling window.
1748
+ //
1749
+ // What comes back is a DISTRIBUTION — per-core mean, peak, and share of the
1750
+ // window above threshold. There is deliberately no way to obtain a single
1751
+ // spot reading from these tools; that shape produced a session of
1752
+ // contradictory answers on 2026-07-20.
1753
+ // ---------------------------------------------------------------------------
1754
+ const CPU_TRIAGE_PATHS = {
1755
+ scriptPath: resolve(PLATFORM_ROOT, "scripts", "cpu-triage.sh"),
1756
+ runnerPath: resolve(PLATFORM_ROOT, "scripts", "cpu-triage-run.sh"),
1757
+ logsDir: resolve(CONFIG_DIR, "logs"),
1758
+ serverLog: resolve(CONFIG_DIR, "logs", "server.log"),
1759
+ };
1760
+ eagerTool(server, "cpu-triage-start", "Start a device CPU triage run and return immediately with a run handle. THIS DOES NOT RETURN A MEASUREMENT — " +
1761
+ "call cpu-triage-read after `expectedCompleteAt` to get the report. " +
1762
+ "Answers 'which cores are busy and what is on them' for the box this install runs on. " +
1763
+ "Samples across at least one full 120s reconcile cycle and reports a distribution (per-core mean, peak, " +
1764
+ "share of window above threshold, plus per-process and per-service deltas), never a spot reading — " +
1765
+ "shorter samples on this platform contradict each other. " +
1766
+ "`control` names a peer brand known to be on the PREVIOUS build: the single most valuable input to this " +
1767
+ "measurement, because a peer that did not get the change beats any before/after inference. " +
1768
+ "Measures the local box only; there is no remote targeting.", {
1769
+ windowSec: z.number().int().min(120).optional().describe("Total sampling window in seconds (default 300, minimum 120 — one reconcile cycle)"),
1770
+ intervalSec: z.number().int().min(1).optional().describe("Sample cadence in seconds (default 10)"),
1771
+ thresholdPct: z.number().int().min(1).max(100).optional().describe("Per-core busy %% treated as hot (default 15)"),
1772
+ control: z.string().optional().describe("A peer brand on the previous build, for like-for-like comparison"),
1773
+ }, async ({ windowSec, intervalSec, thresholdPct, control }) => {
1774
+ const out = startCpuTriage(CPU_TRIAGE_PATHS, { windowSec, intervalSec, thresholdPct, control });
1775
+ if (!out.ok) {
1776
+ return {
1777
+ content: [{ type: "text", text: JSON.stringify({ refused: out.reason, detail: out.detail }, null, 2) }],
1778
+ isError: out.fault,
1779
+ };
1780
+ }
1781
+ return {
1782
+ content: [{
1783
+ type: "text",
1784
+ text: JSON.stringify({
1785
+ runId: out.runId,
1786
+ startedAt: out.startedAt,
1787
+ expectedCompleteAt: out.expectedCompleteAt,
1788
+ windowSec: out.windowSec,
1789
+ resultPath: out.resultPath,
1790
+ next: `Sampling for ${out.windowSec}s. Call cpu-triage-read after ${out.expectedCompleteAt}. Reading earlier returns status=running, which is not a failure.`,
1791
+ }, null, 2),
1792
+ }],
1793
+ };
1794
+ });
1795
+ eagerTool(server, "cpu-triage-read", "Read a cpu-triage run started by cpu-triage-start. Defaults to the most recent run, so a later session can " +
1796
+ "read a run it did not start. Returns one of three states: `running` (still sampling — NOT a failure, and NOT " +
1797
+ "a reason to start another run), `complete` (the parsed report; exitCode 1 means one or more cores were " +
1798
+ "sustained above threshold and the finding is in the report, exitCode 0 means clean), or `failed` (the run hit " +
1799
+ "a precondition failure, with its exit code and stderr).", {
1800
+ runId: z.string().optional().describe("Run to read (default: most recent)"),
1801
+ }, async ({ runId }) => {
1802
+ const out = readCpuTriage(CPU_TRIAGE_PATHS, { runId });
1803
+ if (!out.ok) {
1804
+ return {
1805
+ content: [{ type: "text", text: JSON.stringify({ refused: out.reason, detail: out.detail }, null, 2) }],
1806
+ isError: out.fault,
1807
+ };
1808
+ }
1809
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
1810
+ });
1739
1811
  // operator-authored skills are written to disk as a plugin so the
1740
1812
  // existing plugin-manifest loader picks them up (parsePluginFrontmatter +
1741
1813
  // assemblePublicPluginContent + loadEmbeddedPlugins all read from