@rubytech/create-maxy-code 0.1.69 → 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 (134) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +1 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +163 -102
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +127 -13
  5. package/payload/platform/plugins/docs/references/admin-session.md +22 -18
  6. package/payload/platform/plugins/docs/references/platform.md +4 -0
  7. package/payload/platform/plugins/memory/PLUGIN.md +9 -1
  8. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.d.ts +2 -0
  9. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.d.ts.map +1 -0
  10. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js +116 -0
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/resolve-active-vertical.test.js.map +1 -0
  12. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js +100 -0
  13. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-loader.test.js.map +1 -1
  14. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js +197 -5
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-validator.test.js.map +1 -1
  16. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts +33 -0
  17. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.d.ts.map +1 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js +44 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-active-vertical.js.map +1 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts +29 -1
  21. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.d.ts.map +1 -1
  22. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js +57 -5
  23. package/payload/platform/plugins/memory/mcp/dist/lib/schema-loader.js.map +1 -1
  24. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts +9 -0
  25. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.d.ts.map +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js +36 -2
  27. package/payload/platform/plugins/memory/mcp/dist/lib/schema-validator.js.map +1 -1
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +7 -3
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-open.test.js +1 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-personfields-open.test.js.map +1 -1
  32. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +22 -11
  34. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  35. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +6 -0
  36. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  37. package/payload/platform/plugins/memory/references/schema-base.md +2 -2
  38. package/payload/platform/scripts/lib/read-brand-json.sh +69 -0
  39. package/payload/platform/scripts/seed-neo4j.sh +67 -8
  40. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +73 -1
  42. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +8 -1
  44. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/http-server.js +49 -4
  46. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +5 -0
  48. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +11 -3
  50. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  51. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +45 -20
  53. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  55. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +5 -1
  57. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  58. package/payload/platform/templates/specialists/agents/database-operator.md +4 -27
  59. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +2 -0
  60. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/build.md +35 -7
  61. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +3 -2
  62. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/template.html +3 -1
  63. package/payload/server/public/assets/{admin-BPRjc4z9.js → admin-Bej-Q1IZ.js} +1 -1
  64. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Asw7tNG5.js → architectureDiagram-Q4EWVU46-umb1qQ5e.js} +1 -1
  65. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-Blvay4_b.js → blockDiagram-DXYQGD6D-C8kHw50g.js} +1 -1
  66. package/payload/server/public/assets/{brand-CwHvVF8s.css → brand-DxX912hX.css} +1 -1
  67. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-C2st6jZP.js → c4Diagram-AHTNJAMY-CtKxKvPu.js} +1 -1
  68. package/payload/server/public/assets/channel-BshbIIpE.js +1 -0
  69. package/payload/server/public/assets/{chunk-336JU56O-Cp7B2yh1.js → chunk-336JU56O-_xUA1Yj-.js} +2 -2
  70. package/payload/server/public/assets/{chunk-426QAEUC-B0x3RXvj.js → chunk-426QAEUC-d_A8vQqM.js} +1 -1
  71. package/payload/server/public/assets/{chunk-4TB4RGXK-B0yM0pgq.js → chunk-4TB4RGXK-BgkVBpN_.js} +1 -1
  72. package/payload/server/public/assets/{chunk-5FUZZQ4R-CqGhfArO.js → chunk-5FUZZQ4R-CGOqTZDS.js} +1 -1
  73. package/payload/server/public/assets/{chunk-5PVQY5BW-jSeD3Yrj.js → chunk-5PVQY5BW-CW78oYeu.js} +1 -1
  74. package/payload/server/public/assets/{chunk-EDXVE4YY-C7HuvG2r.js → chunk-EDXVE4YY-D_w_ZcbQ.js} +1 -1
  75. package/payload/server/public/assets/{chunk-ENJZ2VHE-DzCrge6H.js → chunk-ENJZ2VHE-MIcdFlwQ.js} +1 -1
  76. package/payload/server/public/assets/{chunk-ICPOFSXX-BPvCh0Yc.js → chunk-ICPOFSXX-BaRHp6jX.js} +1 -1
  77. package/payload/server/public/assets/{chunk-OYMX7WX6-DamxwrTn.js → chunk-OYMX7WX6-Cgo3QttE.js} +1 -1
  78. package/payload/server/public/assets/{chunk-U2HBQHQK-DDiVd0qV.js → chunk-U2HBQHQK-BVZMJI1z.js} +1 -1
  79. package/payload/server/public/assets/{chunk-X2U36JSP-CDof681v.js → chunk-X2U36JSP-DdgOyRDq.js} +1 -1
  80. package/payload/server/public/assets/{chunk-YZCP3GAM-DYzaYjrN.js → chunk-YZCP3GAM-CO61zu06.js} +1 -1
  81. package/payload/server/public/assets/{chunk-ZZ45TVLE-j0bcHHzU.js → chunk-ZZ45TVLE-B3BNOUKS.js} +1 -1
  82. package/payload/server/public/assets/classDiagram-6PBFFD2Q-mzmkY4eP.js +1 -0
  83. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Co3bBdih.js +1 -0
  84. package/payload/server/public/assets/clone-BRSMOear.js +1 -0
  85. package/payload/server/public/assets/{dagre-C8nbdG6y.js → dagre-BY0FcwR1.js} +1 -1
  86. package/payload/server/public/assets/{dagre-KV5264BT-1RnGN_fy.js → dagre-KV5264BT-YvKOR5T0.js} +1 -1
  87. package/payload/server/public/assets/data-LkSd6fEQ.js +1 -0
  88. package/payload/server/public/assets/{device-url-actions-C_Keyr0L.js → device-url-actions-Cq5VIBKc.js} +1 -1
  89. package/payload/server/public/assets/{diagram-5BDNPKRD-C6d3Rrui.js → diagram-5BDNPKRD-DL3SiNlA.js} +1 -1
  90. package/payload/server/public/assets/{diagram-G4DWMVQ6-aD4Dik01.js → diagram-G4DWMVQ6-C_IwINPH.js} +1 -1
  91. package/payload/server/public/assets/{diagram-MMDJMWI5-CAub49YE.js → diagram-MMDJMWI5-vumL8N8U.js} +1 -1
  92. package/payload/server/public/assets/{diagram-TYMM5635-CAKnAfEr.js → diagram-TYMM5635-ewBaf58r.js} +1 -1
  93. package/payload/server/public/assets/{erDiagram-SMLLAGMA-BrqOworC.js → erDiagram-SMLLAGMA-kiidRkW3.js} +1 -1
  94. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-DV4eU-T2.js → flowDiagram-DWJPFMVM-C-1rI_Iw.js} +1 -1
  95. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DayXUN42.js → ganttDiagram-T4ZO3ILL-B_9YdwWh.js} +1 -1
  96. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CK1xV1OV.js → gitGraphDiagram-UUTBAWPF-BmwivVe3.js} +1 -1
  97. package/payload/server/public/assets/graph-CPXz4A4I.js +1 -0
  98. package/payload/server/public/assets/graph-labels-CZR9j5Ge.js +1 -0
  99. package/payload/server/public/assets/{graphlib-CkF9o1XT.js → graphlib-Dvt3mNtm.js} +1 -1
  100. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CiepM2H6.js → infoDiagram-42DDH7IO-B2n7mCmp.js} +1 -1
  101. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-DwNkdQ6z.js → ishikawaDiagram-UXIWVN3A-C2ejJadN.js} +1 -1
  102. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BlDLMfu_.js → journeyDiagram-VCZTEJTY-BaIXYVwB.js} +1 -1
  103. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-ayZTc0Oj.js → kanban-definition-6JOO6SKY-B1NCV7hH.js} +1 -1
  104. package/payload/server/public/assets/{line-LhPUiuH-.js → line-BEjGPKWc.js} +1 -1
  105. package/payload/server/public/assets/{mermaid-parser.core-DyZYsQv_.js → mermaid-parser.core-DwLM_Bkq.js} +1 -1
  106. package/payload/server/public/assets/{mermaid.core-CauxgRdz.js → mermaid.core-BwedTSna.js} +3 -3
  107. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CQzcaJuh.js → mindmap-definition-QFDTVHPH-D6q_XhF7.js} +1 -1
  108. package/payload/server/public/assets/{page-DAw3-U_s.js → page-BBRZF8Z8.js} +1 -1
  109. package/payload/server/public/assets/{page-BGv81dGN.js → page-CLr-z4E2.js} +1 -1
  110. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BPhAeTm9.js → pieDiagram-DEJITSTG-CAKQ-eUh.js} +1 -1
  111. package/payload/server/public/assets/{public-CGI24wrL.js → public-CfVPuA1x.js} +3 -3
  112. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CxrzL3ZF.js → quadrantDiagram-34T5L4WZ-B8_Xl5p8.js} +1 -1
  113. package/payload/server/public/assets/{requirementDiagram-MS252O5E-ZBSC4fGX.js → requirementDiagram-MS252O5E-BWSq9eFs.js} +1 -1
  114. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BTDBEisC.js → sankeyDiagram-XADWPNL6-V3uWSRXp.js} +1 -1
  115. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-Bv7fA5_J.js → sequenceDiagram-FGHM5R23-DJ_F3-vT.js} +1 -1
  116. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-D07lcZrk.js → stateDiagram-FHFEXIEX-odlwuCTP.js} +1 -1
  117. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DE5qFFDY.js +1 -0
  118. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-DyeyUqKa.js → timeline-definition-GMOUNBTQ-DXE7m_zC.js} +1 -1
  119. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-B9FqDmwS.js → vennDiagram-DHZGUBPP-DEiEepoV.js} +1 -1
  120. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-4-5uCIAD.js → wardleyDiagram-NUSXRM2D-CRhVx95h.js} +1 -1
  121. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-WdkP5T_y.js → xychartDiagram-5P7HB3ND-9Gv4VNaG.js} +1 -1
  122. package/payload/server/public/data.html +5 -5
  123. package/payload/server/public/graph.html +5 -5
  124. package/payload/server/public/index.html +7 -7
  125. package/payload/server/public/public.html +4 -4
  126. package/payload/server/public/assets/channel-DTh_uFbz.js +0 -1
  127. package/payload/server/public/assets/classDiagram-6PBFFD2Q-Dsupydrn.js +0 -1
  128. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DrwAXnBf.js +0 -1
  129. package/payload/server/public/assets/clone-COx5mf-H.js +0 -1
  130. package/payload/server/public/assets/data-DOI26n9p.js +0 -1
  131. package/payload/server/public/assets/graph-DP9P7TKZ.js +0 -1
  132. package/payload/server/public/assets/graph-labels-BoLyECfJ.js +0 -1
  133. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Cr1Qet-f.js +0 -1
  134. /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.69",
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",
@@ -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 \
@@ -92,29 +92,32 @@ After every completed operator admin turn, the Stop hook `platform/plugins/admin
92
92
  Body overrides relative to a plain Sidebar click:
93
93
  - `specialist: 'database-operator'`
94
94
  - `model: 'haiku'`
95
- - `initialMessage: <json-envelope>` (string carrying a JSON-stringified object — see below)
95
+ - `initialMessage: <filled-paragraph>` (the four-sentence database-operator template with `{schema}` and `{conversation}` substituted at hook time — see below)
96
96
 
97
97
  **Spawn body.** The hook POSTs `{adminSessionId, channel: 'browser', specialist: 'database-operator', model: 'haiku', initialMessage}` to `POST /api/admin/claude-sessions`. `adminSessionId` is the loopback-bypass key — the wrapper resolves the operator's real `senderId` from `GET <managerBase>/<adminSessionId>/meta` and forwards a Sidebar-shape body to the manager.
98
98
 
99
- **`initialMessage` JSON envelope (Task 177).** A single JSON object stringified to a string. The schema below is the authoritative contract — the database-operator agent file no longer carries it, so any change here is the only place the envelope shape moves:
99
+ **`initialMessage` substitution contract (Task 195).** The agent body on disk is the user-specified four-sentence paragraph verbatim, with two placeholders `{schema}` and `{conversation}`:
100
100
 
101
101
  ```
102
- {
103
- "turns": [
104
- { "role": "user", "text": "...", "ts": "<ISO-8601>" },
105
- { "role": "assistant", "text": "...", "ts": "<ISO-8601>",
106
- "toolCalls": [ { "tool": "...", "input": {...}, "output": ... } ] },
107
- ...
108
- ],
109
- "conversationId": "<operator session_id>",
110
- "accountId": "<ACCOUNT_ID env, stamped on the manager systemd unit>",
111
- "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
112
- }
102
+ You are an expert Neo4J graph operator. Here is the schema {schema}.
103
+ Use your expert judgement to update the graph in reaction to the
104
+ following conversation {conversation}, using the tools at your disposal.
105
+ You are not user-facing and your text goes nowhere — do not emit it.
113
106
  ```
114
107
 
115
- `turns` is the operator's full conversation transcript, oldest first, newest last — every user / assistant message in the operator's JSONL from session start to the moment the recorder fired. No windowing, no truncation, no env knob. The walker collapses multi-record assistant messages on `message.id` (e.g. one `thinking` record + one `text` record from the same message become one `turns` entry with combined text). `toolCalls` is assistant-only and omitted on turns that called no tools. `toolCalls[].input` and `toolCalls[].output` are native JSON values, not re-stringified — `jq -r '.toolCalls[0].input.foo'` reaches the inner field directly. `tool_use` and `tool_result` blocks are paired by `tool_use_id` and attach to the assistant turn that owned the `tool_use`; the user record carrying only the `tool_result` does not create a separate user turn. Unanswered `tool_use` calls surface with `output: null`. No leading instruction prose — the agent file's system prompt teaches the model what to do with the payload. If `turns` is empty after the walker runs, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned. (Task 175's earlier `(operatorMessage, assistantReply)` pair contract was superseded — the pair asserted a temporal Q→A relationship the walker never enforced, so Task 177 replaced it with the ordered window.)
108
+ At fire time the Stop hook substitutes both placeholders and sends the filled paragraph as `initialMessage` a plain string, not a JSON envelope, no instruction prose, no leading object.
116
109
 
117
- The hook also emits one summary line per fire immediately before `spawn-request`: `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>`. `turnsCount` is monotone across spawns in the same operator session a flat or decreasing series is the regression signature for "windowing crept back in".
110
+ - `{schema}` the concatenated text of `platform/plugins/memory/references/schema-base.md` plus, when `brand.json#vertical` names a vertical file that exists in the same directory, that file's content joined with a blank-line separator (Task 193). When the brand declares `vertical: null`, the field is missing, or the named file is absent, the schema is `schema-base.md` alone never loud-fail here; Task 193 owns the brand-config gate.
111
+ - `{conversation}` ← a flat rendering of the envelope's `turns` array. Format: one line per turn, `role: text`, wrapped in leading and trailing newlines so each line lands on its own line in the filled prompt. Assistant turns that called tools carry inline `[tool: name(args)]` markers at the end of their line, with `args` being the call's input as a JSON literal. Tool outputs are not included — only the call shape the model has been emitting. The user record carrying only `tool_result` blocks does not create a separate transcript line; thinking-only records contribute nothing.
112
+
113
+ The envelope walker still runs (so `[turn-recorder] envelope ...` continues to land for observability and the `trigger-skipped reason=conversation-empty` gate still fires on a turns-empty transcript), but its JSON shape never reaches the model.
114
+
115
+ Walker semantics for `turns` (preserved from Task 177): oldest first, newest last; every user / assistant message in the operator's JSONL from session start to the moment the recorder fired; no windowing, no truncation, no env knob; multi-record assistant messages collapse on `message.id`; `tool_use` and `tool_result` blocks are paired by `tool_use_id` so the result attaches to the assistant turn that owned the call rather than fabricating a separate user turn; if `turns` is empty after the walker runs the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned. The Task 177 envelope shape — `{turns, conversationId, accountId, occurredAt}` — is now an internal hook structure; it is not what the model sees.
116
+
117
+ The hook emits two summary lines per fire immediately before `spawn-request`:
118
+
119
+ - `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` — the walker's output. `turnsCount` is monotone across spawns in the same operator session; a flat or decreasing series is the regression signature for "windowing crept back in".
120
+ - `[turn-recorder] substitution sessionId=<op> schemaBytes=<n> conversationBytes=<n> bodyBytes=<n>` — the per-spawn substitution. `schemaBytes` covers schema-base plus any vertical concatenated. `bodyBytes` equals the trailing `initialMessageBytes` field on the next `spawn-request` line — the two log lines read the same number.
118
121
 
