@rubytech/create-maxy-code 0.1.68 → 0.1.70

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 (138) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +1 -0
  3. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  4. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +163 -102
  5. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +127 -13
  6. package/payload/platform/plugins/docs/references/admin-session.md +22 -18
  7. package/payload/platform/plugins/docs/references/platform.md +4 -0
  8. package/payload/platform/plugins/memory/PLUGIN.md +9 -1
  9. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.d.ts +2 -0
  10. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.d.ts.map +1 -0
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js +116 -0
  12. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js.map +1 -0
  13. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js +100 -0
  14. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js +197 -5
  16. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts +33 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js +44 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts +29 -1
  22. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js +57 -5
  24. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js.map +1 -1
  25. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts +9 -0
  26. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts.map +1 -1
  27. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js +36 -2
  28. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js.map +1 -1
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +7 -3
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-open.test.js +1 -0
  32. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-open.test.js.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  34. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +22 -11
  35. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +6 -0
  37. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  38. package/payload/platform/plugins/memory/references/schema-base.md +2 -2
  39. package/payload/platform/scripts/lib/read-brand-json.sh +69 -0
  40. package/payload/platform/scripts/seed-neo4j.sh +67 -8
  41. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +73 -1
  43. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +8 -1
  45. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/http-server.js +49 -4
  47. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +5 -0
  49. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  50. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +11 -3
  51. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  53. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +45 -20
  54. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  55. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  56. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  57. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +5 -1
  58. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  59. package/payload/platform/templates/specialists/agents/database-operator.md +4 -67
  60. package/payload/premium-plugins/real-agent/plugins/brochures/skills/brand-design/SKILL.md +48 -0
  61. package/payload/premium-plugins/real-agent/plugins/brochures/skills/make-brochure/SKILL.md +1 -1
  62. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +2 -0
  63. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/build.md +284 -11
  64. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/index.html +1 -0
  65. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +66 -8
  66. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/template.html +4 -1
  67. package/payload/server/public/assets/{admin-BPRjc4z9.js → admin-Bej-Q1IZ.js} +1 -1
  68. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Asw7tNG5.js → architectureDiagram-Q4EWVU46-umb1qQ5e.js} +1 -1
  69. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-Blvay4_b.js → blockDiagram-DXYQGD6D-C8kHw50g.js} +1 -1
  70. package/payload/server/public/assets/{brand-CwHvVF8s.css → brand-DxX912hX.css} +1 -1
  71. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-C2st6jZP.js → c4Diagram-AHTNJAMY-CtKxKvPu.js} +1 -1
  72. package/payload/server/public/assets/channel-BshbIIpE.js +1 -0
  73. package/payload/server/public/assets/{chunk-336JU56O-Cp7B2yh1.js → chunk-336JU56O-_xUA1Yj-.js} +2 -2
  74. package/payload/server/public/assets/{chunk-426QAEUC-B0x3RXvj.js → chunk-426QAEUC-d_A8vQqM.js} +1 -1
  75. package/payload/server/public/assets/{chunk-4TB4RGXK-B0yM0pgq.js → chunk-4TB4RGXK-BgkVBpN_.js} +1 -1
  76. package/payload/server/public/assets/{chunk-5FUZZQ4R-CqGhfArO.js → chunk-5FUZZQ4R-CGOqTZDS.js} +1 -1
  77. package/payload/server/public/assets/{chunk-5PVQY5BW-jSeD3Yrj.js → chunk-5PVQY5BW-CW78oYeu.js} +1 -1
  78. package/payload/server/public/assets/{chunk-EDXVE4YY-C7HuvG2r.js → chunk-EDXVE4YY-D_w_ZcbQ.js} +1 -1
  79. package/payload/server/public/assets/{chunk-ENJZ2VHE-DzCrge6H.js → chunk-ENJZ2VHE-MIcdFlwQ.js} +1 -1
  80. package/payload/server/public/assets/{chunk-ICPOFSXX-BPvCh0Yc.js → chunk-ICPOFSXX-BaRHp6jX.js} +1 -1
  81. package/payload/server/public/assets/{chunk-OYMX7WX6-DamxwrTn.js → chunk-OYMX7WX6-Cgo3QttE.js} +1 -1
  82. package/payload/server/public/assets/{chunk-U2HBQHQK-DDiVd0qV.js → chunk-U2HBQHQK-BVZMJI1z.js} +1 -1
  83. package/payload/server/public/assets/{chunk-X2U36JSP-CDof681v.js → chunk-X2U36JSP-DdgOyRDq.js} +1 -1
  84. package/payload/server/public/assets/{chunk-YZCP3GAM-DYzaYjrN.js → chunk-YZCP3GAM-CO61zu06.js} +1 -1
  85. package/payload/server/public/assets/{chunk-ZZ45TVLE-j0bcHHzU.js → chunk-ZZ45TVLE-B3BNOUKS.js} +1 -1
  86. package/payload/server/public/assets/classDiagram-6PBFFD2Q-mzmkY4eP.js +1 -0
  87. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Co3bBdih.js +1 -0
  88. package/payload/server/public/assets/clone-BRSMOear.js +1 -0
  89. package/payload/server/public/assets/{dagre-C8nbdG6y.js → dagre-BY0FcwR1.js} +1 -1
  90. package/payload/server/public/assets/{dagre-KV5264BT-1RnGN_fy.js → dagre-KV5264BT-YvKOR5T0.js} +1 -1
  91. package/payload/server/public/assets/data-LkSd6fEQ.js +1 -0
  92. package/payload/server/public/assets/{device-url-actions-C_Keyr0L.js → device-url-actions-Cq5VIBKc.js} +1 -1
  93. package/payload/server/public/assets/{diagram-5BDNPKRD-C6d3Rrui.js → diagram-5BDNPKRD-DL3SiNlA.js} +1 -1
  94. package/payload/server/public/assets/{diagram-G4DWMVQ6-aD4Dik01.js → diagram-G4DWMVQ6-C_IwINPH.js} +1 -1
  95. package/payload/server/public/assets/{diagram-MMDJMWI5-CAub49YE.js → diagram-MMDJMWI5-vumL8N8U.js} +1 -1
  96. package/payload/server/public/assets/{diagram-TYMM5635-CAKnAfEr.js → diagram-TYMM5635-ewBaf58r.js} +1 -1
  97. package/payload/server/public/assets/{erDiagram-SMLLAGMA-BrqOworC.js → erDiagram-SMLLAGMA-kiidRkW3.js} +1 -1
  98. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-DV4eU-T2.js → flowDiagram-DWJPFMVM-C-1rI_Iw.js} +1 -1
  99. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DayXUN42.js → ganttDiagram-T4ZO3ILL-B_9YdwWh.js} +1 -1
  100. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CK1xV1OV.js → gitGraphDiagram-UUTBAWPF-BmwivVe3.js} +1 -1
  101. package/payload/server/public/assets/graph-CPXz4A4I.js +1 -0
  102. package/payload/server/public/assets/graph-labels-CZR9j5Ge.js +1 -0
  103. package/payload/server/public/assets/{graphlib-CkF9o1XT.js → graphlib-Dvt3mNtm.js} +1 -1
  104. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CiepM2H6.js → infoDiagram-42DDH7IO-B2n7mCmp.js} +1 -1
  105. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-DwNkdQ6z.js → ishikawaDiagram-UXIWVN3A-C2ejJadN.js} +1 -1
  106. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BlDLMfu_.js → journeyDiagram-VCZTEJTY-BaIXYVwB.js} +1 -1
  107. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-ayZTc0Oj.js → kanban-definition-6JOO6SKY-B1NCV7hH.js} +1 -1
  108. package/payload/server/public/assets/{line-LhPUiuH-.js → line-BEjGPKWc.js} +1 -1
  109. package/payload/server/public/assets/{mermaid-parser.core-DyZYsQv_.js → mermaid-parser.core-DwLM_Bkq.js} +1 -1
  110. package/payload/server/public/assets/{mermaid.core-CauxgRdz.js → mermaid.core-BwedTSna.js} +3 -3
  111. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CQzcaJuh.js → mindmap-definition-QFDTVHPH-D6q_XhF7.js} +1 -1
  112. package/payload/server/public/assets/{page-DAw3-U_s.js → page-BBRZF8Z8.js} +1 -1
  113. package/payload/server/public/assets/{page-BGv81dGN.js → page-CLr-z4E2.js} +1 -1
  114. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BPhAeTm9.js → pieDiagram-DEJITSTG-CAKQ-eUh.js} +1 -1
  115. package/payload/server/public/assets/{public-CGI24wrL.js → public-CfVPuA1x.js} +3 -3
  116. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CxrzL3ZF.js → quadrantDiagram-34T5L4WZ-B8_Xl5p8.js} +1 -1
  117. package/payload/server/public/assets/{requirementDiagram-MS252O5E-ZBSC4fGX.js → requirementDiagram-MS252O5E-BWSq9eFs.js} +1 -1
  118. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BTDBEisC.js → sankeyDiagram-XADWPNL6-V3uWSRXp.js} +1 -1
  119. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-Bv7fA5_J.js → sequenceDiagram-FGHM5R23-DJ_F3-vT.js} +1 -1
  120. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-D07lcZrk.js → stateDiagram-FHFEXIEX-odlwuCTP.js} +1 -1
  121. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DE5qFFDY.js +1 -0
  122. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-DyeyUqKa.js → timeline-definition-GMOUNBTQ-DXE7m_zC.js} +1 -1
  123. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-B9FqDmwS.js → vennDiagram-DHZGUBPP-DEiEepoV.js} +1 -1
  124. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-4-5uCIAD.js → wardleyDiagram-NUSXRM2D-CRhVx95h.js} +1 -1
  125. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-WdkP5T_y.js → xychartDiagram-5P7HB3ND-9Gv4VNaG.js} +1 -1
  126. package/payload/server/public/data.html +5 -5
  127. package/payload/server/public/graph.html +5 -5
  128. package/payload/server/public/index.html +7 -7
  129. package/payload/server/public/public.html +4 -4
  130. package/payload/server/public/assets/channel-DTh_uFbz.js +0 -1
  131. package/payload/server/public/assets/classDiagram-6PBFFD2Q-Dsupydrn.js +0 -1
  132. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DrwAXnBf.js +0 -1
  133. package/payload/server/public/assets/clone-COx5mf-H.js +0 -1
  134. package/payload/server/public/assets/data-DOI26n9p.js +0 -1
  135. package/payload/server/public/assets/graph-DP9P7TKZ.js +0 -1
  136. package/payload/server/public/assets/graph-labels-BoLyECfJ.js +0 -1
  137. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Cr1Qet-f.js +0 -1
  138. /package/payload/server/public/assets/{brand-B8JhGW9e.js → brand-BT-mNX7-.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -15,6 +15,7 @@
15
15
  "cdpPort": 9222,
16
16
  "claudeSessionManagerPort": 19400,
17
17
  "commercialMode": false,
18
+ "vertical": null,
18
19
 
19
20
  "defaultColors": {
20
21
  "primary": "#7C8C72",
@@ -129,7 +129,7 @@ Tools are available via the `admin` MCP server.
129
129
  - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
130
130
  - `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
131
131
  - `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.
132
- - `hooks/turn-completed-graph-write.sh` — 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 conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `turns`, `conversationId`, `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.
132
+ - `hooks/turn-completed-graph-write.sh` — 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`, `conversationId`, `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.
133
133
 
134
134
  ## Session identifiers (Task 135)
135
135
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
- # Task 129/131/165/175/177 regression test for the Stop hook.
2
+ # Task 129/131/165/175/177/195 regression test for the Stop hook.
3
3
  #
4
- # Contract:
4
+ # Contract (post-195):
5
5
  # - Every log emission goes through `POST /api/admin/log-ingest`. Hook
6
6
  # stderr is silent on every path.
7
7
  # - Every gated-off path emits one `trigger-skipped` line carrying a
@@ -11,14 +11,17 @@
11
11
  # `conversationBytes=<n>` and exactly ONE POST to
12
12
  # `/api/admin/claude-sessions`. The body carries the operator
13
13
  # session id as `adminSessionId` and an `initialMessage` whose value
14
- # is a JSON-stringified object with EXACTLY the four keys declared by
15
- # the Task 177 envelope: turns, conversationId, accountId, occurredAt.
16
- # `turns` is the chronologically-ordered list of conversation turns.
17
- # No top-level operatorMessage. No top-level assistantReply.
18
- # No leading instruction prose. No `Conversation id:` prose.
19
- # - The hook emits one `[turn-recorder] envelope ...` log line before
20
- # `spawn-request`, naming turnsCount / userTurns / assistantTurns /
21
- # toolCallTurns.
14
+ # is the FILLED database-operator paragraph the four-sentence
15
+ # template with `{schema}` replaced by `schema-base.md` (plus the
16
+ # brand-declared vertical when set) and `{conversation}` replaced by
17
+ # a flat `role: text` rendering of the envelope's `turns` array with
18
+ # inline `[tool: name(args)]` markers on assistant turns that called
19
+ # tools. The initialMessage is a plain string, not a JSON-stringified
20
+ # envelope; no instruction prose, no leading JSON.
21
+ # - The hook emits exactly one `[turn-recorder] envelope ...` line and
22
+ # one `[turn-recorder] substitution ...` line before `spawn-request`.
23
+ # The substitution line carries `schemaBytes`, `conversationBytes`,
24
+ # and `bodyBytes` so the per-spawn substitution is auditable.
22
25
  # - The hook never writes the legacy `spawn-with-input` / `fired` stderr
23
26
  # lines.
24
27
  #
@@ -219,57 +222,88 @@ else
219
222
  fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
220
223
  fi
221
224
 
222
- # 5c. Envelope has exactly four keys (turns, conversationId, accountId,
223
- # occurredAt). `turns` carries 4 entries in record order; each has
224
- # role/text/ts. No `toolCalls` on turns where there were no tool_use
225
- # blocks (omitted-when-empty rule).
225
+ # 5c. (Task 195) initialMessage is the FILLED four-sentence paragraph.
226
+ # The template's `{schema}` is replaced by schema-base.md content;
227
+ # `{conversation}` is replaced by `role: text` lines for every turn.
228
+ # The string is plain text, not a JSON envelope. The template's
229
+ # opening sentence and closing sentence are present verbatim; the
230
+ # four operator/assistant texts each appear on their own line.
226
231
  RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
227
232
  BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
228
- import sys, json, re
233
+ import sys, json
229
234
  op = sys.argv[1]
230
- acct = sys.argv[2]
231
235
  try:
232
236
  outer = json.load(sys.stdin)
233
- msg_str = outer.get("initialMessage")
234
- if not isinstance(msg_str, str):
235
- print("no:initialMessage-not-string:"+repr(type(msg_str).__name__))
237
+ msg = outer.get("initialMessage")
238
+ if not isinstance(msg, str):
239
+ print("no:initialMessage-not-string:"+repr(type(msg).__name__))
236
240
  sys.exit(0)
237
- msg = json.loads(msg_str)
238
- keys = set(msg.keys())
239
- turns = msg.get("turns") or []
240
241
  conds = []
241
- conds.append(("envelope-keys", keys == {"turns", "conversationId", "accountId", "occurredAt"}))
242
- conds.append(("turns-len", len(turns) == 4))
243
- conds.append(("conversationId", msg.get("conversationId") == op))
244
- conds.append(("accountId", msg.get("accountId") == acct))
245
- conds.append(("occurredAt-shape", bool(re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$", msg.get("occurredAt") or ""))))
246
- conds.append(("no-operatorMessage", "operatorMessage" not in keys))
247
- conds.append(("no-assistantReply", "assistantReply" not in keys))
248
- if len(turns) >= 4:
249
- conds.append(("t0-role", turns[0].get("role") == "user"))
250
- conds.append(("t0-text", turns[0].get("text") == "New Real Agent session"))
251
- conds.append(("t0-ts", turns[0].get("ts") == "2026-05-19T21:27:42.258Z"))
252
- conds.append(("t0-no-toolCalls", "toolCalls" not in turns[0]))
253
- conds.append(("t1-role", turns[1].get("role") == "assistant"))
254
- conds.append(("t1-text", turns[1].get("text") == "Session noted. What would you like to work on?"))
255
- conds.append(("t1-ts", turns[1].get("ts") == "2026-05-19T21:27:45.158Z"))
256
- conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
257
- conds.append(("t2-role", turns[2].get("role") == "user"))
258
- conds.append(("t2-text", turns[2].get("text") == "you tell me"))
259
- conds.append(("t3-role", turns[3].get("role") == "assistant"))
260
- conds.append(("t3-text", turns[3].get("text") == "One question to anchor the next move: what is your name?"))
242
+ conds.append(("starts-with-template",
243
+ msg.startswith("You are an expert Neo4J graph operator.")))
244
+ conds.append(("ends-with-template",
245
+ msg.rstrip().endswith("do not emit it.")))
246
+ conds.append(("no-placeholder-schema", "{schema}" not in msg))
247
+ conds.append(("no-placeholder-conversation", "{conversation}" not in msg))
248
+ # schema-base.md content is interpolated.
249
+ conds.append(("contains-schema-base-header", "Schema Reference" in msg))
250
+ # The conversation lines appear in role: text format.
251
+ conds.append(("has-user-1", "\nuser: New Real Agent session" in msg))
252
+ conds.append(("has-asst-1",
253
+ "\nassistant: Session noted. What would you like to work on?" in msg))
254
+ conds.append(("has-user-2", "\nuser: you tell me" in msg))
255
+ conds.append(("has-asst-2",
256
+ "\nassistant: One question to anchor the next move: what is your name?"
257
+ in msg))
258
+ conds.append(("no-leading-json", not msg.lstrip().startswith("{")))
259
+ conds.append(("no-operatorMessage", "operatorMessage" not in msg))
260
+ conds.append(("no-assistantReply", "assistantReply" not in msg))
261
261
  conds.append(("outer-specialist", outer.get("specialist") == "database-operator"))
262
262
  conds.append(("outer-model", outer.get("model") == "haiku"))
263
263
  conds.append(("outer-adminSessionId", outer.get("adminSessionId") == op))
264
+ conds.append(("outer-channel", outer.get("channel") == "browser"))
264
265
  failed = [name for name, ok in conds if not ok]
265
- print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
266
+ print("yes" if not failed else "no:"+repr(failed))
266
267
  except Exception as e:
267
268
  print("parse-fail:"+str(e))
268
- ' "$OP_ID" "$ACCT_ID" 2>/dev/null)
269
+ ' "$OP_ID" 2>/dev/null)
269
270
  if [[ "$BODY_OK" == "yes" ]]; then
270
- pass "case-5c envelope is { turns, conversationId, accountId, occurredAt }; turns is the 4-entry ordered list"
271
+ pass "case-5c initialMessage is the filled four-sentence paragraph; placeholders substituted; transcript rendered as role: text lines"
272
+ else
273
+ fail "case-5c filled-prompt shape wrong ($BODY_OK)"
274
+ fi
275
+
276
+ # 5h. (Task 195) exactly one `substitution` log-ingest line carrying
277
+ # positive byte counts for schema / conversation / body.
278
+ SUBST_LINE_COUNT=$(ingest_lines | grep -cE "^substitution sessionId=${OP_ID} schemaBytes=[1-9][0-9]* conversationBytes=[1-9][0-9]* bodyBytes=[1-9][0-9]*$" || true)
279
+ if [[ "$SUBST_LINE_COUNT" -eq 1 ]]; then
280
+ pass "case-5h substitution log line emitted once with positive byte counts"
281
+ else
282
+ fail "case-5h expected exactly 1 substitution line w/ positive bytes, got $SUBST_LINE_COUNT (lines: $(ingest_lines))"
283
+ fi
284
+
285
+ # 5i. (Task 195) substitution line lands between envelope and spawn-request.
286
+ ALL_5I_LINES=$(ingest_lines)
287
+ ENV_POS_5I=$(printf '%s\n' "$ALL_5I_LINES" | grep -nE "^envelope sessionId=${OP_ID}" | head -1 | cut -d: -f1)
288
+ SUB_POS_5I=$(printf '%s\n' "$ALL_5I_LINES" | grep -nE "^substitution sessionId=${OP_ID}" | head -1 | cut -d: -f1)
289
+ SPAWN_POS_5I=$(printf '%s\n' "$ALL_5I_LINES" | grep -nE "^spawn-request sessionId=${OP_ID}" | head -1 | cut -d: -f1)
290
+ if [[ -n "$ENV_POS_5I" && -n "$SUB_POS_5I" && -n "$SPAWN_POS_5I" \
291
+ && "$ENV_POS_5I" -lt "$SUB_POS_5I" && "$SUB_POS_5I" -lt "$SPAWN_POS_5I" ]]; then
292
+ pass "case-5i envelope < substitution < spawn-request ordering preserved"
271
293
  else
272
- fail "case-5c envelope shape wrong ($BODY_OK): $RECORDER_BODY"
294
+ fail "case-5i ordering wrong (env=$ENV_POS_5I sub=$SUB_POS_5I spawn=$SPAWN_POS_5I)"
295
+ fi
296
+
297
+ # 5j. (Task 195) `initialMessageBytes` on spawn-request equals `bodyBytes`
298
+ # on substitution — the two log lines are reading the same number.
299
+ BODY_BYTES_5J=$(printf '%s\n' "$ALL_5I_LINES" | grep -E "^substitution sessionId=${OP_ID}" | head -1 \
300
+ | sed -E 's/.*bodyBytes=([0-9]+).*/\1/')
301
+ INIT_BYTES_5J=$(printf '%s\n' "$ALL_5I_LINES" | grep -E "^spawn-request sessionId=${OP_ID}" | head -1 \
302
+ | sed -E 's/.*initialMessageBytes=([0-9]+).*/\1/')
303
+ if [[ -n "$BODY_BYTES_5J" && "$BODY_BYTES_5J" == "$INIT_BYTES_5J" ]]; then
304
+ pass "case-5j substitution.bodyBytes == spawn-request.initialMessageBytes (=$BODY_BYTES_5J)"
305
+ else
306
+ fail "case-5j bodyBytes ($BODY_BYTES_5J) does not match initialMessageBytes ($INIT_BYTES_5J)"
273
307
  fi
274
308
 
275
309
  # 5d. exactly ONE `spawn-request` log-ingest line
@@ -307,12 +341,12 @@ else
307
341
  pass "case-5g no legacy stderr emissions"
308
342
  fi
309
343
 
310
- # --- Case 6 (Task 177): multi-block assistant collapse -----------------
311
- # Two assistant JSONL records sharing `message.id`: first is thinking-only,
312
- # second carries the text. Walker collapses both into ONE `turns` entry
313
- # whose text is the text-record's text. Thinking records contribute no
314
- # text and do not establish the turn; `ts` is the first CONTRIBUTING
315
- # record's ts (here, the text record's ts).
344
+ # --- Case 6 (Task 195): multi-block assistant collapse ----------------
345
+ # Two assistant records sharing `message.id`: thinking-only then text.
346
+ # The walker collapses both into ONE assistant turn whose text is the
347
+ # text-record's text. The filled prompt's conversation portion has
348
+ # exactly two transcript lines: `user: think then answer` and
349
+ # `assistant: answer is 42`.
316
350
  MULTI_TRANSCRIPT=$(mktemp); TMPFILES+=("$MULTI_TRANSCRIPT")
317
351
  {
318
352
  printf '{"type":"user","timestamp":"2026-05-19T21:30:00.000Z","message":{"role":"user","content":"think then answer"}}\n'
@@ -331,23 +365,31 @@ MULTI_OK=$(printf '%s' "$MULTI_BODY" | python3 -c '
331
365
  import sys, json
332
366
  try:
333
367
  outer = json.load(sys.stdin)
334
- msg = json.loads(outer.get("initialMessage"))
335
- turns = msg.get("turns") or []
368
+ msg = outer.get("initialMessage")
369
+ if not isinstance(msg, str):
370
+ print("no:initialMessage-not-string")
371
+ sys.exit(0)
336
372
  conds = []
337
- conds.append(("turns-len-2", len(turns) == 2))
338
- if len(turns) >= 2:
339
- conds.append(("t0-user", turns[0].get("role") == "user" and turns[0].get("text") == "think then answer"))
340
- conds.append(("t1-assistant", turns[1].get("role") == "assistant"))
341
- conds.append(("t1-text", turns[1].get("text") == "answer is 42"))
342
- conds.append(("t1-ts-text-record", turns[1].get("ts") == "2026-05-19T21:30:02.000Z"))
343
- conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
373
+ # Filled-prompt sanity.
374
+ conds.append(("starts-template", msg.startswith("You are an expert Neo4J graph operator.")))
375
+ conds.append(("no-placeholder-conv", "{conversation}" not in msg))
376
+ # Conversation lines.
377
+ conds.append(("has-user", "\nuser: think then answer" in msg))
378
+ conds.append(("has-asst", "\nassistant: answer is 42" in msg))
379
+ # Thinking content does NOT bleed in.
380
+ conds.append(("no-thinking-text", "considering..." not in msg))
381
+ # Exactly two transcript lines (one per turn) — no duplicate assistant.
382
+ user_lines = msg.count("\nuser: ")
383
+ asst_lines = msg.count("\nassistant: ")
384
+ conds.append(("one-user-line", user_lines == 1))
385
+ conds.append(("one-asst-line", asst_lines == 1))
344
386
  failed = [name for name, ok in conds if not ok]
345
- print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
387
+ print("yes" if not failed else "no:"+repr(failed))
346
388
  except Exception as e:
347
389
  print("parse-fail:"+str(e))
348
390
  ' 2>/dev/null)
349
391
  if [[ "$MULTI_OK" == "yes" ]]; then
350
- pass "case-6 multi-block assistant collapses to one turn entry; ts is the first record's ts"
392
+ pass "case-6 multi-block assistant collapses to one transcript line; thinking content does not bleed in"
351
393
  else
352
394
  fail "case-6 multi-block collapse wrong ($MULTI_OK): $MULTI_BODY"
353
395
  fi
@@ -387,30 +429,35 @@ TOOL_OK=$(printf '%s' "$TOOL_BODY" | python3 -c '
387
429
  import sys, json
388
430
  try:
389
431
  outer = json.load(sys.stdin)
390
- msg = json.loads(outer.get("initialMessage"))
391
- turns = msg.get("turns") or []
432
+ msg = outer.get("initialMessage")
433
+ if not isinstance(msg, str):
434
+ print("no:initialMessage-not-string")
435
+ sys.exit(0)
392
436
  conds = []
393
- # Expected 3 turns: user "please write a node", assistant tool_use (text=""),
394
- # assistant "done". The user tool_result record carries no separate user turn.
395
- conds.append(("turns-len-3", len(turns) == 3))
396
- if len(turns) >= 3:
397
- conds.append(("t0", turns[0].get("role") == "user" and turns[0].get("text") == "please write a node"))
398
- conds.append(("t1-role", turns[1].get("role") == "assistant"))
399
- conds.append(("t1-text-empty", turns[1].get("text") == ""))
400
- conds.append(("t1-toolCalls-len", len(turns[1].get("toolCalls") or []) == 1))
401
- tc = (turns[1].get("toolCalls") or [{}])[0]
402
- conds.append(("tc-tool", tc.get("tool") == "mcp__memory__memory-write"))
403
- conds.append(("tc-input", tc.get("input") == {"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}))
404
- conds.append(("tc-output", tc.get("output") == "ok: wrote LocalBusiness"))
405
- conds.append(("t2", turns[2].get("role") == "assistant" and turns[2].get("text") == "done"))
406
- conds.append(("t2-no-toolCalls", "toolCalls" not in turns[2]))
437
+ # The user turn carrying only a tool_result becomes no transcript line;
438
+ # so the filled prompt should have exactly ONE `user:` line.
439
+ conds.append(("one-user-line", msg.count("\nuser: ") == 1))
440
+ conds.append(("has-user-prompt", "\nuser: please write a node" in msg))
441
+ # Two assistant lines: one carrying the tool_use marker (with empty
442
+ # text body), one carrying "done".
443
+ conds.append(("two-asst-lines", msg.count("\nassistant: ") == 2))
444
+ conds.append(("has-asst-done", "\nassistant: done" in msg))
445
+ # Tool-call marker on the owning assistant turn — name and full JSON
446
+ # input round-trip inline.
447
+ conds.append(("has-tool-marker", "[tool: mcp__memory__memory-write(" in msg))
448
+ conds.append(("tool-marker-name", "\"name\": \"Smalleys\"" in msg or "\"name\":\"Smalleys\"" in msg))
449
+ conds.append(("tool-marker-nested", "\"nested\"" in msg))
450
+ conds.append(("tool-marker-nodeType", "\"nodeType\": \"LocalBusiness\"" in msg or "\"nodeType\":\"LocalBusiness\"" in msg))
451
+ # The tool_result content does NOT bleed in (the rendering chose
452
+ # input-only markers; output goes nowhere in the filled prompt).
453
+ conds.append(("no-output-leakage", "ok: wrote LocalBusiness" not in msg))
407
454
  failed = [name for name, ok in conds if not ok]
408
- print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
455
+ print("yes" if not failed else "no:"+repr(failed))
409
456
  except Exception as e:
410
457
  print("parse-fail:"+str(e))
411
458
  ' 2>/dev/null)
412
459
  if [[ "$TOOL_OK" == "yes" ]]; then
413
- pass "case-7 tool_use + tool_result land as one toolCalls entry on owning assistant turn; no extra user turn"
460
+ pass "case-7 assistant tool_use renders as [tool: name(args)] marker on the owning turn; tool_result does not create a separate user line"
414
461
  else
415
462
  fail "case-7 tool pairing wrong ($TOOL_OK): $TOOL_BODY"
416
463
  fi
@@ -449,23 +496,29 @@ LONG_OK=$(printf '%s' "$LONG_BODY" | python3 -c '
449
496
  import sys, json
450
497
  try:
451
498
  outer = json.load(sys.stdin)
452
- msg = json.loads(outer.get("initialMessage"))
453
- turns = msg.get("turns") or []
499
+ msg = outer.get("initialMessage")
500
+ if not isinstance(msg, str):
501
+ print("no:initialMessage-not-string")
502
+ sys.exit(0)
503
+ user_lines = msg.count("\nuser: ")
504
+ asst_lines = msg.count("\nassistant: ")
454
505
  conds = []
455
- conds.append(("turns-len-100", len(turns) == 100))
456
- if len(turns) == 100:
457
- conds.append(("first-user-0", turns[0].get("role") == "user" and turns[0].get("text") == "user msg 0"))
458
- conds.append(("last-asst-49", turns[99].get("role") == "assistant" and turns[99].get("text") == "asst reply 49"))
459
- # ts strictly non-decreasing
460
- tss = [t.get("ts","") for t in turns]
461
- conds.append(("ts-ordered", tss == sorted(tss)))
506
+ # No windowing — every one of the 100 turns appears as a transcript line.
507
+ conds.append(("100-user", user_lines == 50))
508
+ conds.append(("100-asst", asst_lines == 50))
509
+ conds.append(("first-user-0", "\nuser: user msg 0" in msg))
510
+ conds.append(("last-user-49", "\nuser: user msg 49" in msg))
511
+ conds.append(("last-asst-49", "\nassistant: asst reply 49" in msg))
512
+ # The filled prompt is large — schema + 100 transcript lines easily
513
+ # exceeds 5_000 bytes even with a tiny schema.
514
+ conds.append(("body-large", len(msg.encode("utf-8")) > 5000))
462
515
  failed = [name for name, ok in conds if not ok]
463
- print("yes" if not failed else "no:"+repr(failed)+"|len="+str(len(turns)))
516
+ print("yes" if not failed else "no:"+repr(failed)+"|users="+str(user_lines)+"|assts="+str(asst_lines))
464
517
  except Exception as e:
465
518
  print("parse-fail:"+str(e))
466
519
  ' 2>/dev/null)
467
520
  if [[ "$LONG_OK" == "yes" ]]; then
468
- pass "case-8 100-turn transcript → turns.length === 100, oldest first, no truncation"
521
+ pass "case-8 100-turn transcript → 50 user lines + 50 assistant lines in filled prompt; no truncation"
469
522
  else
470
523
  fail "case-8 long-conversation wrong ($LONG_OK)"
471
524
  fi
@@ -522,24 +575,32 @@ run_hook "admin" "" "$ESC_ENVELOPE"
522
575
  ESC_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
523
576
  ESC_OK=$(printf '%s' "$ESC_BODY" | python3 -c '
524
577
  import sys, json
525
- EXPECTED_OP = "hello \"world\"\\nline2\nlast café \U0001F98A"
526
- EXPECTED_ASST = "reply with backslash \\\\ and newline\nhere"
578
+ # The user turn text contains literal quotes, a backslash-n (two chars),
579
+ # a real newline, an em-dash, and a fox emoji. The assistant text has
580
+ # two backslashes and a real newline.
581
+ EXPECTED_OP_FRAG_1 = "hello \"world\""
582
+ EXPECTED_OP_FRAG_2 = "last — café \U0001F98A"
583
+ EXPECTED_ASST_FRAG = "reply with backslash \\\\ and newline"
527
584
  try:
528
585
  outer = json.load(sys.stdin)
529
- msg = json.loads(outer.get("initialMessage"))
530
- turns = msg.get("turns") or []
586
+ msg = outer.get("initialMessage")
587
+ if not isinstance(msg, str):
588
+ print("no:initialMessage-not-string")
589
+ sys.exit(0)
531
590
  conds = []
532
- conds.append(("len-2", len(turns) == 2))
533
- if len(turns) == 2:
534
- conds.append(("op-text", turns[0].get("text") == EXPECTED_OP))
535
- conds.append(("asst-text", turns[1].get("text") == EXPECTED_ASST))
591
+ conds.append(("op-quotes", EXPECTED_OP_FRAG_1 in msg))
592
+ conds.append(("op-emoji", EXPECTED_OP_FRAG_2 in msg))
593
+ conds.append(("asst-backslash", EXPECTED_ASST_FRAG in msg))
594
+ conds.append(("user-prefix", "\nuser: " in msg))
595
+ conds.append(("asst-prefix", "\nassistant: " in msg))
596
+ conds.append(("template-intact", "You are an expert Neo4J graph operator." in msg))
536
597
  failed = [name for name, ok in conds if not ok]
537
- print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
598
+ print("yes" if not failed else "no:"+repr(failed))
538
599
  except Exception as e:
539
600
  print("parse-fail:"+str(e))
540
601
  ' 2>/dev/null)
541
602
  if [[ "$ESC_OK" == "yes" ]]; then
542
- pass "case-10 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly through turns[*].text"
603
+ pass "case-10 escaping: quotes / backslashes / em-dash / emoji survive interpolation into the filled prompt"
543
604
  else
544
605
  fail "case-10 escaping wrong ($ESC_OK): $ESC_BODY"
545
606
  fi
@@ -283,26 +283,140 @@ print(f"{len(turns)} {u} {a} {tc}")
283
283
  read -r TURNS_COUNT USER_TURNS ASST_TURNS TC_TURNS <<<"$ENVELOPE_COUNTS"
284
284
  emit_log "envelope sessionId=${ADMIN_SESSION_ID} turnsCount=${TURNS_COUNT} userTurns=${USER_TURNS} assistantTurns=${ASST_TURNS} toolCallTurns=${TC_TURNS}"
285
285
 
286
- # Compose the Sidebar-shape spawn body. Three overrides relative to a
287
- # plain Sidebar "New session" click: specialist, model, initialMessage.
288
- # `adminSessionId` is the loopback-bypass key the wrapper uses to resolve
289
- # the operator's senderId from the manager's /<sid>/meta.
290
- SPAWN_BODY=$(python3 -c '
286
+ # Task 195 substitute {schema} and {conversation} placeholders in the
287
+ # database-operator agent body before spawning. The agent file on disk
288
+ # carries the four-sentence template verbatim with both placeholders; the
289
+ # hook renders the actual schema text and a flat `role: text` conversation
290
+ # transcript here, then sends the filled paragraph as `initialMessage`.
291
+ # The model's first input is the filled prompt — no JSON envelope, no
292
+ # instruction prose, no placeholders.
293
+ HOOK_DIR_RESOLVED="$(cd "$(dirname "$0")" && pwd)"
294
+ PLATFORM_ROOT_RESOLVED="$(cd "${HOOK_DIR_RESOLVED}/../../.." && pwd)"
295
+ SCHEMA_BASE_PATH="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/schema-base.md"
296
+ BRAND_JSON_PATH="${PLATFORM_ROOT_RESOLVED}/config/brand.json"
297
+ AGENT_BODY_PATH="${PLATFORM_ROOT_RESOLVED}/templates/specialists/agents/database-operator.md"
298
+
299
+ if [ ! -f "$SCHEMA_BASE_PATH" ]; then
300
+ emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=schema-base-missing path=${SCHEMA_BASE_PATH}"
301
+ exit 0
302
+ fi
303
+ if [ ! -f "$AGENT_BODY_PATH" ]; then
304
+ emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=agent-body-missing path=${AGENT_BODY_PATH}"
305
+ exit 0
306
+ fi
307
+
308
+ # Brand-declared vertical (Task 193). Treated as null when brand.json is
309
+ # absent, unparseable, lacks the field, or names a file that does not
310
+ # exist — every one of those collapses to "schema-base only", never
311
+ # loud-fails this hook. Task 193 itself is the loud-fail surface for
312
+ # brand-config defects.
313
+ VERTICAL_PATH=""
314
+ if [ -f "$BRAND_JSON_PATH" ]; then
315
+ VERTICAL_NAME=$(python3 -c '
316
+ import sys, json
317
+ try:
318
+ with open(sys.argv[1]) as f:
319
+ b = json.load(f)
320
+ v = b.get("vertical")
321
+ print(v if isinstance(v, str) and v else "")
322
+ except Exception:
323
+ print("")
324
+ ' "$BRAND_JSON_PATH")
325
+ if [ -n "$VERTICAL_NAME" ]; then
326
+ CANDIDATE="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/${VERTICAL_NAME}.md"
327
+ if [ -f "$CANDIDATE" ]; then
328
+ VERTICAL_PATH="$CANDIDATE"
329
+ fi
330
+ fi
331
+ fi
332
+
333
+ # One Python pass:
334
+ # - reads the agent file and strips the frontmatter to recover the
335
+ # paragraph (the literal four sentences with `{schema}` and
336
+ # `{conversation}` placeholders);
337
+ # - reads schema-base.md (and concatenates the vertical when set);
338
+ # - renders the envelope's `turns` array into a flat transcript — one
339
+ # line per turn, `role: text`, with inline `[tool: name(args)]`
340
+ # markers where the assistant called tools (so the model sees the
341
+ # tool-shape it has been calling without having to read JSON);
342
+ # - substitutes both placeholders in the paragraph;
343
+ # - emits one tab-separated line of byte counts, then the
344
+ # spawn-body JSON containing the filled paragraph as `initialMessage`.
345
+ # The envelope rides through a tmpfile (not stdin) so the python script
346
+ # can be sourced from the heredoc; the two stdin redirections would
347
+ # otherwise collide and Python would see an empty envelope.
348
+ ENVELOPE_FILE=$(mktemp)
349
+ printf '%s' "$ENVELOPE" > "$ENVELOPE_FILE"
350
+ COMBINED=$(python3 - \
351
+ "$AGENT_BODY_PATH" "$SCHEMA_BASE_PATH" "$VERTICAL_PATH" "$ADMIN_SESSION_ID" "$ENVELOPE_FILE" <<'PY'
291
352
  import sys, json
292
- sid = sys.argv[1]
293
- envelope_str = sys.argv[2]
294
- body = {
353
+
354
+ agent_path, schema_path, vertical_path, sid, envelope_path = sys.argv[1:6]
355
+
356
+ with open(agent_path, "r", encoding="utf-8") as f:
357
+ raw = f.read()
358
+ parts = raw.split("---", 2)
359
+ body_template = parts[2].lstrip("\n").rstrip() if len(parts) >= 3 else raw.rstrip()
360
+
361
+ with open(schema_path, "r", encoding="utf-8") as f:
362
+ schema_text = f.read()
363
+ if vertical_path:
364
+ with open(vertical_path, "r", encoding="utf-8") as f:
365
+ schema_text = schema_text.rstrip() + "\n\n" + f.read()
366
+
367
+ with open(envelope_path, "r", encoding="utf-8") as f:
368
+ envelope = json.load(f)
369
+ turns = envelope.get("turns") or []
370
+ lines = []
371
+ for t in turns:
372
+ role = t.get("role", "?")
373
+ text_t = t.get("text", "") or ""
374
+ marks = []
375
+ for tc in (t.get("toolCalls") or []):
376
+ tool = tc.get("tool", "?")
377
+ inp = tc.get("input")
378
+ try:
379
+ inp_s = json.dumps(inp, ensure_ascii=False) if inp is not None else ""
380
+ except Exception:
381
+ inp_s = str(inp)
382
+ marks.append(f"[tool: {tool}({inp_s})]")
383
+ if marks:
384
+ lines.append(f"{role}: {text_t} {' '.join(marks)}".rstrip())
385
+ else:
386
+ lines.append(f"{role}: {text_t}")
387
+ # Wrap the transcript block in newlines so each turn lands on its own
388
+ # line in the filled prompt — the template's `{conversation}` sits
389
+ # mid-sentence, so the leading newline separates the first transcript
390
+ # line from the surrounding prose, and the trailing newline separates
391
+ # the last one from the closing `, using the tools at your disposal.`
392
+ conversation_text = "\n" + "\n".join(lines) + "\n"
393
+
394
+ filled = body_template.replace("{schema}", schema_text).replace("{conversation}", conversation_text)
395
+
396
+ schema_bytes = len(schema_text.encode("utf-8"))
397
+ conv_bytes = len(conversation_text.encode("utf-8"))
398
+ body_bytes = len(filled.encode("utf-8"))
399
+
400
+ spawn_body = {
295
401
  "adminSessionId": sid,
296
402
  "channel": "browser",
297
403
  "specialist": "database-operator",
298
404
  "model": "haiku",
299
- "initialMessage": envelope_str,
405
+ "initialMessage": filled,
300
406
  }
301
- print(json.dumps(body))
302
- ' "$ADMIN_SESSION_ID" "$ENVELOPE" 2>/dev/null)
303
407
 
304
- INITIAL_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
305
- emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${INITIAL_BYTES}"
408
+ print(f"{schema_bytes}\t{conv_bytes}\t{body_bytes}")
409
+ print(json.dumps(spawn_body))
410
+ PY
411
+ )
412
+ rm -f "$ENVELOPE_FILE"
413
+
414
+ BYTES_LINE=$(printf '%s' "$COMBINED" | head -n1)
415
+ SPAWN_BODY=$(printf '%s' "$COMBINED" | tail -n +2)
416
+ IFS=$'\t' read -r SCHEMA_BYTES CONV_BYTES BODY_BYTES <<<"$BYTES_LINE"
417
+
418
+ emit_log "substitution sessionId=${ADMIN_SESSION_ID} schemaBytes=${SCHEMA_BYTES} conversationBytes=${CONV_BYTES} bodyBytes=${BODY_BYTES}"
419
+ emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${BODY_BYTES}"
306
420
 
307
421
  SPAWN_RES_FILE=$(mktemp)
308
422
  SPAWN_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \