@rubytech/create-realagent-code 0.1.572 → 0.1.574

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 (373) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +2 -1
  3. package/payload/platform/lib/admins-write/__tests__/index.test.ts +97 -0
  4. package/payload/platform/lib/admins-write/dist/index.d.ts +12 -0
  5. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/admins-write/dist/index.js +27 -5
  7. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  8. package/payload/platform/lib/admins-write/src/index.ts +35 -5
  9. package/payload/platform/lib/admins-write/vitest.config.ts +14 -0
  10. package/payload/platform/lib/dispatch-read/dist/session.d.ts.map +1 -1
  11. package/payload/platform/lib/dispatch-read/dist/session.js +19 -4
  12. package/payload/platform/lib/dispatch-read/dist/session.js.map +1 -1
  13. package/payload/platform/lib/dispatch-read/src/__tests__/session.test.ts +91 -0
  14. package/payload/platform/lib/dispatch-read/src/session.ts +25 -6
  15. package/payload/platform/lib/routine-templates/dist/index.d.ts +2 -2
  16. package/payload/platform/lib/routine-templates/dist/index.d.ts.map +1 -1
  17. package/payload/platform/lib/routine-templates/dist/index.js +9 -1
  18. package/payload/platform/lib/routine-templates/dist/index.js.map +1 -1
  19. package/payload/platform/lib/routine-templates/src/index.ts +14 -2
  20. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  21. package/payload/platform/plugins/admin/PLUGIN.md +8 -3
  22. package/payload/platform/plugins/admin/hooks/__tests__/html-structure-gate.test.sh +264 -0
  23. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +122 -24
  24. package/payload/platform/plugins/admin/hooks/__tests__/specialist-dispatch-gate.test.sh +158 -0
  25. package/payload/platform/plugins/admin/hooks/html-structure-gate.sh +165 -0
  26. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +111 -19
  27. package/payload/platform/plugins/admin/hooks/specialist-dispatch-gate.sh +159 -0
  28. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts +27 -0
  29. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts.map +1 -0
  30. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js +29 -0
  31. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js.map +1 -0
  32. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts +11 -0
  33. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts.map +1 -0
  34. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js +20 -0
  35. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js.map +1 -0
  36. package/payload/platform/plugins/admin/mcp/dist/index.js +137 -2
  37. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  38. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +32 -2
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +192 -90
  40. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +18 -0
  41. package/payload/platform/plugins/docs/references/admin-ui.md +71 -31
  42. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +50 -57
  43. package/payload/platform/plugins/docs/references/internals.md +6 -0
  44. package/payload/platform/plugins/docs/references/outlook-guide.md +31 -1
  45. package/payload/platform/plugins/docs/references/telegram-guide.md +23 -0
  46. package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
  47. package/payload/platform/plugins/email/PLUGIN.md +1 -1
  48. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js +36 -5
  49. package/payload/platform/plugins/email/mcp/dist/__tests__/email-draft-send.test.js.map +1 -1
  50. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js +10 -5
  51. package/payload/platform/plugins/email/mcp/dist/__tests__/imap-drafts.test.js.map +1 -1
  52. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +12 -6
  53. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  54. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +18 -8
  55. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  56. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.d.ts.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js +41 -10
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-draft-send.js.map +1 -1
  59. package/payload/platform/plugins/email/references/email-reference.md +12 -0
  60. package/payload/platform/plugins/outlook/PLUGIN.md +3 -1
  61. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts +2 -0
  62. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts.map +1 -0
  63. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js +177 -0
  64. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js.map +1 -0
  65. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts +2 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js +130 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts +2 -0
  70. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts.map +1 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js +162 -0
  72. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js.map +1 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js +168 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts +64 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js +110 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts +119 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js +0 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts +8 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js +247 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.d.ts.map +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js +10 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js.map +1 -1
  92. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +21 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  95. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts +16 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
  97. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +5 -1
  98. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
  99. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +4 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
  101. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +2 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +24 -0
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +2 -1
  108. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
  109. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
  110. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +29 -0
  111. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
  112. package/payload/platform/plugins/scheduling/PLUGIN.md +4 -0
  113. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts +2 -0
  114. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts.map +1 -0
  115. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js +21 -0
  116. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js.map +1 -0
  117. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts +13 -0
  118. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts.map +1 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js +10 -0
  120. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js.map +1 -0
  121. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +54 -0
  122. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  123. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +78 -5
  124. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  125. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +133 -2
  126. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  127. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +11 -0
  128. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  129. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +30 -1
  130. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  131. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.d.ts.map +1 -1
  132. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js +8 -0
  133. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-update.js.map +1 -1
  134. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  135. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +37 -6
  136. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  137. package/payload/platform/scripts/check-no-esm-require.mjs +4 -0
  138. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts +32 -0
  139. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts.map +1 -0
  140. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js +144 -0
  141. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js.map +1 -0
  142. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  143. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +3 -0
  144. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  145. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts +44 -0
  146. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts.map +1 -0
  147. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js +185 -0
  148. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js.map +1 -0
  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 +32 -5
  151. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/index.js +125 -0
  153. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +12 -1
  155. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  156. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +13 -1
  157. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  158. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -1
  159. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +7 -0
  160. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -1
  161. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts +42 -0
  162. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts.map +1 -0
  163. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js +254 -0
  164. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js.map +1 -0
  165. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts +92 -0
  166. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts.map +1 -0
  167. package/payload/platform/services/claude-session-manager/dist/specialist-census.js +227 -0
  168. package/payload/platform/services/claude-session-manager/dist/specialist-census.js.map +1 -0
  169. package/payload/platform/services/telegram-channel/dist/jsonl.d.ts +8 -0
  170. package/payload/platform/services/telegram-channel/dist/jsonl.d.ts.map +1 -0
  171. package/payload/platform/services/telegram-channel/dist/jsonl.js +42 -0
  172. package/payload/platform/services/telegram-channel/dist/jsonl.js.map +1 -0
  173. package/payload/platform/services/telegram-channel/dist/notification.d.ts +0 -31
  174. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  175. package/payload/platform/services/telegram-channel/dist/notification.js +4 -33
  176. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  177. package/payload/platform/services/telegram-channel/dist/server.d.ts +0 -5
  178. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  179. package/payload/platform/services/telegram-channel/dist/server.js +7 -101
  180. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  181. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +0 -66
  182. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  183. package/payload/platform/services/whatsapp-channel/dist/notification.js +2 -64
  184. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  185. package/payload/platform/services/whatsapp-channel/dist/server.d.ts +0 -12
  186. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  187. package/payload/platform/services/whatsapp-channel/dist/server.js +15 -268
  188. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  189. package/payload/platform/templates/account-settings.json +8 -4
  190. package/payload/server/public/activity.html +6 -6
  191. package/payload/server/public/agents.html +5 -5
  192. package/payload/server/public/assets/{AdminLoginScreens-CMdj1W5p.js → AdminLoginScreens-Dp6k1XgD.js} +1 -1
  193. package/payload/server/public/assets/AdminLoginScreens-Dp6k1XgD.js.br +0 -0
  194. package/payload/server/public/assets/AdminLoginScreens-Dp6k1XgD.js.gz +0 -0
  195. package/payload/server/public/assets/{AdminShell-CZcaW3BQ.js → AdminShell-D-wB90c3.js} +1 -1
  196. package/payload/server/public/assets/AdminShell-D-wB90c3.js.br +0 -0
  197. package/payload/server/public/assets/AdminShell-D-wB90c3.js.gz +0 -0
  198. package/payload/server/public/assets/{activity-CAhibgb-.js → activity-BJxhblz-.js} +1 -1
  199. package/payload/server/public/assets/activity-BJxhblz-.js.br +0 -0
  200. package/payload/server/public/assets/activity-BJxhblz-.js.gz +0 -0
  201. package/payload/server/public/assets/{admin-xEXv3w9e.js → admin-DzEi8shs.js} +1 -1
  202. package/payload/server/public/assets/admin-DzEi8shs.js.br +0 -0
  203. package/payload/server/public/assets/admin-DzEi8shs.js.gz +0 -0
  204. package/payload/server/public/assets/{agents-CiAmzTaT.js → agents-DTL8kjfD.js} +1 -1
  205. package/payload/server/public/assets/agents-DTL8kjfD.js.br +0 -0
  206. package/payload/server/public/assets/agents-DTL8kjfD.js.gz +0 -0
  207. package/payload/server/public/assets/{browser-D36BiYhS.js → browser-UZp75zhx.js} +1 -1
  208. package/payload/server/public/assets/browser-UZp75zhx.js.br +0 -0
  209. package/payload/server/public/assets/browser-UZp75zhx.js.gz +0 -0
  210. package/payload/server/public/assets/{calendar-CJAM-iXn.js → calendar-Dfo0O2K_.js} +1 -1
  211. package/payload/server/public/assets/calendar-Dfo0O2K_.js.br +0 -0
  212. package/payload/server/public/assets/calendar-Dfo0O2K_.js.gz +0 -0
  213. package/payload/server/public/assets/chat-ByJAvEWZ.js +1 -0
  214. package/payload/server/public/assets/chat-ByJAvEWZ.js.br +0 -0
  215. package/payload/server/public/assets/chat-ByJAvEWZ.js.gz +0 -0
  216. package/payload/server/public/assets/chevron-left-iG7sYNm7.js +1 -0
  217. package/payload/server/public/assets/chevron-left-iG7sYNm7.js.br +0 -0
  218. package/payload/server/public/assets/clock-xSs0UF10.js +1 -0
  219. package/payload/server/public/assets/clock-xSs0UF10.js.br +0 -0
  220. package/payload/server/public/assets/clock-xSs0UF10.js.gz +0 -0
  221. package/payload/server/public/assets/{copy-axBe92iY.js → copy-CGI2jioK.js} +1 -1
  222. package/payload/server/public/assets/copy-CGI2jioK.js.br +0 -0
  223. package/payload/server/public/assets/copy-CGI2jioK.js.gz +0 -0
  224. package/payload/server/public/assets/data-dvwK4BcP.js +1 -0
  225. package/payload/server/public/assets/data-dvwK4BcP.js.br +0 -0
  226. package/payload/server/public/assets/data-dvwK4BcP.js.gz +0 -0
  227. package/payload/server/public/assets/{dispatch-D_i2ko7K.js → dispatch-Dx9-ty1K.js} +1 -1
  228. package/payload/server/public/assets/dispatch-Dx9-ty1K.js.br +0 -0
  229. package/payload/server/public/assets/dispatch-Dx9-ty1K.js.gz +0 -0
  230. package/payload/server/public/assets/{field-DFgkqq1P.js → field-D-P6CsWA.js} +1 -1
  231. package/payload/server/public/assets/field-D-P6CsWA.js.br +0 -0
  232. package/payload/server/public/assets/field-D-P6CsWA.js.gz +0 -0
  233. package/payload/server/public/assets/{file-text-CEFoq0Je.js → file-text-RWintp9q.js} +1 -1
  234. package/payload/server/public/assets/file-text-RWintp9q.js.br +0 -0
  235. package/payload/server/public/assets/file-text-RWintp9q.js.gz +0 -0
  236. package/payload/server/public/assets/{graph-BrpbilNr.js → graph-CpBlfYrJ.js} +1 -1
  237. package/payload/server/public/assets/graph-CpBlfYrJ.js.br +0 -0
  238. package/payload/server/public/assets/graph-CpBlfYrJ.js.gz +0 -0
  239. package/payload/server/public/assets/{graph-labels-BtmcWzo7.js → graph-labels-BOMyPppX.js} +1 -1
  240. package/payload/server/public/assets/graph-labels-BOMyPppX.js.br +0 -0
  241. package/payload/server/public/assets/graph-labels-BOMyPppX.js.gz +0 -0
  242. package/payload/server/public/assets/jsx-runtime-DxsQ5Y-z.css +1 -0
  243. package/payload/server/public/assets/jsx-runtime-DxsQ5Y-z.css.br +0 -0
  244. package/payload/server/public/assets/jsx-runtime-DxsQ5Y-z.css.gz +0 -0
  245. package/payload/server/public/assets/{operator-Dl7dJt1G.js → operator-DFgW6GiH.js} +1 -1
  246. package/payload/server/public/assets/operator-DFgW6GiH.js.br +0 -0
  247. package/payload/server/public/assets/operator-DFgW6GiH.js.gz +0 -0
  248. package/payload/server/public/assets/{page-nx31Ij0T.js → page-BRShHSH0.js} +1 -1
  249. package/payload/server/public/assets/page-BRShHSH0.js.br +0 -0
  250. package/payload/server/public/assets/page-BRShHSH0.js.gz +0 -0
  251. package/payload/server/public/assets/{page-zC66xzNT.js → page-BbBCu9iP.js} +5 -5
  252. package/payload/server/public/assets/page-BbBCu9iP.js.br +0 -0
  253. package/payload/server/public/assets/page-BbBCu9iP.js.gz +0 -0
  254. package/payload/server/public/assets/play-BJxUaf6-.js +1 -0
  255. package/payload/server/public/assets/play-BJxUaf6-.js.br +0 -0
  256. package/payload/server/public/assets/play-BJxUaf6-.js.gz +0 -0
  257. package/payload/server/public/assets/{public-DEKnH5Q0.js → public-DkObqkzL.js} +1 -1
  258. package/payload/server/public/assets/public-DkObqkzL.js.br +0 -0
  259. package/payload/server/public/assets/public-DkObqkzL.js.gz +0 -0
  260. package/payload/server/public/assets/{rotate-ccw-CBex8gon.js → rotate-ccw-DgMO69nh.js} +1 -1
  261. package/payload/server/public/assets/rotate-ccw-DgMO69nh.js.br +0 -0
  262. package/payload/server/public/assets/rotate-ccw-DgMO69nh.js.gz +0 -0
  263. package/payload/server/public/assets/routines-D9kvg_Xu.js +2 -0
  264. package/payload/server/public/assets/routines-D9kvg_Xu.js.br +0 -0
  265. package/payload/server/public/assets/routines-D9kvg_Xu.js.gz +0 -0
  266. package/payload/server/public/assets/{skills-BmxjPOmq.js → skills-l5I9HZ3D.js} +1 -1
  267. package/payload/server/public/assets/skills-l5I9HZ3D.js.br +0 -0
  268. package/payload/server/public/assets/skills-l5I9HZ3D.js.gz +0 -0
  269. package/payload/server/public/assets/{tasks-BTFYO9vT.js → tasks-C2qJMv6i.js} +1 -1
  270. package/payload/server/public/assets/tasks-C2qJMv6i.js.br +0 -0
  271. package/payload/server/public/assets/tasks-C2qJMv6i.js.gz +0 -0
  272. package/payload/server/public/assets/{triangle-alert-px1YRJJ7.js → triangle-alert-BnC67uSs.js} +1 -1
  273. package/payload/server/public/assets/triangle-alert-BnC67uSs.js.br +0 -0
  274. package/payload/server/public/assets/triangle-alert-BnC67uSs.js.gz +0 -0
  275. package/payload/server/public/assets/{useCopyFeedback-nMXwRcDX.js → useCopyFeedback-1RmgkTFh.js} +1 -1
  276. package/payload/server/public/assets/useCopyFeedback-1RmgkTFh.js.br +0 -0
  277. package/payload/server/public/assets/useCopyFeedback-1RmgkTFh.js.gz +0 -0
  278. package/payload/server/public/assets/{useMediaQuery-DjbOTk2X.js → useMediaQuery-BQslddHQ.js} +1 -1
  279. package/payload/server/public/assets/useMediaQuery-BQslddHQ.js.br +0 -0
  280. package/payload/server/public/assets/useMediaQuery-BQslddHQ.js.gz +0 -0
  281. package/payload/server/public/assets/{useVoiceRecorder-Cx7AOmib.js → useVoiceRecorder-BeZNQEC4.js} +1 -1
  282. package/payload/server/public/assets/useVoiceRecorder-BeZNQEC4.js.br +0 -0
  283. package/payload/server/public/assets/useVoiceRecorder-BeZNQEC4.js.gz +0 -0
  284. package/payload/server/public/assets/{wrench-Cexvlx6J.js → wrench-D4ufQG6C.js} +1 -1
  285. package/payload/server/public/assets/wrench-D4ufQG6C.js.br +0 -0
  286. package/payload/server/public/assets/wrench-D4ufQG6C.js.gz +0 -0
  287. package/payload/server/public/browser.html +5 -5
  288. package/payload/server/public/calendar.html +8 -8
  289. package/payload/server/public/chat.html +14 -14
  290. package/payload/server/public/data.html +12 -12
  291. package/payload/server/public/dispatch.html +5 -5
  292. package/payload/server/public/field.html +3 -3
  293. package/payload/server/public/graph.html +10 -10
  294. package/payload/server/public/index.html +14 -14
  295. package/payload/server/public/operator.html +16 -16
  296. package/payload/server/public/public.html +14 -14
  297. package/payload/server/public/routines.html +8 -8
  298. package/payload/server/public/skills.html +6 -6
  299. package/payload/server/public/tasks.html +7 -7
  300. package/payload/server/server.js +1088 -694
  301. package/payload/server/public/assets/AdminLoginScreens-CMdj1W5p.js.br +0 -0
  302. package/payload/server/public/assets/AdminLoginScreens-CMdj1W5p.js.gz +0 -0
  303. package/payload/server/public/assets/AdminShell-CZcaW3BQ.js.br +0 -0
  304. package/payload/server/public/assets/AdminShell-CZcaW3BQ.js.gz +0 -0
  305. package/payload/server/public/assets/activity-CAhibgb-.js.br +0 -0
  306. package/payload/server/public/assets/activity-CAhibgb-.js.gz +0 -0
  307. package/payload/server/public/assets/admin-xEXv3w9e.js.br +0 -0
  308. package/payload/server/public/assets/admin-xEXv3w9e.js.gz +0 -0
  309. package/payload/server/public/assets/agents-CiAmzTaT.js.br +0 -0
  310. package/payload/server/public/assets/agents-CiAmzTaT.js.gz +0 -0
  311. package/payload/server/public/assets/browser-D36BiYhS.js.br +0 -0
  312. package/payload/server/public/assets/browser-D36BiYhS.js.gz +0 -0
  313. package/payload/server/public/assets/calendar-CJAM-iXn.js.br +0 -0
  314. package/payload/server/public/assets/calendar-CJAM-iXn.js.gz +0 -0
  315. package/payload/server/public/assets/chat-DhZSvtZK.js +0 -1
  316. package/payload/server/public/assets/chat-DhZSvtZK.js.br +0 -0
  317. package/payload/server/public/assets/chat-DhZSvtZK.js.gz +0 -0
  318. package/payload/server/public/assets/chevron-left-CXEcNlc3.js +0 -1
  319. package/payload/server/public/assets/chevron-left-CXEcNlc3.js.br +0 -0
  320. package/payload/server/public/assets/clock-DdxIJwTr.js +0 -1
  321. package/payload/server/public/assets/clock-DdxIJwTr.js.br +0 -0
  322. package/payload/server/public/assets/clock-DdxIJwTr.js.gz +0 -0
  323. package/payload/server/public/assets/copy-axBe92iY.js.br +0 -0
  324. package/payload/server/public/assets/copy-axBe92iY.js.gz +0 -0
  325. package/payload/server/public/assets/data-DUvkI-zq.js +0 -1
  326. package/payload/server/public/assets/data-DUvkI-zq.js.br +0 -0
  327. package/payload/server/public/assets/data-DUvkI-zq.js.gz +0 -0
  328. package/payload/server/public/assets/dispatch-D_i2ko7K.js.br +0 -0
  329. package/payload/server/public/assets/dispatch-D_i2ko7K.js.gz +0 -0
  330. package/payload/server/public/assets/field-DFgkqq1P.js.br +0 -0
  331. package/payload/server/public/assets/field-DFgkqq1P.js.gz +0 -0
  332. package/payload/server/public/assets/file-text-CEFoq0Je.js.br +0 -0
  333. package/payload/server/public/assets/file-text-CEFoq0Je.js.gz +0 -0
  334. package/payload/server/public/assets/graph-BrpbilNr.js.br +0 -0
  335. package/payload/server/public/assets/graph-BrpbilNr.js.gz +0 -0
  336. package/payload/server/public/assets/graph-labels-BtmcWzo7.js.br +0 -0
  337. package/payload/server/public/assets/graph-labels-BtmcWzo7.js.gz +0 -0
  338. package/payload/server/public/assets/jsx-runtime-XuYKjgVb.css +0 -1
  339. package/payload/server/public/assets/jsx-runtime-XuYKjgVb.css.br +0 -0
  340. package/payload/server/public/assets/jsx-runtime-XuYKjgVb.css.gz +0 -0
  341. package/payload/server/public/assets/operator-Dl7dJt1G.js.br +0 -3
  342. package/payload/server/public/assets/operator-Dl7dJt1G.js.gz +0 -0
  343. package/payload/server/public/assets/page-nx31Ij0T.js.br +0 -0
  344. package/payload/server/public/assets/page-nx31Ij0T.js.gz +0 -0
  345. package/payload/server/public/assets/page-zC66xzNT.js.br +0 -0
  346. package/payload/server/public/assets/page-zC66xzNT.js.gz +0 -0
  347. package/payload/server/public/assets/play-MbU8O2iC.js +0 -1
  348. package/payload/server/public/assets/play-MbU8O2iC.js.br +0 -0
  349. package/payload/server/public/assets/play-MbU8O2iC.js.gz +0 -0
  350. package/payload/server/public/assets/public-DEKnH5Q0.js.br +0 -0
  351. package/payload/server/public/assets/public-DEKnH5Q0.js.gz +0 -0
  352. package/payload/server/public/assets/rotate-ccw-CBex8gon.js.br +0 -0
  353. package/payload/server/public/assets/rotate-ccw-CBex8gon.js.gz +0 -0
  354. package/payload/server/public/assets/routines-BqCmbvFJ.js +0 -2
  355. package/payload/server/public/assets/routines-BqCmbvFJ.js.br +0 -0
  356. package/payload/server/public/assets/routines-BqCmbvFJ.js.gz +0 -0
  357. package/payload/server/public/assets/skills-BmxjPOmq.js.br +0 -0
  358. package/payload/server/public/assets/skills-BmxjPOmq.js.gz +0 -0
  359. package/payload/server/public/assets/tasks-BTFYO9vT.js.br +0 -0
  360. package/payload/server/public/assets/tasks-BTFYO9vT.js.gz +0 -0
  361. package/payload/server/public/assets/triangle-alert-px1YRJJ7.js.br +0 -0
  362. package/payload/server/public/assets/triangle-alert-px1YRJJ7.js.gz +0 -0
  363. package/payload/server/public/assets/useCopyFeedback-nMXwRcDX.js.br +0 -0
  364. package/payload/server/public/assets/useCopyFeedback-nMXwRcDX.js.gz +0 -0
  365. package/payload/server/public/assets/useMediaQuery-DjbOTk2X.js.br +0 -0
  366. package/payload/server/public/assets/useMediaQuery-DjbOTk2X.js.gz +0 -0
  367. package/payload/server/public/assets/useVoiceRecorder-Cx7AOmib.js.br +0 -0
  368. package/payload/server/public/assets/useVoiceRecorder-Cx7AOmib.js.gz +0 -0
  369. package/payload/server/public/assets/wrench-Cexvlx6J.js.br +0 -0
  370. package/payload/server/public/assets/wrench-Cexvlx6J.js.gz +0 -0
  371. /package/payload/server/public/assets/{jsx-runtime-C5Hcfj84.js → jsx-runtime-5lmxFBIw.js} +0 -0
  372. /package/payload/server/public/assets/{jsx-runtime-C5Hcfj84.js.br → jsx-runtime-5lmxFBIw.js.br} +0 -0
  373. /package/payload/server/public/assets/{jsx-runtime-C5Hcfj84.js.gz → jsx-runtime-5lmxFBIw.js.gz} +0 -0
@@ -32,8 +32,20 @@
32
32
  */
33
33
  import { ROUTINE_ROSTER, ROUTINE_TEMPLATE_VERSION, type RoutineTemplate } from "./roster.js";
34
34
 
35
- export { ROUTINE_ROSTER, ROUTINE_ROSTER_KEYS, ROUTINE_TEMPLATE_VERSION } from "./roster.js";
36
- export type { RoutineTemplate } from "./roster.js";
35
+ // Task 2471 `isWatermarkedCheck` and `gateNeedsCommit` were declared but not
36
+ // re-exported, so the only callers they ever had were this lib's own tests and
37
+ // the invariant they express went unenforced at both gate write paths. They are
38
+ // public now so the scheduling plugin's `validateGate` can reuse them, rather
39
+ // than restating which check types carry a watermark and letting the two lists
40
+ // drift.
41
+ export {
42
+ ROUTINE_ROSTER,
43
+ ROUTINE_ROSTER_KEYS,
44
+ ROUTINE_TEMPLATE_VERSION,
45
+ isWatermarkedCheck,
46
+ gateNeedsCommit,
47
+ } from "./roster.js";
48
+ export type { RoutineTemplate, RoutineGate, RoutineGateCheck } from "./roster.js";
37
49
 
38
50
  /** The minimal Neo4j session surface the seed needs. Both the admin plugin's
39
51
  * `getSession()` and a driver session from `seed-cli` satisfy it; the tests
@@ -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), linkedin-ingest-token (returns the account's durable LinkedIn ingest token — the one credential the Real Agent LinkedIn Ingest Chrome extension needs in its options page; minted on first call then stable across restarts and new chat sessions, and never a conversation session key nor the admin browser's session_key, which the ingest route rejects; action='rotate' replaces it and invalidates every existing extension install), 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 house-paired WhatsApp/Telegram inbound stream to a different sub-account, and does not affect a sub-account's own separately paired WhatsApp number), 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, the public-agent write tools (agent-create is the only supported way to create a public agent: it validates the slug against the same rule the router serves by, validates config.json against the servable schema, refuses an empty KNOWLEDGE, writes the four files with IDENTITY.md copied verbatim from the platform template, and projects the agent into the graph in the same call, reporting the verified projected count rather than assuming it; agent-update applies the same validation to a merged config, writes only the fields supplied, and re-projects, so the graph cannot fall behind the files; agent-delete removes the :Agent node and its owned knowledge projections before the directory and refuses the admin agent — writing these files by hand reaches none of those checks, which is what left an agent on disk and absent from the graph for a day), agent-image, plugin-toggle-enabled, premium-list, premium-deliver, skill-find, 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 Real Agent 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), linkedin-ingest-token (returns the account's durable LinkedIn ingest token — the one credential the Real Agent LinkedIn Ingest Chrome extension needs in its options page; minted on first call then stable across restarts and new chat sessions, and never a conversation session key nor the admin browser's session_key, which the ingest route rejects; action='rotate' replaces it and invalidates every existing extension install), 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 house-paired WhatsApp/Telegram inbound stream to a different sub-account, and does not affect a sub-account's own separately paired WhatsApp number), 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, admin-bind (sets the phone and/or Telegram chat id on an admin's users.json entry — the binding resolveAdminUserId reads; without it a channel turn from that person resolves to no admin and the answer reaches only the door it arrived on, while a new admin is born bound by passing the same fields to admin-add. Defaults to the calling admin and routes an explicit userId through the same cross-identity gate the PIN tools use, requiring confirm=true, because a binding decides whose identity an inbound resolves to), agent-list, agent-config-read, the public-agent write tools (agent-create is the only supported way to create a public agent: it validates the slug against the same rule the router serves by, validates config.json against the servable schema, refuses an empty KNOWLEDGE, writes the four files with IDENTITY.md copied verbatim from the platform template, and projects the agent into the graph in the same call, reporting the verified projected count rather than assuming it; agent-update applies the same validation to a merged config, writes only the fields supplied, and re-projects, so the graph cannot fall behind the files; agent-delete removes the :Agent node and its owned knowledge projections before the directory and refuses the admin agent — writing these files by hand reaches none of those checks, which is what left an agent on disk and absent from the graph for a day), agent-image, plugin-toggle-enabled, premium-list, premium-deliver, skill-find, 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 Real Agent 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), linkedin-ingest-token (returns the account's durable LinkedIn ingest token — the one credential the Real Agent LinkedIn Ingest Chrome extension needs in its options page; minted on first call then stable across restarts and new chat sessions, and never a conversation session key nor the admin browser's session_key, which the ingest route rejects; action='rotate' replaces it and invalidates every existing extension install), 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 house-paired WhatsApp/Telegram inbound stream to a different sub-account, and does not affect a sub-account's own separately paired WhatsApp number), 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, the public-agent write tools (agent-create is the only supported way to create a public agent: it validates the slug against the same rule the router serves by, validates config.json against the servable schema, refuses an empty KNOWLEDGE, writes the four files with IDENTITY.md copied verbatim from the platform template, and projects the agent into the graph in the same call, reporting the verified projected count rather than assuming it; agent-update applies the same validation to a merged config, writes only the fields supplied, and re-projects, so the graph cannot fall behind the files; agent-delete removes the :Agent node and its owned knowledge projections before the directory and refuses the admin agent — writing these files by hand reaches none of those checks, which is what left an agent on disk and absent from the graph for a day), agent-image, plugin-toggle-enabled, premium-list, premium-deliver, skill-find, 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 Real Agent 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), linkedin-ingest-token (returns the account's durable LinkedIn ingest token — the one credential the Real Agent LinkedIn Ingest Chrome extension needs in its options page; minted on first call then stable across restarts and new chat sessions, and never a conversation session key nor the admin browser's session_key, which the ingest route rejects; action='rotate' replaces it and invalidates every existing extension install), 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 house-paired WhatsApp/Telegram inbound stream to a different sub-account, and does not affect a sub-account's own separately paired WhatsApp number), 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, admin-bind (sets the phone and/or Telegram chat id on an admin's users.json entry — the binding resolveAdminUserId reads; without it a channel turn from that person resolves to no admin and the answer reaches only the door it arrived on, while a new admin is born bound by passing the same fields to admin-add. Defaults to the calling admin and routes an explicit userId through the same cross-identity gate the PIN tools use, requiring confirm=true, because a binding decides whose identity an inbound resolves to), agent-list, agent-config-read, the public-agent write tools (agent-create is the only supported way to create a public agent: it validates the slug against the same rule the router serves by, validates config.json against the servable schema, refuses an empty KNOWLEDGE, writes the four files with IDENTITY.md copied verbatim from the platform template, and projects the agent into the graph in the same call, reporting the verified projected count rather than assuming it; agent-update applies the same validation to a merged config, writes only the fields supplied, and re-projects, so the graph cannot fall behind the files; agent-delete removes the :Agent node and its owned knowledge projections before the directory and refuses the admin agent — writing these files by hand reaches none of those checks, which is what left an agent on disk and absent from the graph for a day), agent-image, plugin-toggle-enabled, premium-list, premium-deliver, skill-find, 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 Real Agent platform."
5
5
  account-owned-files: [{"file": "webchat-channel-bindings.json", "description": "Routes this account's inbound admin webchat messages. Written by the session manager's webchat-channel-store."}, {"file": "wa-channel-bindings.json", "description": "Routes this account's inbound WhatsApp messages. Written by the session manager's wa-channel-store, which runs on every brand whether or not the whatsapp plugin ships."}, {"file": "telegram-channel-bindings.json", "description": "Routes this account's inbound Telegram messages. Written by the session manager's telegram-channel-store, which runs on every brand whether or not the telegram plugin ships."}, {"file": "canonical-webchat-session.json", "description": "Pins the account's canonical webchat session. Written by the admin UI server."}, {"file": "session-titles.json", "description": "Operator renames of sessions. Written by the session manager's title stores."}, {"file": "agents-disabled.json", "description": "Names the agents the operator switched off. Written by the admin agents route; provision reads it to re-apply the disables and never creates it."}]
6
6
  tools:
7
7
  - name: system-status
@@ -92,6 +92,10 @@ tools:
92
92
  publicAllowlist: false
93
93
  adminAllowlist: true
94
94
  riskClass: write_local
95
+ - name: admin-bind
96
+ publicAllowlist: false
97
+ adminAllowlist: true
98
+ riskClass: write_local
95
99
  - name: agent-list
96
100
  publicAllowlist: false
97
101
  adminAllowlist: true
@@ -225,7 +229,7 @@ Tools are available via the `admin` MCP server.
225
229
 
226
230
  **Client sub-accounts carry one owner identity, no operator seats.** Since admin access is install-wide (`canAccessAdmin` never reads `admins[]`), so `admin-add` on a `role:'client'` sub-account refuses before any store write and logs `[admin-add] op=refused-on-client reason=install-wide-access-no-seat account=<id8>`; only the house account seats admins. A standing session-manager audit (`redundant-operator-seat-audit.ts`) flags any pre-fix non-owner `AdminUser` on a client account with `[identity-audit] op=redundant-operator-seat`; removal of a flagged seat is a deliberate remediation, never automatic. See `.docs/admin-identity-surface.md`.
227
231
 
228
- **Three-store admin auth invariant.** `admin-add` (on the house account) writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is forbidden by IDENTITY.md doctrine — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
232
+ **Three-store admin auth invariant.** `admin-add` (on the house account) writes to all three identity stores (`users.json` PIN auth at the persistent `~/{configDir}/users.json` location , `account.json` `admins[]` role, Neo4j `:AdminUser`/`:Person` graph identity) with per-leg `[admin-auth-store]` log lines plus the `[admins-write]` chokepoint line, and returns `is_error: true` on any leg failure naming what's already written. `admin-update-pin` writes `users.json` only and emits the same `[admin-auth-store]` line. **Single chokepoint:** every `users.json` + `account.json admins[]` mutation routes through `platform/lib/admins-write` (`writeAdminEntry` for admin-add/set-pin/admin-bind, `removeAdminFromAccount` for admin-remove); the static check `grep -rnE 'admins\.push\|config\.admins\s*=' platform/ \| grep -v lib/admins-write` returns 0. Direct `Edit`/`Write` on `account.json` is forbidden by IDENTITY.md doctrine — mutations go through `account-update`, `plugin-toggle-enabled`, or the `admin-*` tools. **Install + boot invariants:** the installer and admin-server boot walk every account.json admins[] vs users.json; `[install-invariant]` / `[admin-invariant] direction=… userId=<id8> source=<file>` fires per divergence with a `check complete divergences=<n>` summary. Log-only — does not block. See `.docs/agents.md` § "Three-store admin auth invariant" for the full contract.
229
233
 
230
234
  **Credential audit trail (append-only).** Every write to the credential stores emits one canonical `[users-audit] action=<set-access|clear-access|add|remove|set-pin|migrate> actor=<id8|onboarding|install|boot|unknown> [session=<id8>] field=<accessHash|pin|row> rowsBefore=<id8,…> rowsAfter=<id8,…> ts=<iso>` line to `~/{configDir}/logs/users-audit.log` — a dedicated file, distinct from `server.log`, so a credential mutation is greppable in isolation. The line is emitted at the writer chokepoint (`admin-access-password` for accessHash set/clear/migrate, `admins-write` `writeAdminEntry`/`removeAdminFromAccount` for row/pin/role) so every caller is covered; the one direct users.json writer that does not route through `writeAdminEntry` — `admin-update-pin` — emits the same line via the exported `logUsersAudit`. A write that changes no field (a clear with no hash, a migrate noop, an absent-admin remove) emits nothing. Credential-mutating MCP tools add `session=<id8>` from `SESSION_NODE_ID` so a mutation traces to its agent session even when the transcript is gone.
231
235
 
@@ -272,8 +276,9 @@ Tools are available via the `admin` MCP server.
272
276
  - `hooks/quote-engine-gate.sh` — **PreToolUse matcher=`Bash`** (a third command on the same `Bash` matcher as `archive-ingest-surface-gate` and `fs-schema-guard-bash-pre`, any exit 2 blocks). Makes the deterministic `quote-engine-run` / `quote-render` tools the only route the account's persisted quoting scripts can run. Blocks (exit 2) a `Bash` `command` that executes `quoting/engine.mjs` or `quoting/render.mjs` directly — the pricing and render scripts must run through their MCP tools, which apply the reconcile / margin-leak / link-liveness gates and write the figures and render receipts. A hand-run `node quoting/engine.mjs` (the SiteDesk 2026-07-25 bypass: a repricing dispatched to content-producer that ran the engine by hand and re-rendered off the deterministic path) skips all of that, so it is refused. It fires only on an execution (a JS runtime invoking the script, or the script run at command position); a read or inspect of the file (`cat`/`grep`/`git diff quoting/engine.mjs`) and every unrelated `Bash` command pass at exit 0. A `mode=verify` reproduction is gated too — it must also run through `quote-engine-run`, so a hand-run `node quoting/engine.mjs mode=verify` is refused. Known fail-open gap (accepted, consistent with the soft posture): a `cd quoting && node engine.mjs` drops the `quoting/` prefix and is not matched. **Block message:** `Blocked: the quoting engine and render scripts must run through their tools, not directly. Use the quote-engine-run tool to price a job (it files the figures) and the quote-render tool to produce the documents (it files them behind the reconcile, margin-leak and link-liveness gates). Running quoting/engine.mjs or quoting/render.mjs by hand skips those gates and files no receipt.` **Log line** (stderr, on a block): `[quote-engine] op=bypass reason=direct-script-exec`. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `Bash` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated commands.
273
277
  - `hooks/quote-pdf-bash-gate.sh` — **PreToolUse matcher=`Bash`.** Closes the Bash route to a client quote PDF. `quote-render-gate` refuses a hand-authored quote at `browser-pdf-save`, but an agent holding `Bash` reaches the same outcome with any HTML-to-PDF converter and meets no control at all (`content-producer` holds `Bash`, `Write`, `Edit` and `browser-pdf-save`; `quote-engine-gate` refuses only execution of the two persisted quoting scripts). Blocks (exit 2) a command that names both an `.html` under a gated quote path — `memory/users/<phone>/documents/`, `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` — and a `.pdf`. The match is structural on the paths, never on the converter's name, so `wkhtmltopdf`, `chromium --print-to-pdf` and `weasyprint` are covered by the same rule. A read that names no PDF (`cat q.html`) passes, and `jobs/<jobId>/pricing/` is never gated. **Block message:** `Blocked: a client quote PDF must come from the quote-render tool, not from a converter run by hand. Run quote-render for this job — it files the document and its render receipt — then print the client HTML path it returns.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=bash-pdf-production`. **Fail-open** on an uninspectable call.
274
278
  - `hooks/quote-path-write-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit` / `NotebookEdit`.** The roster safeguard expressed as a capability rather than a removal. Blocks (exit 2) a write whose target is under `jobs/<jobId>/quote/`, `jobs/<jobId>/Quotations/` or `quoting/templates/` when the calling agent's card does not grant `quote-render`. `jobs/<jobId>/pricing/` is excluded — the internal priced breakdown is not a client quote artefact — and every other deliverable the agent owns (works orders, heritage statements, survey sheets, drawing registers, trial-pit records, invoice stamps) is untouched. **Caller identity is `agent_type` from the hook envelope**, which Claude Code sets when a hook fires inside a subagent; `MAXY_SPECIALIST` is not usable, since it is stamped only when a specialist is spawned as its own PTY process and reads empty for the native Agent-tool subagents this gate exists to stop. **Card lookup spans both registration families** — the per-account registry (`<accountDir>/.claude/agents/`, `<accountDir>/specialists/agents/`), `$CLAUDE_CONFIG_DIR/agents/`, and the plugin roots (`$(dirname $PLATFORM_ROOT)/premium-plugins/*/agents/`, `$PLATFORM_ROOT/plugins/*/agents/`) — matching on frontmatter `name:` as well as filename, because the quoter's card is filed as `sitedesk--quoter.md` and declares `name: quoter`. Omitting the premium root would block the quoter itself. A write from the main conversation carries no `agent_type` and the admin seat holds no `quote-render`, so it is refused too: no part of a client quote document is another agent's work, the admin's included. **Block message:** `Blocked: quote documents and quote templates are the quoter's work. Dispatch the quoter for this change; it produces the document through the quote-render tool, which files it with its render receipt.` **Log line** (stderr, on a block): `[quote-render] op=bypass reason=<quote-path-write-without-render-tool|quote-path-write-unresolved-agent>`. **Fail-open** on an uninspectable envelope.
279
+ - `hooks/html-structure-gate.sh` — **PreToolUse matcher=`Write` / `Edit` / `MultiEdit`.** Refuses a write that would produce an HTML document with a `<style>` element opened inside another one, or with more openers than closers. The fault is not cosmetic: HTML parses style content as raw text, so the **first** `</style>` closes the outer element — everything after it becomes body content and renders as visible text, and the rule immediately after the stray opener is eaten as part of an invalid selector. One fault, two symptoms, both seen on a client quotation that went out on 2026-07-24 and was noticed on 2026-08-07. **Detector, three rules:** HTML comments are stripped first, each replaced by its own newlines, so a `<style>` inside `<!-- … -->` is not scanned and line numbers do not shift (two account templates carry one and are clean; a comment-blind detector reports both); `<style…>`/`</style…>` are then walked case-insensitively tracking depth, and an opener seen at depth > 0 is the fault; a CSS comment protects an **opener** but never a **closer**, because style content is raw text to the HTML parser so `</style>` terminates the element wherever it appears, while an opener is never a parse event inside a stylesheet — it is text the CSS parser reads as an invalid selector, and inside `/* … */` it is discarded (the shipped property-brochure `template.html` and `index.html` document their brand-token override slot exactly that way and are correct); at end of input openers must equal closers, and an unmatched opener is reported at its own line. The same three rules are implemented in the session manager's `html-structure-census`, which walks what is already on disk. **It walks the document the call would produce, not the fragment** — for `Write` that is `content`; for `Edit`/`MultiEdit` the on-disk file is read and the replacements applied in memory first, because the balance rule would otherwise refuse every partial edit and a nested opener whose outer `<style>` is already on disk would pass. **Scope is the whole account with an exclusion list, never a bucket list**: every `.html`/`.htm` resolving inside the account dir (this hook's cwd, as in `fs-schema-guard.sh`) except under `node_modules/`, `.wrangler/`, `.tool/`, `.trash/`. A bucket list is what let the live document through — it sat under `sites/<project>/public/`, which no quote bucket covers. `NotebookEdit` is deliberately not registered: a notebook is not a client document. **Block message:** `Blocked: this document opens a <style> element inside another one (line N). The browser closes the outer element at the first </style>, so the rest of the stylesheet renders as visible text on the page and the rule after the stray opener is discarded. Put the block in the body as its own <style> element, or merge its rules into the one already open.` **Log line:** `[html-structure-gate] op=refuse account=<id8> file=<json-quoted-path> line=<n>`, a direct append to `<logDir>/server.log` resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<cwd>/logs` ladder, not a stderr echo. There is deliberately **no hard-coded brand fallback**: four brands are co-resident on the laptop, each with its own `~/.<brand>/logs`, so a literal would file this account's path in a sibling brand's `server.log`; when no rung resolves the line is dropped and the refusal still blocks. The path is JSON-quoted because these filenames carry spaces and a bare `key=value` line truncates at the first space. **Fail-open** on an uninspectable envelope (tty / empty stdin / unreadable target / unparseable edit).
275
280
  - `hooks/preference-consult-gate.sh` — **PreToolUse gate on customer-facing document deliverables.** Registered against `mcp__plugin_browser_browser__browser-pdf-save` (a second command on the same matcher as `quote-render-gate`, either exit 2 blocks) and against the email/Outlook send tools plus `SendUserFile`. It fires only in the document-deliverable shape: a `browser-pdf-save` or `SendUserFile` whose path is under `memory/users/<phone>/documents/` (or a `SendUserFile` of a `.pdf`/`.html`/`.docx`), and `email-send`/`email-reply`/`email-draft-send`/`outlook-mail-send`/`outlook-mail-reply`/`outlook-draft-send` carrying a non-empty `attachments`. For a gated call it reads the session transcript (`transcript_path`) and blocks (exit 2) when no `profile-read` tool call ran after the last user message, because the account's layer-2 `Preference` records (signature policy, header, naming, styling) were not consulted this turn, the exact miss that dropped a customer signature on a delivered document. **Block message:** `Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again.` **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block, `op=allow tool=<name> consulted=true` on an allowed document deliverable. Every non-document tool/shape, and every casual channel reply or plain-text email, passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / transcript missing or unreadable). Scope decision: the gate fires on document deliverables only, not on every send, so it never blocks routine chat. The standing reconcile counterpart for stranded `output/` deliverables is filed as a follow-up task.
276
- - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Third check — a running header on every physical page.** Chrome decides physical pagination at print time, so a document whose header is a hand-placed `<div>` next to a hand-placed page break loses that header on any page the content bled onto (job 1854, 2026-08-04: 8 headerless pages out of 21, with `run-head` and `page-break` counts matching at 20 apiece, which is why counting the two against each other is blind to the fault). This check is deliberately **receipt-free and path-agnostic**, because template-seeded documents — budget estimates, valuations, works orders, survey sheets — never pass through `quote-render` and are exactly the ones that reproduce it. It resolves the source HTML from the render receipt when the PDF is a gated client quote, else from the sibling `<same-basename>.html` beside the PDF (the documented `browser-pdf-save` convention in `plugins/business-assistant/references/invoicing.md` step 4 and `plugins/business-assistant/references/document-management.md` step 4). The **header marker** is the literal string `content` of the first `@page` top margin box that carries one, tried `@top-center` → `@top-right` → `@top-left`, with CSS escapes and HTML entities resolved; a `content:url()` logo box carries no extractable text and is skipped, and a marker under 4 characters counts as absent. Pages are read one at a time with `pdftotext -f N -l N` **cropped to the top 12% of the page** and compared on whitespace-collapsed text. The crop is load-bearing, not an optimisation: a running footer routinely repeats the same reference as the header (the GLS footer is `… Builders Est. 1987 · Quote <ref>`), so a whole-page match reports a header on a page that carries only a footer — measured on a real 10-page print, where page 1 matched on its footer alone while its header was correctly suppressed. Twelve percent covers a top margin as deep as 30mm on A4 and excludes the footer. Checking starts at **page 2** when the source declares `@page :first` (which suppresses the boxes so page 1 keeps its own masthead) and from page 1 otherwise. It blocks (exit 2) when any checked page lacks the marker. **Only a document that declares a running header is ever gated** — a brochure or any document without one passes untouched. **Block message:** `Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages (…). The header must be a property of the page itself — a page margin box — not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page.` **Log line:** `<ISO> [quote-render] op=pdf-header-conformance file=<basename> pages=<N> pagesChecked=<n> pagesHeaderless=<k> session=<sid>`, emitted on **every** decisive call including a clean one (`pagesHeaderless=0`), and on each skip path as `pagesChecked=0 reason=<no-source-html|no-running-header|single-page|no-poppler>` — so a clean run is distinguishable from a check that never ran. A block additionally emits `op=bypass file=<basename> reason=pdf-header-conformance detail=pagesHeaderless=<k> pages=<comma list>`. **Sink:** `<logDir>/quote-render-conformance.log`, resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `ACCOUNT_DIR/logs` ladder that `mailbox-inject.sh` uses, **not** `server.log` — a hook's stderr on exit 0 reaches no sink at all (measured 2026-08-06 on the laptop's `sitedesk-code` install: no bracketed hook line appears in `server.log` live or rotated, nor in the JSONL transcripts), so a stderr-only line would have been an audit nothing reads. The line is echoed to stderr as well, so a blocking run still shows it to the agent. **Fail-open** additionally on absent `pdfinfo`/`pdftotext`, an unresolvable source HTML, and a source that declares no running header. **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
281
+ - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Third check — a running header on every physical page.** Chrome decides physical pagination at print time, so a document whose header is a hand-placed `<div>` next to a hand-placed page break loses that header on any page the content bled onto (job 1854, 2026-08-04: 8 headerless pages out of 21, with `run-head` and `page-break` counts matching at 20 apiece, which is why counting the two against each other is blind to the fault). This check is deliberately **receipt-free and path-agnostic**, because template-seeded documents — budget estimates, valuations, works orders, survey sheets — never pass through `quote-render` and are exactly the ones that reproduce it. It resolves the source HTML from the render receipt when the PDF is a gated client quote, else from the sibling `<same-basename>.html` beside the PDF (the documented `browser-pdf-save` convention in `plugins/business-assistant/references/invoicing.md` step 4 and `plugins/business-assistant/references/document-management.md` step 4). The **header marker** is the literal string `content` of the first `@page` top margin box that carries one, tried `@top-center` → `@top-right` → `@top-left`, with CSS escapes and HTML entities resolved; a `content:url()` logo box carries no extractable text and is skipped, and a marker under 4 characters counts as absent. Pages are read one at a time with `pdftotext -f N -l N` **cropped to the top 12% of the page** and compared on whitespace-collapsed text. The crop is load-bearing, not an optimisation: a running footer routinely repeats the same reference as the header (the GLS footer is `… Builders Est. 1987 · Quote <ref>`), so a whole-page match reports a header on a page that carries only a footer — measured on a real 10-page print, where page 1 matched on its footer alone while its header was correctly suppressed. Twelve percent covers a top margin as deep as 30mm on A4 and excludes the footer. Checking starts at **page 2** when the source declares `@page :first` (which suppresses the boxes so page 1 keeps its own masthead) and from page 1 otherwise. It blocks (exit 2) when any checked page lacks the marker. **Only a document that declares a running header is ever gated** — a brochure or any document without one passes untouched. **Block message:** `Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages (…). The header must be a property of the page itself — a page margin box — not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page.` **Log line:** `<ISO> [quote-render] op=pdf-header-conformance file=<basename> pages=<N> pagesChecked=<n> pagesHeaderless=<k> session=<sid>`, emitted on **every** decisive call including a clean one (`pagesHeaderless=0`), and on each skip path as `pagesChecked=0 reason=<no-source-html|no-running-header|single-page|no-poppler>` — so a clean run is distinguishable from a check that never ran. A block additionally emits `op=bypass file=<basename> reason=pdf-header-conformance detail=pagesHeaderless=<k> pages=<comma list>`. **Sink:** `<logDir>/quote-render-conformance.log`, resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `ACCOUNT_DIR/logs` ladder that `mailbox-inject.sh` uses, **not** `server.log` — a hook's stderr on exit 0 reaches no sink at all (measured 2026-08-06 on the laptop's `sitedesk-code` install: no bracketed hook line appears in `server.log` live or rotated, nor in the JSONL transcripts), so a stderr-only line would have been an audit nothing reads. The line is echoed to stderr as well, so a blocking run still shows it to the agent. **Fail-open** additionally on absent `pdfinfo`/`pdftotext`, an unresolvable source HTML, and a source that declares no running header. **Fourth check — the running header image's geometry.** Chromium paints a page-margin box's `content:url()` at **intrinsic pixel size** (one image px to one CSS px at 96 px/in) and does **not** scale it to the box's declared `width`/`height`, so a full-resolution logo covers most of the page on every page the box paints — measured 2026-08-05 on a real print, a 700×252 asset in a declared 34mm box rendering 525.0 pt wide on a 594.96 pt A4 page, 88% of page width, against the corrected 125×45 derivative's 93.8 pt. It runs in the same offline stage as the header check, after it rather than instead of it, so **one print reports a headerless page and an oversized logo together** rather than surfacing the second only after the first is fixed and reprinted; both stages still precede the network fetch. **Only a document that declares an image in a `@page` top margin box is inspected** — a `@top-left|center|right` block whose `content` is a `url(...)` — so a brochure or a text-only running header is never gated. Using `pdfimages -list` it groups image rows by object id across the checked page range and asserts **only the running image, the object present on every checked page**, which is what a running header is by construction. The assertion is `width_px / x_ppi * 72 ≤ page_width / 3`, 198.3 pt on A4. Scoping to the running image is load-bearing, not an optimisation: measured across three real quotes, running headers occupy 9.0%, 15.8% and 18.9% of page width while **single-page content photos occupy 38.7% to 77.2%**, so a whole-page threshold at any value that catches the 88% defect blocks legitimate documents — a 33-page quote was measured carrying two 38.7% photos and a 21-page quote a 77.2% one. Nothing is asserted when the checked range is under two pages, since "present on every page" is vacuous there. **Block message:** `Blocked: the running header image on this printed document covers most of the page width (…). An image used as a page margin box's content is painted at its own pixel size and is not scaled down to the box you declared, so the image file itself has to be made the size the box expects. Shrink the asset, rebuild the document, print it again, and check a page after the first.` **Log line:** `op=pdf-image-geometry file=<basename> pagesChecked=<n> runningImages=<k> widestPt=<pt> fracPage=<pct>% limitPt=<pt>`, emitted whether or not it fails because it is the line that decides the verdict, and on a skip as `pagesChecked=0 reason=no-poppler`. A block additionally emits `op=bypass file=<basename> reason=pdf-header-image-geometry detail=widestPt=… fracPage=…% limitPt=…`. **Every skip now names its reason.** The link stage returned silently at seven points; each emits `op=skip reason=<bad-envelope|not-browser-tool|no-path|not-gated|bad-jobid|no-receipt|no-source>`, because a silent return is indistinguishable from a gate that never ran — which is the state a stale PostToolUse matcher actually leaves an account in. Fail-open behaviour itself is unchanged; only its visibility. The two returns taken before the log sink resolves (tty stdin, empty stdin) stay silent: there is no log directory or session id at that point. **Standing audit.** The gate only fires on a print, so four failure modes emit nothing and do not reproduce on demand: the gate passing a defective PDF, the gate never matching the path, a template drifting back to a full-resolution asset, and a stale document never re-rendered. `claude-session-manager`'s `quote-print-census.ts` sweeps hourly, emitting `[quote-print-census] scanned=<N> pdfs=<N> sourceHtml=<N> headerMissingPages=<N> oversizedImages=<N> unreadable=<N>` to `server.log`. It does **not** re-implement either rule: it invokes this hook once per PDF with `LOG_DIR`/`CLAUDE_SESSION_MANAGER_PERSIST_DIR`/`ACCOUNT_DIR` stripped, so the hook resolves no log directory, writes no file, and returns its verdict on stderr — one implementation, no drift. Its source-HTML half reads each margin-box image's intrinsic PNG width directly (over 264 px cannot fit a third of an A4 page at 96 px/in), which sees a drifted template **before** anything is printed from it. Healthy is `headerMissingPages=0 oversizedImages=0` with `pdfs` non-zero; the leak signature is `oversizedImages` above zero with no `op=bypass` ever emitted, meaning defective documents exist the gate never saw. A PDF that produced no verdict line, or a `no-poppler` skip, counts `unreadable` rather than clean. **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
277
282
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
278
283
  - `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
279
284
  - `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your answer and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_settings` (`platform/scripts/lib/account-settings-reconcile.sh`) on every install, converging every existing account's `settings.json` against `platform/templates/account-settings.json` — which carries this matcher — with one `[settings-reconcile] account=<id> status=<already-set|updated|absent-seeded|rewrite-failed|no-template>` line per account and one `[settings-reconcile] accounts=<n> divergent=<n>` summary, contents never echoed. (The per-hook backfill lib this converge replaced has been retired.)
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for html-structure-gate.sh.
3
+ #
4
+ # A second <style> opener nested inside an open <style> element is not a
5
+ # cosmetic fault. HTML parses style content as raw text, so the FIRST </style>
6
+ # closes the outer element: everything after it becomes body content and renders
7
+ # as visible text on the page, and the rule immediately after the stray opener is
8
+ # eaten as part of an invalid selector. One fault, two symptoms, and a client
9
+ # quotation went out carrying both.
10
+ #
11
+ # The gate walks the document the call would PRODUCE, not the fragment it
12
+ # carries. For Write that is `content`; for Edit and MultiEdit the on-disk file
13
+ # is read and the replacements applied in memory first. Walking a fragment cannot
14
+ # work: the unbalanced-at-EOF rule would refuse every partial edit, and a nested
15
+ # opener whose outer <style> is already on disk would pass.
16
+ #
17
+ # Scope is the whole account with an exclusion list, never a bucket list. A
18
+ # bucket list is what let the live file through: it sat under sites/<project>/
19
+ # public/, which no quote bucket covers.
20
+ #
21
+ # Covers:
22
+ # A. Write producing a nested opener → BLOCKED, names the line
23
+ # B. the same document with the opener removed → ALLOWED
24
+ # C. <style> inside an HTML comment → ALLOWED
25
+ # D. three openers, two closers → BLOCKED, names the unmatched one
26
+ # E. a write under sites/<project>/public/ → BLOCKED (not a quote bucket)
27
+ # F. a write under node_modules/ → ALLOWED (excluded)
28
+ # G. a .txt target → ALLOWED
29
+ # H. Edit introducing the opener into a clean file → BLOCKED
30
+ # I. Edit elsewhere in an already-malformed file → BLOCKED
31
+ # J. MultiEdit whose second edit introduces it → BLOCKED
32
+ # K. empty stdin → ALLOWED (fail-open)
33
+ # L. a Read of a malformed path → ALLOWED
34
+ # M. a path outside the account dir → ALLOWED
35
+ # N. a block appends the op=refuse line, path JSON-quoted
36
+
37
+ set -u
38
+
39
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/html-structure-gate.sh"
40
+ if [[ ! -x "$HOOK" ]]; then
41
+ echo "FAIL: $HOOK not executable" >&2
42
+ exit 1
43
+ fi
44
+
45
+ FAKE=$(mktemp -d)
46
+ cleanup() { rm -rf "$FAKE"; }
47
+ trap cleanup EXIT
48
+
49
+ ACCT="$FAKE/accounts/2078cb54abcd"
50
+ mkdir -p "$ACCT/sites/gls-sign/public" "$ACCT/node_modules/pkg" "$ACCT/logs" \
51
+ "$ACCT/documents/Quotations/1867" "$FAKE/outside"
52
+
53
+ # A document whose stray opener sits at line 4.
54
+ NESTED='<html>
55
+ <style>
56
+ :root{--orange:#F89828;}
57
+ <style>
58
+ .esign-box{border:1px solid var(--orange);}
59
+ </style>
60
+ <body>ok</body>
61
+ </html>'
62
+
63
+ CLEAN='<html>
64
+ <style>
65
+ :root{--orange:#F89828;}
66
+ .esign-box{border:1px solid var(--orange);}
67
+ </style>
68
+ <body>ok</body>
69
+ </html>'
70
+
71
+ # The false positive a comment-blind detector produces: the templates on the
72
+ # live install carry a <style> inside an HTML comment and are clean.
73
+ COMMENTED='<html>
74
+ <style>
75
+ a{}
76
+ <!--
77
+ <style>
78
+ -->
79
+ </style>
80
+ </html>'
81
+
82
+ # Three openers, two closers. The unmatched opener is on line 5.
83
+ UNBALANCED='<style>
84
+ </style>
85
+ <style>
86
+ </style>
87
+ <style>
88
+ a{}'
89
+
90
+ # Already malformed on disk: an edit anywhere in it still produces a malformed
91
+ # document, so it is refused.
92
+ printf '%s\n' "$NESTED" > "$ACCT/documents/Quotations/1867/broken.html"
93
+ printf '%s\n' "$CLEAN" > "$ACCT/documents/Quotations/1867/clean.html"
94
+ printf '%s\n' "$NESTED" > "$FAKE/outside/broken.html"
95
+
96
+ PASS=0
97
+ FAIL=0
98
+
99
+ write_envelope() { # file_path, content
100
+ python3 -c '
101
+ import json, sys
102
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Write",
103
+ "tool_input": {"file_path": sys.argv[1], "content": sys.argv[2]}}))' "$1" "$2"
104
+ }
105
+
106
+ edit_envelope() { # file_path, old_string, new_string
107
+ python3 -c '
108
+ import json, sys
109
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "Edit",
110
+ "tool_input": {"file_path": sys.argv[1], "old_string": sys.argv[2],
111
+ "new_string": sys.argv[3]}}))' "$1" "$2" "$3"
112
+ }
113
+
114
+ multiedit_envelope() { # file_path, old1, new1, old2, new2
115
+ python3 -c '
116
+ import json, sys
117
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": "MultiEdit",
118
+ "tool_input": {"file_path": sys.argv[1], "edits": [
119
+ {"old_string": sys.argv[2], "new_string": sys.argv[3]},
120
+ {"old_string": sys.argv[4], "new_string": sys.argv[5]}]}}))' "$1" "$2" "$3" "$4" "$5"
121
+ }
122
+
123
+ other_envelope() { # tool_name, file_path
124
+ python3 -c '
125
+ import json, sys
126
+ print(json.dumps({"hook_event_name": "PreToolUse", "tool_name": sys.argv[1],
127
+ "tool_input": {"file_path": sys.argv[2]}}))' "$1" "$2"
128
+ }
129
+
130
+ run_case() { # name, stdin, expected_exit
131
+ local name="$1" stdin="$2" expected_exit="$3" actual_exit
132
+ ( cd "$ACCT" && export LOG_DIR="$ACCT/logs" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
133
+ actual_exit=$?
134
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
135
+ echo "PASS: $name (exit=$actual_exit)"
136
+ PASS=$((PASS + 1))
137
+ else
138
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
139
+ FAIL=$((FAIL + 1))
140
+ fi
141
+ }
142
+
143
+ run_line_case() { # name, stdin, expected_line
144
+ local name="$1" stdin="$2" want="$3" out
145
+ out=$( cd "$ACCT" && export LOG_DIR="$ACCT/logs" && printf '%s' "$stdin" | bash "$HOOK" 2>&1 >/dev/null )
146
+ if printf '%s' "$out" | grep -q "line $want"; then
147
+ echo "PASS: $name (names line $want)"
148
+ PASS=$((PASS + 1))
149
+ else
150
+ echo "FAIL: $name (expected line $want, got: $out)" >&2
151
+ FAIL=$((FAIL + 1))
152
+ fi
153
+ }
154
+
155
+ run_case "A: Write producing a nested opener → BLOCKED" \
156
+ "$(write_envelope 'documents/Quotations/1867/new.html' "$NESTED")" 2
157
+
158
+ run_line_case "A2: the block names the stray opener's line" \
159
+ "$(write_envelope 'documents/Quotations/1867/new.html' "$NESTED")" 4
160
+
161
+ run_case "B: the same document with the opener removed → ALLOWED" \
162
+ "$(write_envelope 'documents/Quotations/1867/new.html' "$CLEAN")" 0
163
+
164
+ run_case "C: <style> inside an HTML comment → ALLOWED" \
165
+ "$(write_envelope 'documents/Quotations/1867/front-matter.html' "$COMMENTED")" 0
166
+
167
+ run_case "D: three openers, two closers → BLOCKED" \
168
+ "$(write_envelope 'documents/Quotations/1867/new.html' "$UNBALANCED")" 2
169
+
170
+ run_line_case "D2: the unbalanced block names the unmatched opener" \
171
+ "$(write_envelope 'documents/Quotations/1867/new.html' "$UNBALANCED")" 5
172
+
173
+ run_case "E: a write under sites/<project>/public/ → BLOCKED" \
174
+ "$(write_envelope 'sites/gls-sign/public/quote-gresswell.html' "$NESTED")" 2
175
+
176
+ run_case "F: a write under node_modules/ → ALLOWED (excluded)" \
177
+ "$(write_envelope 'node_modules/pkg/broken.html' "$NESTED")" 0
178
+
179
+ run_case "G: a .txt target → ALLOWED" \
180
+ "$(write_envelope 'documents/Quotations/1867/notes.txt' "$NESTED")" 0
181
+
182
+ run_case "H: Edit introducing the opener into a clean file → BLOCKED" \
183
+ "$(edit_envelope 'documents/Quotations/1867/clean.html' ' .esign-box' '<style>
184
+ .esign-box')" 2
185
+
186
+ run_case "I: Edit elsewhere in an already-malformed file → BLOCKED" \
187
+ "$(edit_envelope 'documents/Quotations/1867/broken.html' '<body>ok</body>' '<body>changed</body>')" 2
188
+
189
+ run_case "J: MultiEdit whose second edit introduces the opener → BLOCKED" \
190
+ "$(multiedit_envelope 'documents/Quotations/1867/clean.html' '<body>ok</body>' '<body>two</body>' ' .esign-box' '<style>
191
+ .esign-box')" 2
192
+
193
+ # The shipped property-brochure template documents its override slot with a CSS
194
+ # comment naming the tag it wants emitted. The CSS parser discards a comment, so
195
+ # the mention is inert. A closer is NOT protected the same way: style content is
196
+ # raw text to the HTML parser, so </style> terminates the element wherever it
197
+ # appears.
198
+ CSS_COMMENT='<html>
199
+ <style>
200
+ a{}
201
+ /* branded register MUST emit a <style id="brand-tokens"> block here */
202
+ b{}
203
+ </style>
204
+ </html>'
205
+
206
+ CLOSER_IN_COMMENT='<html>
207
+ <style>
208
+ a{}
209
+ /* </style> */
210
+ <style>
211
+ <style>
212
+ </style>
213
+ </html>'
214
+
215
+ run_case "K: empty stdin → ALLOWED (fail-open)" "" 0
216
+
217
+ run_case "K2: an opener named inside a CSS comment → ALLOWED" \
218
+ "$(write_envelope 'documents/Quotations/1867/brochure.html' "$CSS_COMMENT")" 0
219
+
220
+ run_case "K3: a closer inside a CSS comment still closes, so the next nesting is caught" \
221
+ "$(write_envelope 'documents/Quotations/1867/brochure.html' "$CLOSER_IN_COMMENT")" 2
222
+
223
+ run_case "L: a Read of a malformed path → ALLOWED" \
224
+ "$(other_envelope Read 'documents/Quotations/1867/broken.html')" 0
225
+
226
+ run_case "M: a path outside the account dir → ALLOWED" \
227
+ "$(write_envelope "$FAKE/outside/broken.html" "$NESTED")" 0
228
+
229
+ # N. The observability line: one append per refusal, path JSON-quoted so a
230
+ # filename carrying spaces survives a key=value reader.
231
+ SPACED='documents/Quotations/1867/Q1867 - 22 Barley Ponds Road (Themistocli).html'
232
+ ( cd "$ACCT" && export LOG_DIR="$ACCT/logs" && printf '%s' "$(write_envelope "$SPACED" "$NESTED")" | bash "$HOOK" >/dev/null 2>&1 )
233
+ if grep -qF '[html-structure-gate] op=refuse account=2078cb54 file="documents/Quotations/1867/Q1867 - 22 Barley Ponds Road (Themistocli).html" line=4' "$ACCT/logs/server.log" 2>/dev/null; then
234
+ echo "PASS: N: the refusal is logged with a JSON-quoted path and the account id8"
235
+ PASS=$((PASS + 1))
236
+ else
237
+ echo "FAIL: N: no op=refuse line (got: $(cat "$ACCT/logs/server.log" 2>/dev/null))" >&2
238
+ FAIL=$((FAIL + 1))
239
+ fi
240
+
241
+ # O. Brand isolation: when no brand-correct sink resolves, the refusal still
242
+ # blocks but writes nothing. Four brands are co-resident on the laptop and each
243
+ # has its own ~/.<brand>/logs, so a hard-coded fallback would file one brand's
244
+ # account path in another brand's server.log.
245
+ BARE="$FAKE/accounts/bare0000acct"
246
+ mkdir -p "$BARE"
247
+ printf '%s\n' "$NESTED" > "$BARE/doc.html"
248
+ FAKEHOME="$FAKE/fakehome"
249
+ mkdir -p "$FAKEHOME/.maxy-code/logs"
250
+ O_ENVELOPE=$(write_envelope 'doc.html' "$NESTED")
251
+ ( cd "$BARE" && printf '%s' "$O_ENVELOPE" | env -u LOG_DIR -u CLAUDE_SESSION_MANAGER_PERSIST_DIR \
252
+ HOME="$FAKEHOME" bash "$HOOK" >/dev/null 2>&1 )
253
+ O_EXIT=$?
254
+ if [[ "$O_EXIT" -eq 2 ]] && [[ ! -s "$FAKEHOME/.maxy-code/logs/server.log" ]]; then
255
+ echo "PASS: O: with no brand-correct sink the write is still refused and no other brand's log is written"
256
+ PASS=$((PASS + 1))
257
+ else
258
+ echo "FAIL: O: exit=$O_EXIT, foreign log=$(cat "$FAKEHOME/.maxy-code/logs/server.log" 2>/dev/null)" >&2
259
+ FAIL=$((FAIL + 1))
260
+ fi
261
+
262
+ echo "──────── html-structure-gate test summary ────────"
263
+ echo "PASS=$PASS FAIL=$FAIL"
264
+ [[ "$FAIL" -eq 0 ]]