119
122
  **Manager-side specialist branches.** The recorder is the first specialist subagent that exercises the full specialist flag matrix:
120
123
  - The bundled `platform/templates/specialists/agents/database-operator.md` template is symlinked into `$CLAUDE_CONFIG_DIR/agents/database-operator.md` by the installer; without that link `claude --agent database-operator` silently falls back to the admin agent.
@@ -200,10 +203,11 @@ Three log-line shapes, all keyed on the same recorder `sessionId`, distinguish t
200
203
  ```
201
204
 
202
205
  - `[pty-spawn-tool-inventory]` (Task 178) measures the **framework** layer — the argv built and the MCP servers' published tools. Argv-tools count must be 11; mcp-listed-tools must be ≥ 11.
203
- - `[recorder-session-init]` (Task 183 — emitted by the JSONL tail in `claude-session-manager`) measures the **CLI/SDK** layer — what the SDK actually exposed to the model. The set difference `toolsArgvMissingFromExposed` is the Outcome A fingerprint:
206
+ - `[recorder-session-init]` (Task 183 — emitted by the JSONL tail in `claude-session-manager`) measures the **CLI/SDK** layer — what the SDK actually exposed to the model. The `source=` field names the data path; `toolsArgvMissingFromExposed` is the Outcome A fingerprint:
207
+ - `source=system.init` / `command_permissions` / `deferred_tools_delta` ⇒ the SDK wrote an init record to the JSONL and the tail matched one of the three known shapes. The bridge-attached `--remote-control` mode writes `attachment.deferred_tools_delta`; the headless SDK init record (`system.subtype=init`) is what `claude -p --output-format=stream-json` modes write.
208
+ - `source=headless-argv-fallback` ⇒ Task 185 finding (2026-05-20): headless `claude --verbose` PTY mode (which the database-operator recorder uses) does **not** write any tool-list record to the JSONL — none of the three probe shapes ever appears. The tail falls back to the spawner's `--allowed-tools` argv as the exposed set, independently verified by `[pty-spawn-tool-inventory]`'s `exposed=` field. Treat this source identically to a positive JSONL match: the argv set is authoritative for headless spawns.
204
209
  - `toolsArgvMissingFromExposed=—` ⇒ all argv tools made it through; Outcome A is ruled out.
205
- - Any non-`—` value enumerates the names the SDK dropped and is the diagnosis.
206
- - `error=init-record-not-found probedTypes=system.init,command_permissions,deferred_tools_delta` ⇒ no init-shaped record was found in the JSONL by the time the first assistant turn landed. The miss itself is the observable; iterate the probe list against the live JSONL to learn the new record shape.
210
+ - Any non-`—` value enumerates the names the SDK dropped and is the diagnosis. Only meaningful when `source` is JSONL-derived; under `headless-argv-fallback` the exposed set equals argv by construction, so this field is always `—`.
207
211
  - `[recorder-turn]` measures the **model** layer — Haiku's verbatim words (`textHead`) and the tool names it actually called (`toolUseNames`). `toolUseNames=—` paired with `toolsArgvMissingFromExposed=—` is the Outcome B signature: tools present, model abstained.
208
212
 
209
213
  The three lines are emitted into `server.log` via the platform UI's `/api/admin/log-ingest` loopback route, so a single grep against `server.log` answers the question for any past, present, or future recorder spawn: