@rubytech/create-maxy-code 0.1.182 → 0.1.183

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 (170) hide show
  1. package/dist/__tests__/bundler-rewrite-platform-lib.test.js +68 -0
  2. package/dist/bundler-rewrite-platform-lib.js +29 -0
  3. package/package.json +1 -1
  4. package/payload/platform/lib/graph-search/dist/index.d.ts +10 -0
  5. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/graph-search/dist/index.js +57 -30
  7. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  8. package/payload/platform/lib/graph-search/src/__tests__/hybrid.test.ts +95 -2
  9. package/payload/platform/lib/graph-search/src/__tests__/vector-index-coverage.test.ts +198 -0
  10. package/payload/platform/lib/graph-search/src/index.ts +73 -29
  11. package/payload/platform/neo4j/schema.cypher +36 -0
  12. package/payload/platform/plugins/admin/PLUGIN.md +2 -0
  13. package/payload/platform/plugins/admin/hooks/admin-authoring-observer.sh +155 -0
  14. package/payload/platform/plugins/admin/skills/access-manager/SKILL.md +7 -5
  15. package/payload/platform/plugins/admin/skills/access-manager/references/operations.md +27 -55
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +178 -0
  17. package/payload/platform/plugins/admin/skills/specialist-management/SKILL.md +3 -2
  18. package/payload/platform/plugins/aeo/mcp/dist/index.js +3 -0
  19. package/payload/platform/plugins/aeo/mcp/dist/index.js.map +1 -1
  20. package/payload/platform/plugins/contacts/PLUGIN.md +1 -0
  21. package/payload/platform/plugins/contacts/mcp/dist/index.js +3 -0
  22. package/payload/platform/plugins/contacts/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/docs/references/access-control.md +29 -22
  24. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.d.ts +2 -0
  25. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.d.ts.map +1 -0
  26. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.js +56 -0
  27. package/payload/platform/plugins/email/mcp/dist/scripts/send-transactional.js.map +1 -0
  28. package/payload/platform/plugins/memory/.claude-plugin/plugin.json +1 -1
  29. package/payload/platform/plugins/memory/PLUGIN.md +5 -1
  30. package/payload/platform/plugins/memory/mcp/dist/index.js +76 -2
  31. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  32. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js +30 -1
  33. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js.map +1 -1
  34. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts +15 -0
  35. package/payload/platform/plugins/memory/mcp/dist/lib/section-types.d.ts.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts +10 -0
  37. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts.map +1 -1
  38. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js +35 -4
  39. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js.map +1 -1
  40. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js +61 -0
  41. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest.test.js.map +1 -1
  42. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.d.ts +2 -0
  43. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.d.ts.map +1 -0
  44. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js +125 -0
  45. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js.map +1 -0
  46. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  47. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +65 -5
  48. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  49. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update-by-name.d.ts +15 -0
  50. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update-by-name.d.ts.map +1 -0
  51. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update-by-name.js +45 -0
  52. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update-by-name.js.map +1 -0
  53. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +24 -1
  54. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  55. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  56. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +16 -0
  57. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  58. package/payload/platform/plugins/memory/references/graph-primitives.md +1 -0
  59. package/payload/platform/plugins/memory/references/schema-base.md +4 -2
  60. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +24 -0
  61. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -0
  62. package/payload/platform/plugins/scheduling/mcp/dist/index.js +3 -0
  63. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  64. package/payload/platform/plugins/work/PLUGIN.md +1 -0
  65. package/payload/platform/plugins/work/mcp/dist/index.js +4 -0
  66. package/payload/platform/plugins/work/mcp/dist/index.js.map +1 -1
  67. package/payload/platform/plugins/workflows/PLUGIN.md +1 -0
  68. package/payload/platform/plugins/workflows/mcp/dist/index.js +3 -0
  69. package/payload/platform/plugins/workflows/mcp/dist/index.js.map +1 -1
  70. package/payload/platform/scripts/__tests__/agents-md-bootstrap.test.sh +111 -0
  71. package/payload/platform/scripts/lib/agents-md-bootstrap.sh +49 -0
  72. package/payload/platform/scripts/setup-account.sh +18 -14
  73. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +8 -0
  74. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  75. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/http-server.js +12 -0
  78. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  79. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +32 -4
  80. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  81. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +154 -53
  82. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  83. package/payload/platform/templates/agents/admin/AGENTS.md +18 -7
  84. package/payload/platform/templates/agents/admin/IDENTITY.md +2 -2
  85. package/payload/platform/templates/specialists/agents/archive-ingest-operator.md +1 -1
  86. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -1
  87. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +1 -1
  88. package/payload/platform/templates/specialists/agents/content-producer.md +3 -1
  89. package/payload/platform/templates/specialists/agents/database-operator.md +2 -2
  90. package/payload/platform/templates/specialists/agents/librarian.md +1 -1
  91. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  92. package/payload/platform/templates/specialists/agents/project-manager.md +1 -1
  93. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +40 -0
  94. package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
  95. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -1
  96. package/payload/server/public/assets/ChatInput-DzacFNMk.css +1 -0
  97. package/payload/server/public/assets/{Checkbox-YrQovXpN.js → Checkbox-BJKNkUYu.js} +1 -1
  98. package/payload/server/public/assets/{admin-DH5jp-1u.js → admin-BNy4N2d6.js} +1 -1
  99. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Do2zYONG.js → architectureDiagram-Q4EWVU46-DZoOzmjE.js} +1 -1
  100. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DpIX_bRu.js → blockDiagram-DXYQGD6D-DG3yufCR.js} +1 -1
  101. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Cz335ZSY.js → c4Diagram-AHTNJAMY-BBx5wXuZ.js} +1 -1
  102. package/payload/server/public/assets/channel-DdtUtt5g.js +1 -0
  103. package/payload/server/public/assets/{chunk-336JU56O-Dmknn3Wo.js → chunk-336JU56O-DwP7g5ns.js} +2 -2
  104. package/payload/server/public/assets/{chunk-426QAEUC-ClfGjizN.js → chunk-426QAEUC-DXzGdc22.js} +1 -1
  105. package/payload/server/public/assets/{chunk-4TB4RGXK-BPKctJ5c.js → chunk-4TB4RGXK-1LDRjk0E.js} +1 -1
  106. package/payload/server/public/assets/{chunk-5FUZZQ4R-BWDCO9zw.js → chunk-5FUZZQ4R-BHjWhwhn.js} +1 -1
  107. package/payload/server/public/assets/{chunk-5PVQY5BW-BE34_Db0.js → chunk-5PVQY5BW-CwPHRVN8.js} +1 -1
  108. package/payload/server/public/assets/{chunk-EDXVE4YY-BysA-_6v.js → chunk-EDXVE4YY-7443hrYZ.js} +1 -1
  109. package/payload/server/public/assets/{chunk-ENJZ2VHE-BMVyMsqA.js → chunk-ENJZ2VHE-BTnmmnmX.js} +1 -1
  110. package/payload/server/public/assets/{chunk-ICPOFSXX-C5KRdLV4.js → chunk-ICPOFSXX-CPnMJplL.js} +1 -1
  111. package/payload/server/public/assets/{chunk-OYMX7WX6-DVZD-AVc.js → chunk-OYMX7WX6-DCQ5Q4RS.js} +1 -1
  112. package/payload/server/public/assets/{chunk-U2HBQHQK-DYmQdx8x.js → chunk-U2HBQHQK-BmfZG8e4.js} +1 -1
  113. package/payload/server/public/assets/{chunk-X2U36JSP-DrZenIl3.js → chunk-X2U36JSP-Da2yJL2u.js} +1 -1
  114. package/payload/server/public/assets/{chunk-YZCP3GAM-CDuGbvMq.js → chunk-YZCP3GAM-BdUmMUJ6.js} +1 -1
  115. package/payload/server/public/assets/{chunk-ZZ45TVLE-COztbiu5.js → chunk-ZZ45TVLE-abkGirrZ.js} +1 -1
  116. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CKk2DFZe.js +1 -0
  117. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E--YvfDQCh.js +1 -0
  118. package/payload/server/public/assets/clone-Cw6UfPGM.js +1 -0
  119. package/payload/server/public/assets/{dagre-Cv9Ib2OM.js → dagre-BVVuAEsU.js} +1 -1
  120. package/payload/server/public/assets/{dagre-KV5264BT-CoEd61KZ.js → dagre-KV5264BT-VG2kNj19.js} +1 -1
  121. package/payload/server/public/assets/data-uBpwaldK.js +1 -0
  122. package/payload/server/public/assets/{diagram-5BDNPKRD-WXNzw3uW.js → diagram-5BDNPKRD-DRiVy69K.js} +1 -1
  123. package/payload/server/public/assets/{diagram-G4DWMVQ6-C6IuQ2VV.js → diagram-G4DWMVQ6-DtpiRTYX.js} +1 -1
  124. package/payload/server/public/assets/{diagram-MMDJMWI5-CdJdir5A.js → diagram-MMDJMWI5-Da3t99fY.js} +1 -1
  125. package/payload/server/public/assets/{diagram-TYMM5635-DxWKQH5T.js → diagram-TYMM5635-BzQmLTJQ.js} +1 -1
  126. package/payload/server/public/assets/{erDiagram-SMLLAGMA-C9fvs33K.js → erDiagram-SMLLAGMA-D3Ll-GDw.js} +1 -1
  127. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-BO7Yrbqv.js → flowDiagram-DWJPFMVM-D17bje6d.js} +1 -1
  128. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-CQJyWBZq.js → ganttDiagram-T4ZO3ILL-BjeQ-Bun.js} +1 -1
  129. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DZfQHrYw.js → gitGraphDiagram-UUTBAWPF-LtX4pvZS.js} +1 -1
  130. package/payload/server/public/assets/{graph-labels-BMU8LsOD.js → graph-labels-Cn9jAE6I.js} +1 -1
  131. package/payload/server/public/assets/graph-tvTPvBaz.js +1 -0
  132. package/payload/server/public/assets/{graphlib-C40H-Q5E.js → graphlib-CzwsfZOw.js} +1 -1
  133. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CakfV5f9.js → infoDiagram-42DDH7IO-DOgH4i52.js} +1 -1
  134. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CPuBPyfa.js → ishikawaDiagram-UXIWVN3A-DYqy5BFj.js} +1 -1
  135. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-zpZm2Mmo.js → journeyDiagram-VCZTEJTY-CsFpIULJ.js} +1 -1
  136. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DfJduQCo.js → kanban-definition-6JOO6SKY-5uZuArw7.js} +1 -1
  137. package/payload/server/public/assets/{lib-Bnh57com.js → lib-p4ylk2XS.js} +1 -1
  138. package/payload/server/public/assets/{line-BpYQqO5R.js → line-BY8PmCSW.js} +1 -1
  139. package/payload/server/public/assets/{mermaid-parser.core-C5Mwi14D.js → mermaid-parser.core-CJ_9xqwK.js} +1 -1
  140. package/payload/server/public/assets/{mermaid.core-D8qBtvvT.js → mermaid.core-CXznBkX7.js} +3 -3
  141. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-Bli9JhIN.js → mindmap-definition-QFDTVHPH-DZbHBud5.js} +1 -1
  142. package/payload/server/public/assets/{page-0lzgQWnS.js → page-B3VYaBcf.js} +1 -1
  143. package/payload/server/public/assets/{page-C29S3cBt.js → page-CfN7nw1J.js} +1 -1
  144. package/payload/server/public/assets/{pieDiagram-DEJITSTG-MsLXTSLg.js → pieDiagram-DEJITSTG-QmOi4DRG.js} +1 -1
  145. package/payload/server/public/assets/public-Bbew78md.js +7 -0
  146. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-De1sh2K0.js → quadrantDiagram-34T5L4WZ-Cpidq6Xj.js} +1 -1
  147. package/payload/server/public/assets/{requirementDiagram-MS252O5E-D5Sfg1t_.js → requirementDiagram-MS252O5E-CBqtfbzR.js} +1 -1
  148. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-DHA3LIbv.js → sankeyDiagram-XADWPNL6-4BiY4dV9.js} +1 -1
  149. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BE_QlG1B.js → sequenceDiagram-FGHM5R23-DVlL-l2u.js} +1 -1
  150. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-NIwp3F4u.js → stateDiagram-FHFEXIEX-CAm9dW68.js} +1 -1
  151. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-B2EzXi50.js +1 -0
  152. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-CPE9jyDF.js → timeline-definition-GMOUNBTQ-CD-tZc4y.js} +1 -1
  153. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-CKfoyYmH.js → vennDiagram-DHZGUBPP-Davcm0TF.js} +1 -1
  154. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-B9yJek-o.js → wardleyDiagram-NUSXRM2D-40LBYw_Y.js} +1 -1
  155. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DuCcSLqX.js → xychartDiagram-5P7HB3ND-DEPT-__v.js} +1 -1
  156. package/payload/server/public/data.html +5 -5
  157. package/payload/server/public/graph.html +6 -6
  158. package/payload/server/public/index.html +8 -8
  159. package/payload/server/public/public.html +5 -5
  160. package/payload/server/server.js +1158 -337
  161. package/payload/server/public/assets/ChatInput-CJ50oqWt.css +0 -1
  162. package/payload/server/public/assets/channel-PC4_IjI1.js +0 -1
  163. package/payload/server/public/assets/classDiagram-6PBFFD2Q-B_pMa-SX.js +0 -1
  164. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BdBAgF20.js +0 -1
  165. package/payload/server/public/assets/clone-CkNt9L9r.js +0 -1
  166. package/payload/server/public/assets/data-CtanHhM2.js +0 -1
  167. package/payload/server/public/assets/graph-6DPD6be1.js +0 -1
  168. package/payload/server/public/assets/public-DltgEfA9.js +0 -7
  169. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-pfk2F6st.js +0 -1
  170. /package/payload/server/public/assets/{ChatInput-CJo_77bp.js → ChatInput-CvRaT05l.js} +0 -0
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Doctrine test — pins the per-identity-label vector-index coverage
8
+ * invariant so future identity labels added to schema-base.md cannot
9
+ * silently drift into BM25-only semantic ranking (Task 483).
10
+ *
11
+ * Pre-Task-483 the :Organization label carried `embedding` from the
12
+ * ingest writer but no vector index, so hybrid() fell through to
13
+ * BM25-only for Organization-only queries (Task 478 made the
14
+ * fallback honest). Without a coverage gate, the next analogous gap
15
+ * — a new identity label written with embeddings but no index —
16
+ * would silently degrade the same way.
17
+ *
18
+ * Doctrine: every identity label declared in schema-base.md's
19
+ * `## Node Types` table is either (a) covered by a
20
+ * `CREATE VECTOR INDEX … FOR (n:<Label>) ON (n.embedding)` declaration
21
+ * in schema.cypher, or (b) explicitly listed in
22
+ * `BM25_ONLY_IDENTITY_LABELS` below with a one-line rationale. A new
23
+ * identity label that satisfies neither condition fails this test
24
+ * and points the author at the decision they have to make.
25
+ */
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const SCHEMA_CYPHER_PATH = resolve(__dirname, "../../../../neo4j/schema.cypher");
29
+ const SCHEMA_BASE_PATH = resolve(
30
+ __dirname,
31
+ "../../../../plugins/memory/references/schema-base.md",
32
+ );
33
+
34
+ /**
35
+ * Identity labels documented in schema-base.md that legitimately rank
36
+ * via BM25 only — no vector index is declared, and none is intended.
37
+ *
38
+ * The rationale per label is the doctrinal record: it answers the
39
+ * question a future reader will ask ("why doesn't <Label> get
40
+ * semantic ranking?") without forcing them to re-derive it from
41
+ * scratch. Removing a label from this set without adding the
42
+ * corresponding `CREATE VECTOR INDEX` (or vice versa) fails the
43
+ * test below.
44
+ */
45
+ const BM25_ONLY_IDENTITY_LABELS: ReadonlyMap<string, string> = new Map([
46
+ // Structured business records — looked up by deterministic key
47
+ // (orderNumber / confirmationNumber / address), not by semantic
48
+ // similarity. No `body`-like free-text writer.
49
+ ["Order", "structured business record; deterministic key lookup"],
50
+ ["Invoice", "structured business record; deterministic key lookup"],
51
+ ["PostalAddress", "structured fields only; no free-text writer"],
52
+ ["OpeningHoursSpecification", "structured fields only"],
53
+ // Identity nodes accessed via composite key, never semantic search.
54
+ ["UserProfile", "always queried by (accountId, userId)"],
55
+ ["AccessGrant", "always queried by (contactValue, agentSlug, accountId) or magicToken"],
56
+ // CDM-only labels — declared in schema-base for vertical extension
57
+ // hooks; no writer in the current platform.
58
+ ["Lead", "CDM-only declaration; no platform writer"],
59
+ ["Case", "CDM-only declaration; no platform writer"],
60
+ // Catalogue / branding nodes — small fixed populations, accessed by
61
+ // accountId + purpose / name, not by semantic similarity.
62
+ ["FAQPage", "container node; child :Question nodes carry semantic content"],
63
+ ["ImageObject", "binary asset; alt-text not currently indexed"],
64
+ // Legacy / retired writers (kept for read compatibility).
65
+ ["CloudflareTunnel", "legacy projection (Task 288); writer retired"],
66
+ ["CloudflareHostname", "legacy projection (Task 288); writer retired"],
67
+ ["WhatsAppConversation", "legacy; superseded by :ConversationArchive (Task 397)"],
68
+ ["WhatsAppMessage", "legacy; superseded by :ConversationArchive (Task 397)"],
69
+ // Authored-content history — looked up by accountId + datePublished
70
+ // / runAt; semantic search lives on the parent or downstream report.
71
+ ["SocialPost", "authored-content record; lookup by accountId + datePublished"],
72
+ ["AEOAudit", "audit-history record; lookup by accountId + runAt"],
73
+ // Voice-mirror records — keyed by (accountId, adminUserId).
74
+ ["VoiceProfile", "always queried by (accountId, adminUserId)"],
75
+ ["VoiceEdit", "always queried by parent VoiceProfile"],
76
+ ]);
77
+
78
+ interface VectorIndexDeclaration {
79
+ name: string;
80
+ label: string;
81
+ }
82
+
83
+ /**
84
+ * Extract every `CREATE VECTOR INDEX <name> IF NOT EXISTS FOR (<alias>:<Label>) ON (<alias>.embedding)`
85
+ * declaration from schema.cypher. The alias and label vary across
86
+ * declarations (`p:Person`, `b:LocalBusiness`, `o:Organization` …),
87
+ * so the regex captures both and we keep only the label.
88
+ *
89
+ * Line comments are stripped first so a commented-out historical
90
+ * declaration cannot inject a phantom label.
91
+ */
92
+ function parseVectorIndexes(cypher: string): VectorIndexDeclaration[] {
93
+ const stripped = cypher.replace(/^\s*\/\/.*$/gm, "");
94
+ const decls: VectorIndexDeclaration[] = [];
95
+ const re = /CREATE\s+VECTOR\s+INDEX\s+(\w+)\s+IF\s+NOT\s+EXISTS\s+FOR\s+\(\w+:(\w+)\)/g;
96
+ for (const m of stripped.matchAll(re)) {
97
+ decls.push({ name: m[1], label: m[2] });
98
+ }
99
+ return decls;
100
+ }
101
+
102
+ /**
103
+ * Extract every documented identity label from schema-base.md's
104
+ * `## Node Types` table — column 2 of each table row, stripped of
105
+ * backticks and any trailing `{property:'value'}` qualifier.
106
+ *
107
+ * Parser scope: the first markdown table that appears under
108
+ * `## Node Types`. Stops at the first non-table line (blank line or
109
+ * a paragraph), so explanatory prose under the table cannot leak
110
+ * pseudo-labels.
111
+ */
112
+ function parseIdentityLabels(md: string): Set<string> {
113
+ const heading = md.indexOf("## Node Types");
114
+ if (heading < 0) {
115
+ throw new Error(
116
+ "parseIdentityLabels: could not find '## Node Types' heading in schema-base.md. " +
117
+ "Schema-base layout drifted; update the doctrine test.",
118
+ );
119
+ }
120
+ const after = md.slice(heading);
121
+ const lines = after.split("\n");
122
+ const labels = new Set<string>();
123
+ let inTable = false;
124
+ for (const line of lines) {
125
+ if (line.startsWith("|")) {
126
+ // Header row + separator row both contain `|`; skip them.
127
+ if (line.includes("Entity") && line.includes("Neo4j Label")) {
128
+ inTable = true;
129
+ continue;
130
+ }
131
+ if (/^\|\s*-+/.test(line)) continue;
132
+ if (!inTable) continue;
133
+ const cols = line.split("|").map((c) => c.trim());
134
+ // `| Entity | Neo4j Label | ... |` → cols = ["", "Entity", "Neo4j Label", ..., ""]
135
+ const labelCell = cols[2];
136
+ if (!labelCell) continue;
137
+ // Strip backticks and trailing `{...}` property qualifier.
138
+ const cleaned = labelCell.replace(/`/g, "").replace(/\s*\{.*\}\s*$/, "").trim();
139
+ // Defensive: only accept PascalCase identifiers. Anything else
140
+ // (multi-label decoration, prose, etc.) is skipped — the test's
141
+ // job is to gate identity-label coverage, not to police table
142
+ // formatting.
143
+ if (/^[A-Z][A-Za-z0-9]*$/.test(cleaned)) labels.add(cleaned);
144
+ } else if (inTable && line.trim().length === 0) {
145
+ // Blank line ends the table.
146
+ break;
147
+ }
148
+ }
149
+ return labels;
150
+ }
151
+
152
+ describe("vector-index coverage — every identity label is vector-indexed or explicitly BM25-only (Task 483)", () => {
153
+ const cypher = readFileSync(SCHEMA_CYPHER_PATH, "utf-8");
154
+ const schemaBase = readFileSync(SCHEMA_BASE_PATH, "utf-8");
155
+
156
+ const declarations = parseVectorIndexes(cypher);
157
+ const vectorIndexed = new Set(declarations.map((d) => d.label));
158
+ const identityLabels = parseIdentityLabels(schemaBase);
159
+
160
+ it("parsers extracted non-trivial sets — sanity floor", () => {
161
+ expect(declarations.length).toBeGreaterThanOrEqual(15);
162
+ expect(identityLabels.size).toBeGreaterThanOrEqual(20);
163
+ });
164
+
165
+ it("every identity label in schema-base.md is either vector-indexed or in BM25_ONLY_IDENTITY_LABELS", () => {
166
+ const uncovered: string[] = [];
167
+ for (const label of identityLabels) {
168
+ if (vectorIndexed.has(label)) continue;
169
+ if (BM25_ONLY_IDENTITY_LABELS.has(label)) continue;
170
+ uncovered.push(label);
171
+ }
172
+ // Failure here means a new identity label was added to schema-base.md
173
+ // without either declaring a vector index in schema.cypher or adding
174
+ // it to BM25_ONLY_IDENTITY_LABELS with a one-line rationale. Pick
175
+ // one — the silent gap is the failure mode.
176
+ expect(uncovered).toEqual([]);
177
+ });
178
+
179
+ it("BM25_ONLY_IDENTITY_LABELS does not overlap with vector-indexed labels", () => {
180
+ const overlap: string[] = [];
181
+ for (const label of BM25_ONLY_IDENTITY_LABELS.keys()) {
182
+ if (vectorIndexed.has(label)) overlap.push(label);
183
+ }
184
+ // A label cannot legitimately appear in both sets: the allowlist
185
+ // documents the *absence* of a vector index. If the index was
186
+ // added later, remove the label from the allowlist.
187
+ expect(overlap).toEqual([]);
188
+ });
189
+
190
+ it(":Organization carries a vector index (Task 483 acceptance)", () => {
191
+ // Pin the load-bearing assertion of this task explicitly so a
192
+ // regression that re-removed the declaration would fail with a
193
+ // pointed message rather than as a generic coverage gap.
194
+ expect(vectorIndexed.has("Organization")).toBe(true);
195
+ const orgDecl = declarations.find((d) => d.label === "Organization");
196
+ expect(orgDecl?.name).toBe("organization_embedding");
197
+ });
198
+ });
@@ -203,6 +203,48 @@ export interface Bm25OnlyParams {
203
203
  * guard); admin route enforces non-empty `labels` as a precondition.
204
204
  */
205
205
  labels?: string[];
206
+ /**
207
+ * Task 485 — per-visitor slice ringfence. When set, the scope filter
208
+ * widens from `(scope IS NULL OR scope IN $allowedScopes)` to
209
+ * `(sliceToken = $sliceToken OR scope IS NULL OR scope IN $allowedScopes)`
210
+ * so a gated public-agent read sees its visitor's user-scoped nodes in
211
+ * addition to public-scope rows. Skipped on every admin and open-mode
212
+ * public call; the env var `ACCESS_SLICE_TOKEN` populates it on the
213
+ * memory MCP server side.
214
+ */
215
+ sliceToken?: string;
216
+ }
217
+
218
+ /**
219
+ * Build the scope-and-slice WHERE fragment + params. Centralises the three
220
+ * cypher composition sites (bm25, vector, prop) so the gated/non-gated
221
+ * behaviour does not drift between them. Returns empty strings when no
222
+ * filter applies.
223
+ */
224
+ function buildScopeAndSliceClause(
225
+ allowedScopes: readonly string[] | undefined,
226
+ sliceToken: string | undefined,
227
+ alias: string = "node",
228
+ ): { clause: string; params: Record<string, unknown> } {
229
+ const hasSlice = typeof sliceToken === "string" && sliceToken.length > 0;
230
+ const hasScopes = Array.isArray(allowedScopes) && allowedScopes.length > 0;
231
+ if (!hasSlice && !hasScopes) return { clause: "", params: {} };
232
+ if (hasSlice && hasScopes) {
233
+ return {
234
+ clause: `AND (${alias}.sliceToken = $sliceToken OR ${alias}.scope IS NULL OR ${alias}.scope IN $allowedScopes)`,
235
+ params: { sliceToken, allowedScopes },
236
+ };
237
+ }
238
+ if (hasSlice) {
239
+ return {
240
+ clause: `AND (${alias}.sliceToken = $sliceToken OR ${alias}.scope IS NULL)`,
241
+ params: { sliceToken },
242
+ };
243
+ }
244
+ return {
245
+ clause: `AND (${alias}.scope IS NULL OR ${alias}.scope IN $allowedScopes)`,
246
+ params: { allowedScopes },
247
+ };
206
248
  }
207
249
 
208
250
  export interface HybridParams extends Bm25OnlyParams {
@@ -388,10 +430,9 @@ export async function bm25Only(
388
430
  session: Session,
389
431
  params: Bm25OnlyParams,
390
432
  ): Promise<SearchHit[]> {
391
- const { query, accountId, limit, allowedScopes, agentSlug, keywords, keywordMatch, labels } = params;
392
- const scopeClause = allowedScopes
393
- ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
394
- : "";
433
+ const { query, accountId, limit, allowedScopes, agentSlug, keywords, keywordMatch, labels, sliceToken } = params;
434
+ const scope = buildScopeAndSliceClause(allowedScopes, sliceToken);
435
+ const scopeClause = scope.clause;
395
436
  const agentClause = agentSlug
396
437
  ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
397
438
  : "";
@@ -420,7 +461,7 @@ export async function bm25Only(
420
461
  query: escaped,
421
462
  accountId,
422
463
  limit: int(limit),
423
- ...(allowedScopes ? { allowedScopes } : {}),
464
+ ...scope.params,
424
465
  ...(agentSlug ? { agentSlug } : {}),
425
466
  ...(labels && labels.length > 0 ? { labels } : {}),
426
467
  ...(keywordFilter?.params ?? {}),
@@ -486,6 +527,7 @@ export async function hybrid(
486
527
  accountId,
487
528
  limit,
488
529
  allowedScopes,
530
+ sliceToken,
489
531
  keywords,
490
532
  keywordMatch = "any",
491
533
  agentSlug,
@@ -579,31 +621,35 @@ export async function hybrid(
579
621
  const labelToIndex = await discoverIndexes(session);
580
622
 
581
623
  // --- Resolve vector indexes from the effective label set ---
624
+ //
625
+ // Task 478: when `effectiveLabels` is set but none resolve to a vector index
626
+ // (e.g. `labels:["Organization"]` — universal BM25 covers it, no vector
627
+ // index declared), proceed with `indexesToQuery=[]`. The vector loop below
628
+ // runs zero iterations and the BM25 half still runs with the label filter,
629
+ // so search returns rows whose existence the rest of the platform asserts.
630
+ // Pre-fix this branch returned an empty result, hiding ingest-created
631
+ // Organization rows that BM25 would have found — the deadlock recorded in
632
+ // .tasks/archive/478-*.md. The labels-without-vector list is emitted as an
633
+ // observability line so a future analogous gap stays visible.
582
634
  let indexesToQuery: string[];
583
635
  if (effectiveLabels && effectiveLabels.length > 0) {
584
636
  indexesToQuery = effectiveLabels
585
637
  .map((l) => labelToIndex.get(l))
586
638
  .filter((idx): idx is string => idx !== undefined);
587
639
  if (indexesToQuery.length === 0) {
588
- return {
589
- mode: useRrf ? "rrf" : "hybrid",
590
- results: [],
591
- expandMs: 0,
592
- rawMerged: 0,
593
- suppressed: 0,
594
- bm25Bypass: 0,
595
- threshold: params.vectorThreshold ?? null,
596
- stageCounts: emptyStageCounts,
597
- };
640
+ const missing = effectiveLabels.filter((l) => !labelToIndex.has(l));
641
+ process.stderr.write(
642
+ `[graph-search] labels-without-vector-index=${missing.join(",")} ` +
643
+ `accountId=${(accountId ?? "").slice(0, 8)} — falling through to BM25-only for these labels\n`,
644
+ );
598
645
  }
599
646
  } else {
600
647
  indexesToQuery = [...new Set(labelToIndex.values())];
601
648
  }
602
649
 
603
- const scopeClause = allowedScopes
604
- ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
605
- : "";
606
- const scopeParams = allowedScopes ? { allowedScopes } : {};
650
+ const hybridScope = buildScopeAndSliceClause(allowedScopes, sliceToken);
651
+ const scopeClause = hybridScope.clause;
652
+ const scopeParams = hybridScope.params;
607
653
  const agentClause = agentSlug
608
654
  ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
609
655
  : "";
@@ -752,6 +798,7 @@ export async function hybrid(
752
798
  accountId,
753
799
  limit,
754
800
  allowedScopes,
801
+ sliceToken,
755
802
  });
756
803
  if (kwHits.length === 0) continue;
757
804
  const rawScores = kwHits.map((h) => h.score);
@@ -761,23 +808,21 @@ export async function hybrid(
761
808
  }
762
809
  }
763
810
 
764
- const propScopeClause = allowedScopes
765
- ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
766
- : "";
811
+ const propScope = buildScopeAndSliceClause(allowedScopes, sliceToken);
767
812
  const propResult = await session.run(
768
813
  `MATCH (node)
769
814
  WHERE node.accountId = $accountId
770
815
  AND ${notTrashed("node")}
771
816
  AND node.keywords IS NOT NULL
772
817
  AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
773
- ${propScopeClause}
818
+ ${propScope.clause}
774
819
  RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
775
820
  LIMIT $limit`,
776
821
  {
777
822
  accountId,
778
823
  kwSubs: keywordSubscriptions,
779
824
  limit: int(limit),
780
- ...(allowedScopes ? { allowedScopes } : {}),
825
+ ...propScope.params,
781
826
  },
782
827
  );
783
828
  for (const record of propResult.records) {
@@ -808,6 +853,7 @@ export async function hybrid(
808
853
  accountId,
809
854
  limit,
810
855
  allowedScopes,
856
+ sliceToken,
811
857
  });
812
858
  if (kwHits.length === 0) continue;
813
859
  const rawScores = kwHits.map((h) => h.score);
@@ -817,23 +863,21 @@ export async function hybrid(
817
863
  }
818
864
  }
819
865
 
820
- const propScopeClause = allowedScopes
821
- ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
822
- : "";
866
+ const propScope = buildScopeAndSliceClause(allowedScopes, sliceToken);
823
867
  const propResult = await session.run(
824
868
  `MATCH (node)
825
869
  WHERE node.accountId = $accountId
826
870
  AND ${notTrashed("node")}
827
871
  AND node.keywords IS NOT NULL
828
872
  AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
829
- ${propScopeClause}
873
+ ${propScope.clause}
830
874
  RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
831
875
  LIMIT $limit`,
832
876
  {
833
877
  accountId,
834
878
  kwSubs: keywordSubscriptions,
835
879
  limit: int(limit),
836
- ...(allowedScopes ? { allowedScopes } : {}),
880
+ ...propScope.params,
837
881
  },
838
882
  );
839
883
  for (const record of propResult.records) {
@@ -135,6 +135,24 @@ OPTIONS {
135
135
  }
136
136
  };
137
137
 
138
+ // :Organization carries `embedding` from the ingest writer
139
+ // (memory-ingest.ts writeRelatedNode). Pre-Task-483 no vector index
140
+ // existed, so hybrid() fell through to BM25-only for Organization-only
141
+ // queries (Task 478 made that fallback honest). The index restores
142
+ // semantic-similarity ranking parity with the other identity labels —
143
+ // a query like "the company that does antimicrobial coatings" surfaces
144
+ // BioSymm via cosine similarity rather than relying on literal BM25
145
+ // token matching. Neo4j populates the index against existing rows on
146
+ // CREATE; no manual reindex required.
147
+ CREATE VECTOR INDEX organization_embedding IF NOT EXISTS
148
+ FOR (o:Organization) ON (o.embedding)
149
+ OPTIONS {
150
+ indexConfig: {
151
+ `vector.dimensions`: 768,
152
+ `vector.similarity_function`: 'cosine'
153
+ }
154
+ };
155
+
138
156
  CREATE VECTOR INDEX price_embedding IF NOT EXISTS
139
157
  FOR (p:PriceSpecification) ON (p.embedding)
140
158
  OPTIONS {
@@ -878,6 +896,24 @@ FOR (ag:AccessGrant) ON (ag.magicToken);
878
896
  CREATE INDEX access_grant_status IF NOT EXISTS
879
897
  FOR (ag:AccessGrant) ON (ag.status);
880
898
 
899
+ // Slice token (Task 485) — per-visitor ringfence key for gated public agents.
900
+ // Indexed on AccessGrant for revoke-eviction lookups and on every label that
901
+ // the session-end reviewer writes through (via database-operator), so
902
+ // memory-search can filter `n.sliceToken = $sliceToken OR n.scope = "public"`
903
+ // without a label scan. New labels added to the reviewer's surface gain a
904
+ // twin index here.
905
+ CREATE INDEX access_grant_slice_token IF NOT EXISTS
906
+ FOR (ag:AccessGrant) ON (ag.sliceToken);
907
+
908
+ CREATE INDEX person_slice_token IF NOT EXISTS
909
+ FOR (n:Person) ON (n.sliceToken);
910
+
911
+ CREATE INDEX preference_slice_token IF NOT EXISTS
912
+ FOR (n:Preference) ON (n.sliceToken);
913
+
914
+ CREATE INDEX idea_slice_token IF NOT EXISTS
915
+ FOR (n:Idea) ON (n.sliceToken);
916
+
881
917
  // ----------------------------------------------------------
882
918
  // Agent node — projection of file-based public agents
883
919
  //
@@ -126,6 +126,7 @@ Tools are available via the `admin` MCP server.
126
126
  | Manage plugins and settings | User asks to install, enable, disable, or configure plugins, or change account settings | `skills/plugin-management/SKILL.md` |
127
127
  | Manage specialists | User asks to install or remove a specialist subagent, or activate/deactivate premium plugin agents | `skills/specialist-management/SKILL.md` |
128
128
  | Generate print-quality PDF | User asks to create a PDF document, one-pager, brochure, or any HTML intended for print/download | `skills/a4-print-documents/SKILL.md` |
129
+ | Compose a business document | User asks to write or draft a proposal, report (market / analysis / strategic), brief, memo, white paper, plan (non-fundraise), or case study | `skills/professional-document/SKILL.md` |
129
130
  | Plain-English explanation | User asks to explain, define, or pre-empts ("explain in plain English", "what does X mean", "define X", "I don't understand", "what is this"), or admin is about to return a reply containing a term not in the operator's prior turn | `skills/plainly/SKILL.md` |
130
131
  | Manage admin users | User asks to add, remove, or list admins on this account, or change an admin PIN | `skills/admin-user-management/SKILL.md` |
131
132
  | Session reset / continue / resume | User asks to clear the session, start fresh, continue the last session, or pick up where they left off | `skills/session-management/SKILL.md` |
@@ -139,6 +140,7 @@ Tools are available via the `admin` MCP server.
139
140
  - `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.
140
141
  - `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.
141
142
  - `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
143
+ - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
142
144
  - `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `sessionId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
143
145
 
144
146
  ## Session identifiers (Task 135)
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env bash
2
+ # Admin-authoring observer (Task 486).
3
+ #
4
+ # Observation-only PostToolUse hook. When the admin agent (not a specialist
5
+ # subagent) writes or edits a file under <accountDir>/output/, emit one
6
+ # stderr line capturing whether a specialist was dispatched earlier in the
7
+ # current turn. A `priorSpecialistSpawnInTurn=false` value on a long-form
8
+ # prose file is the regression signal Task 486 was designed to make visible
9
+ # without relying on operator complaint — it surfaces the same failure mode
10
+ # the BioSymm proposal session exhibited (admin authored a customer-facing
11
+ # proposal inline despite content-producer being installed).
12
+ #
13
+ # This hook never blocks. Exit code is always 0.
14
+ #
15
+ # Output line shape (on stderr, no stdout):
16
+ # [admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>
17
+
18
+ set -uo pipefail
19
+
20
+ # Empty stdin (terminal invocation) — silent no-op for safety in tests.
21
+ if [ -t 0 ]; then
22
+ exit 0
23
+ fi
24
+ INPUT=$(cat)
25
+
26
+ # Only PostToolUse on Write or Edit is observed.
27
+ HOOK_EVENT=$(printf '%s' "$INPUT" | python3 -c '
28
+ import sys, json
29
+ try:
30
+ print(json.load(sys.stdin).get("hook_event_name", "") or "")
31
+ except Exception:
32
+ print("")
33
+ ' 2>/dev/null)
34
+ TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c '
35
+ import sys, json
36
+ try:
37
+ print(json.load(sys.stdin).get("tool_name", "") or "")
38
+ except Exception:
39
+ print("")
40
+ ' 2>/dev/null)
41
+ if [ "$HOOK_EVENT" != "PostToolUse" ]; then
42
+ exit 0
43
+ fi
44
+ if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
45
+ exit 0
46
+ fi
47
+
48
+ # Subagent writes are out of scope. pty-spawner stamps MAXY_SPECIALIST on
49
+ # every specialist process; only the admin process sees it empty.
50
+ if [ -n "${MAXY_SPECIALIST:-}" ]; then
51
+ exit 0
52
+ fi
53
+
54
+ FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
55
+ import sys, json
56
+ try:
57
+ d = json.load(sys.stdin)
58
+ print((d.get("tool_input", {}) or {}).get("file_path", "") or "")
59
+ except Exception:
60
+ print("")
61
+ ' 2>/dev/null)
62
+ [ -z "$FILE_PATH" ] && exit 0
63
+
64
+ # Resolve to absolute. The hook fires in the account dir (PWD), which is the
65
+ # claude project root (each account has its own .git).
66
+ case "$FILE_PATH" in
67
+ /*) ABS="$FILE_PATH" ;;
68
+ *) ABS="$PWD/$FILE_PATH" ;;
69
+ esac
70
+
71
+ # Only files under <accountDir>/output/ are in scope.
72
+ case "$ABS" in
73
+ "$PWD/output/"*) REL="${ABS#$PWD/}" ;;
74
+ *) exit 0 ;;
75
+ esac
76
+
77
+ TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | python3 -c '
78
+ import sys, json
79
+ try:
80
+ print(json.load(sys.stdin).get("transcript_path", "") or "")
81
+ except Exception:
82
+ print("")
83
+ ' 2>/dev/null)
84
+ # Test override so the .test.sh suite can pin a fixture transcript.
85
+ if [ -n "${ADMIN_AUTHORING_TRANSCRIPT_PATH:-}" ]; then
86
+ TRANSCRIPT_PATH="$ADMIN_AUTHORING_TRANSCRIPT_PATH"
87
+ fi
88
+
89
+ if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
90
+ echo "[admin-authoring] inline-write path=${REL} priorSpecialistSpawnInTurn=unknown" >&2
91
+ exit 0
92
+ fi
93
+
94
+ # Walk the transcript: from the latest real-user turn forward, find any
95
+ # assistant `tool_use` whose `name == "Task"` and whose
96
+ # `input.subagent_type` starts with `specialists:`.
97
+ PRIOR=$(TRANSCRIPT_PATH="$TRANSCRIPT_PATH" python3 - <<'PY'
98
+ import os, json, sys
99
+
100
+ path = os.environ["TRANSCRIPT_PATH"]
101
+ records = []
102
+ try:
103
+ with open(path, "r", encoding="utf-8") as f:
104
+ for line in f:
105
+ line = line.strip()
106
+ if line:
107
+ records.append(json.loads(line))
108
+ except Exception:
109
+ print("unknown"); sys.exit(0)
110
+
111
+ def is_real_user(d):
112
+ if d.get("type") != "user":
113
+ return False
114
+ msg = d.get("message", {}) or {}
115
+ if msg.get("role") != "user":
116
+ return False
117
+ content = msg.get("content")
118
+ if isinstance(content, str):
119
+ return True
120
+ if isinstance(content, list):
121
+ for b in content:
122
+ if isinstance(b, dict) and b.get("type") != "tool_result":
123
+ return True
124
+ return False
125
+ return True
126
+
127
+ last_user = -1
128
+ for i in range(len(records) - 1, -1, -1):
129
+ if is_real_user(records[i]):
130
+ last_user = i
131
+ break
132
+
133
+ for d in records[last_user + 1:]:
134
+ if d.get("type") != "assistant":
135
+ continue
136
+ msg = d.get("message", {}) or {}
137
+ if msg.get("role") != "assistant":
138
+ continue
139
+ for b in (msg.get("content") or []):
140
+ if not isinstance(b, dict):
141
+ continue
142
+ if b.get("type") != "tool_use":
143
+ continue
144
+ if b.get("name") != "Task":
145
+ continue
146
+ sub = (b.get("input") or {}).get("subagent_type")
147
+ if isinstance(sub, str) and sub.startswith("specialists:"):
148
+ print("true"); sys.exit(0)
149
+
150
+ print("false")
151
+ PY
152
+ )
153
+
154
+ echo "[admin-authoring] inline-write path=${REL} priorSpecialistSpawnInTurn=${PRIOR}" >&2
155
+ exit 0
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: access-manager
3
- description: "Manage per-agent access grants — invite visitors by email or phone, list who has access, revoke or extend access, resend invitations, and set agent access modes (open, gated, paid)."
3
+ description: "Manage per-agent access grants for gated public agents — invite visitors by email, list who has access, revoke or extend access, resend invitations, and set agent access modes (open, gated)."
4
4
  ---
5
5
 
6
6
  # Access Manager
7
7
 
8
- Applies when the business owner wants to control who can access a gated or paid public agent.
8
+ Applies when the business owner wants to control who can access a gated public agent.
9
9
 
10
10
  ## When to Activate
11
11
 
12
- - Admin asks to invite someone to a gated agent (by email or phone number)
12
+ - Admin asks to invite someone to a gated agent (always by email)
13
13
  - Admin asks who has access, or to list access grants for an agent
14
14
  - Admin asks to revoke or extend someone's access
15
- - Admin asks to set an agent's access mode (open, gated, paid)
15
+ - Admin asks to set an agent's access mode (open or gated)
16
16
  - Admin asks to resend an invitation
17
17
 
18
18
  ## Constraints
@@ -20,7 +20,9 @@ Applies when the business owner wants to control who can access a gated or paid
20
20
  - **Admin-scope only.** Every AccessGrant node lands with `scope: "admin"`. Credential data must never be visible to public agents — the recorder enforces the scope tag on its write turn.
21
21
  - **Agent must exist.** Before any grant operation, verify the target agent directory exists under `agents/` and read its `config.json`.
22
22
  - **Confirm before mutating.** Present a `confirm` component before grants are created, access is revoked, or access modes change. The recorder writes off the confirmed transcript.
23
- - **Domain required for email invitations.** Resolve the public hostname via `tunnel-status`. Without a confirmed hostname, magic link URLs cannot be constructed.
23
+ - **Domain required.** Resolve the public hostname via `tunnel-status`. Without a confirmed hostname, magic link URLs cannot be constructed.
24
+ - **`sliceToken` is immutable.** Stamped at create as a UUIDv4; revoke / extend / resend never mutate it. The same visitor across multiple invitations has the same person record but distinct sliceTokens per grant.
25
+ - **Revoke evicts active sessions.** After the cypher revoke succeeds, POST loopback `/api/admin/access-session-evict?grantId=<elementId>` so any in-flight `__access_session` cookie for that grant is dropped from the UI server's session map.
24
26
  - **Deterministic grant queries.** AccessGrant nodes are not vector-indexed. Query them via `cypher-shell` (password at `~/.maxy/.neo4j-password`), not via `memory-search`.
25
27
 
26
28
  ## Operations