@rubytech/create-maxy-code 0.1.66 → 0.1.68

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 (121) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +251 -94
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +119 -71
  5. package/payload/platform/plugins/docs/references/admin-session.md +54 -11
  6. package/payload/platform/plugins/docs/references/deployment.md +2 -2
  7. package/payload/platform/plugins/docs/references/internals.md +2 -0
  8. package/payload/platform/plugins/docs/references/platform.md +1 -1
  9. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js +3 -2
  10. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js.map +1 -1
  11. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts.map +1 -1
  12. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +55 -55
  13. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
  14. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +4 -4
  15. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -0
  16. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/http-server.js +73 -1
  18. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/index.js +18 -0
  20. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +42 -0
  22. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -0
  23. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +330 -0
  24. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -0
  25. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
  26. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
  27. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
  28. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
  29. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +53 -1
  30. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +134 -6
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
  34. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +8 -5
  37. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +35 -16
  39. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  40. package/payload/platform/templates/agents/admin/IDENTITY.md +8 -0
  41. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  42. package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
  43. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +36 -5
  44. package/payload/server/{chunk-S6PQA6Q4.js → chunk-HZ77KMTV.js} +1 -1
  45. package/payload/server/{chunk-NPZKH5EC.js → chunk-ITQ55PCQ.js} +1 -1
  46. package/payload/server/{chunk-X3Y6ENKA.js → chunk-VGMVEYX7.js} +35 -61
  47. package/payload/server/{cloudflare-task-tracker-TL6R364J.js → cloudflare-task-tracker-PZBX7X3D.js} +2 -2
  48. package/payload/server/maxy-edge.js +2 -2
  49. package/payload/server/public/assets/{admin-ymReS1mN.js → admin-BPRjc4z9.js} +3 -3
  50. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-DgfgldJT.js → architectureDiagram-Q4EWVU46-Asw7tNG5.js} +1 -1
  51. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-agu8GDzA.js → blockDiagram-DXYQGD6D-Blvay4_b.js} +1 -1
  52. package/payload/server/public/assets/{brand-BWCtpG7_.js → brand-B8JhGW9e.js} +1 -1
  53. package/payload/server/public/assets/{brand-BDASZqMG.css → brand-CwHvVF8s.css} +1 -1
  54. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-CmNsUmAe.js → c4Diagram-AHTNJAMY-C2st6jZP.js} +1 -1
  55. package/payload/server/public/assets/channel-DTh_uFbz.js +1 -0
  56. package/payload/server/public/assets/{chunk-336JU56O-D9Uts7k9.js → chunk-336JU56O-Cp7B2yh1.js} +2 -2
  57. package/payload/server/public/assets/{chunk-426QAEUC-D4ABqXMn.js → chunk-426QAEUC-B0x3RXvj.js} +1 -1
  58. package/payload/server/public/assets/{chunk-4TB4RGXK-D1KE4OAX.js → chunk-4TB4RGXK-B0yM0pgq.js} +1 -1
  59. package/payload/server/public/assets/{chunk-5FUZZQ4R-CAh0GXe2.js → chunk-5FUZZQ4R-CqGhfArO.js} +1 -1
  60. package/payload/server/public/assets/{chunk-5PVQY5BW-DBHa6HOv.js → chunk-5PVQY5BW-jSeD3Yrj.js} +1 -1
  61. package/payload/server/public/assets/{chunk-EDXVE4YY-BDC-6mL3.js → chunk-EDXVE4YY-C7HuvG2r.js} +1 -1
  62. package/payload/server/public/assets/{chunk-ENJZ2VHE-CH3uROkW.js → chunk-ENJZ2VHE-DzCrge6H.js} +1 -1
  63. package/payload/server/public/assets/{chunk-ICPOFSXX-BzZf0nAw.js → chunk-ICPOFSXX-BPvCh0Yc.js} +1 -1
  64. package/payload/server/public/assets/{chunk-OYMX7WX6-BrLHrI6O.js → chunk-OYMX7WX6-DamxwrTn.js} +1 -1
  65. package/payload/server/public/assets/{chunk-U2HBQHQK-Bw4UkLe_.js → chunk-U2HBQHQK-DDiVd0qV.js} +1 -1
  66. package/payload/server/public/assets/{chunk-X2U36JSP-23xgdk05.js → chunk-X2U36JSP-CDof681v.js} +1 -1
  67. package/payload/server/public/assets/{chunk-YZCP3GAM-BA8iGgRa.js → chunk-YZCP3GAM-DYzaYjrN.js} +1 -1
  68. package/payload/server/public/assets/{chunk-ZZ45TVLE-B-ol4EgR.js → chunk-ZZ45TVLE-j0bcHHzU.js} +1 -1
  69. package/payload/server/public/assets/classDiagram-6PBFFD2Q-Dsupydrn.js +1 -0
  70. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DrwAXnBf.js +1 -0
  71. package/payload/server/public/assets/clone-COx5mf-H.js +1 -0
  72. package/payload/server/public/assets/{dagre--kXszEIb.js → dagre-C8nbdG6y.js} +1 -1
  73. package/payload/server/public/assets/{dagre-KV5264BT-amN1uU2d.js → dagre-KV5264BT-1RnGN_fy.js} +1 -1
  74. package/payload/server/public/assets/data-DOI26n9p.js +1 -0
  75. package/payload/server/public/assets/{device-url-actions-CPoz5fqz.js → device-url-actions-C_Keyr0L.js} +1 -1
  76. package/payload/server/public/assets/{diagram-5BDNPKRD-Cwizs9tP.js → diagram-5BDNPKRD-C6d3Rrui.js} +1 -1
  77. package/payload/server/public/assets/{diagram-G4DWMVQ6-BpSu5Uao.js → diagram-G4DWMVQ6-aD4Dik01.js} +1 -1
  78. package/payload/server/public/assets/{diagram-MMDJMWI5-DiArtkto.js → diagram-MMDJMWI5-CAub49YE.js} +1 -1
  79. package/payload/server/public/assets/{diagram-TYMM5635-_tygK58Y.js → diagram-TYMM5635-CAKnAfEr.js} +1 -1
  80. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CBV6LWYA.js → erDiagram-SMLLAGMA-BrqOworC.js} +1 -1
  81. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CHZFrtOU.js → flowDiagram-DWJPFMVM-DV4eU-T2.js} +1 -1
  82. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-B3vKq5ov.js → ganttDiagram-T4ZO3ILL-DayXUN42.js} +1 -1
  83. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-dnJPjbtj.js → gitGraphDiagram-UUTBAWPF-CK1xV1OV.js} +1 -1
  84. package/payload/server/public/assets/graph-DP9P7TKZ.js +1 -0
  85. package/payload/server/public/assets/graph-labels-BoLyECfJ.js +1 -0
  86. package/payload/server/public/assets/{graphlib-CNXtnega.js → graphlib-CkF9o1XT.js} +1 -1
  87. package/payload/server/public/assets/{infoDiagram-42DDH7IO-D8DYwVAF.js → infoDiagram-42DDH7IO-CiepM2H6.js} +1 -1
  88. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CfEVY2Ud.js → ishikawaDiagram-UXIWVN3A-DwNkdQ6z.js} +1 -1
  89. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-FmoBq7Kh.js → journeyDiagram-VCZTEJTY-BlDLMfu_.js} +1 -1
  90. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-B-Q9qk99.js → kanban-definition-6JOO6SKY-ayZTc0Oj.js} +1 -1
  91. package/payload/server/public/assets/{line-DZwbJENP.js → line-LhPUiuH-.js} +1 -1
  92. package/payload/server/public/assets/{mermaid-parser.core-C3ahKykw.js → mermaid-parser.core-DyZYsQv_.js} +1 -1
  93. package/payload/server/public/assets/{mermaid.core-B8OFZIE8.js → mermaid.core-CauxgRdz.js} +3 -3
  94. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CqhkfbWa.js → mindmap-definition-QFDTVHPH-CQzcaJuh.js} +1 -1
  95. package/payload/server/public/assets/{page-D-OLjort.js → page-BGv81dGN.js} +1 -1
  96. package/payload/server/public/assets/{page-DFFt9_Q0.js → page-DAw3-U_s.js} +2 -2
  97. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CJqQ6u3-.js → pieDiagram-DEJITSTG-BPhAeTm9.js} +1 -1
  98. package/payload/server/public/assets/{public-rWe9ADCS.js → public-CGI24wrL.js} +5 -5
  99. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BtEqOXQM.js → quadrantDiagram-34T5L4WZ-CxrzL3ZF.js} +1 -1
  100. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DPHWUtE6.js → requirementDiagram-MS252O5E-ZBSC4fGX.js} +1 -1
  101. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-FBmc3wHT.js → sankeyDiagram-XADWPNL6-BTDBEisC.js} +1 -1
  102. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BMSd2-1M.js → sequenceDiagram-FGHM5R23-Bv7fA5_J.js} +1 -1
  103. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DqFbU-LT.js → stateDiagram-FHFEXIEX-D07lcZrk.js} +1 -1
  104. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Cr1Qet-f.js +1 -0
  105. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-DsfvHVK8.js → timeline-definition-GMOUNBTQ-DyeyUqKa.js} +1 -1
  106. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-kHU05mqC.js → vennDiagram-DHZGUBPP-B9FqDmwS.js} +1 -1
  107. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C696Mez_.js → wardleyDiagram-NUSXRM2D-4-5uCIAD.js} +1 -1
  108. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-yht-JyXJ.js → xychartDiagram-5P7HB3ND-WdkP5T_y.js} +1 -1
  109. package/payload/server/public/data.html +5 -5
  110. package/payload/server/public/graph.html +5 -5
  111. package/payload/server/public/index.html +7 -7
  112. package/payload/server/public/public.html +4 -4
  113. package/payload/server/server.js +20 -6
  114. package/payload/server/public/assets/channel-DXGOX55q.js +0 -1
  115. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DTuuDxpK.js +0 -1
  116. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CIw8RU0q.js +0 -1
  117. package/payload/server/public/assets/clone-O4ZyuvYl.js +0 -1
  118. package/payload/server/public/assets/data-D6U2e85B.js +0 -1
  119. package/payload/server/public/assets/graph-C274PN27.js +0 -1
  120. package/payload/server/public/assets/graph-labels-DQFzd1FY.js +0 -1
  121. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CXTnsHGq.js +0 -1
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env bash
2
2
  # Stop hook — fires on every completed admin-agent turn and dispatches one
3
- # headless database-operator session against the operator's most recent
4
- # turn payload. The recorder is the only writer to the Neo4j graph; the
5
- # admin agent stays focused on the operator's request.
3
+ # headless database-operator session against the operator's full
4
+ # conversation transcript. The recorder is the only writer to the Neo4j
5
+ # graph; the admin agent stays focused on the operator's request.
6
6
  #
7
7
  # Task 147 — the recorder spawn is byte-for-byte equivalent to a Sidebar
8
8
  # "New session" body, with three overrides:
9
9
  # - specialist: "database-operator"
10
10
  # - model: "haiku"
11
- # - initialMessage: JSON-stringified envelope (Task 175)
11
+ # - initialMessage: JSON-stringified envelope (Task 177)
12
12
  #
13
13
  # It POSTs to the SAME route the Sidebar uses
14
14
  # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
@@ -16,10 +16,16 @@
16
16
  # manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn
17
17
  # body. No recorder-only carving on the manager side.
18
18
  #
19
- # Task 175 — `initialMessage` is now a JSON object stringified to a
20
- # string. Top-level keys EXACTLY match the database-operator agent file
21
- # `## Input shape`: operatorMessage, assistantReply, toolCalls,
22
- # conversationId, accountId, occurredAt. No leading instruction prose.
19
+ # Task 177 — `initialMessage` is a JSON object stringified to a string.
20
+ # Top-level keys EXACTLY: turns, conversationId, accountId, occurredAt.
21
+ # `turns` is the chronologically-ordered conversation window — every
22
+ # user / assistant message in the operator's JSONL, oldest first, no
23
+ # windowing or truncation. Each entry:
24
+ # { role: "user"|"assistant", text: string, ts: string, toolCalls?: [...] }
25
+ # `toolCalls` (assistant-only, omitted when empty) carries
26
+ # `[{ tool, input, output }]` for tool_use/tool_result pairs in that turn.
27
+ # Replaces the Task 175 `(operatorMessage, assistantReply)` pair contract,
28
+ # which asserted a temporal pairing the walker never enforced.
23
29
  #
24
30
  # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
25
31
  # stderr stays silent on the success path):
@@ -28,8 +34,7 @@
28
34
  # "database-operator" (recursion guard)
29
35
  # - Stop-hook stdin must be non-empty → reason=empty-stdin
30
36
  # - transcript_path must exist on disk → reason=missing-transcript
31
- # - operatorMessage, assistantReply, toolCalls → reason=conversation-empty
32
- # all empty after the walker runs
37
+ # - turns array is empty after walker runs → reason=conversation-empty
33
38
  #
34
39
  # Input: Claude Code's Stop hook stdin shape
35
40
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -96,16 +101,17 @@ if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIP
96
101
  exit 0
97
102
  fi
98
103
 
99
- # Task 175 — build the JSON envelope the database-operator agent file
100
- # declares as its stdin contract. One Python pass walks the transcript
101
- # and emits:
104
+ # Task 177 — build the envelope the database-operator agent file declares
105
+ # as its stdin contract. One Python pass walks the transcript and emits:
102
106
  # {
103
- # "operatorMessage": <last role=user text>,
104
- # "assistantReply": <last role=assistant text>,
105
- # "toolCalls": [ {tool, input, output}, ... ],
106
- # "conversationId": <session_id>,
107
- # "accountId": <ACCOUNT_ID env>,
108
- # "occurredAt": <hook fire time, ISO-8601 UTC, .000Z>
107
+ # "turns": [
108
+ # { "role": "user"|"assistant", "text": "...", "ts": "...",
109
+ # "toolCalls"?: [{ "tool": "...", "input": {...}, "output": ... }] },
110
+ # ...
111
+ # ],
112
+ # "conversationId": "<session_id>",
113
+ # "accountId": "<ACCOUNT_ID env>",
114
+ # "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
109
115
  # }
110
116
  # `python3 json.dumps` handles every escape so the hook never has to
111
117
  # concatenate strings into JSON by hand.
@@ -115,26 +121,26 @@ ACCOUNT_ID_ENV="${ACCOUNT_ID:-}"
115
121
  ENVELOPE=$(python3 - "$TRANSCRIPT_PATH" "$ADMIN_SESSION_ID" "$ACCOUNT_ID_ENV" "$OCCURRED_AT" <<'PY'
116
122
  import sys, json
117
123
 
118
- def last_text(content):
119
- """Return the last `text` block string in a content list, or '' if none."""
124
+ def concat_text(content):
125
+ """Concatenate every `text` block in a content list (or return the
126
+ string content as-is). `thinking` blocks contribute nothing."""
120
127
  if isinstance(content, str):
121
128
  return content
122
129
  if not isinstance(content, list):
123
130
  return ""
124
- last = ""
131
+ out = []
125
132
  for b in content:
126
133
  if isinstance(b, dict) and b.get("type") == "text":
127
134
  t = b.get("text")
128
135
  if isinstance(t, str):
129
- last = t
130
- return last
136
+ out.append(t)
137
+ return "".join(out)
131
138
 
132
139
  path, conversation_id, account_id, occurred_at = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
133
140
 
134
- last_user_text = ""
135
- last_assistant_text = ""
136
- pending_calls = {} # tool_use_id -> {tool, input}
137
- tool_calls = [] # ordered as tool_results arrive
141
+ turns = [] # ordered output: { role, text, ts, toolCalls? }
142
+ msg_id_to_turn_index = {} # collapse rule for assistant message.id
143
+ pending_tool_calls = {} # tool_use_id -> (turn_index, toolCalls_index)
138
144
 
139
145
  try:
140
146
  with open(path, "r", encoding="utf-8") as f:
@@ -153,74 +159,99 @@ try:
153
159
  if not isinstance(msg, dict):
154
160
  continue
155
161
  content = msg.get("content")
162
+ ts = rec.get("timestamp", "") or ""
163
+
156
164
  if t == "user":
157
- text = last_text(content)
158
- if text:
159
- last_user_text = text
165
+ # tool_result blocks attach to the assistant turn that
166
+ # owned the corresponding tool_use; they never create a
167
+ # separate user turn entry.
160
168
  if isinstance(content, list):
161
169
  for b in content:
162
170
  if isinstance(b, dict) and b.get("type") == "tool_result":
163
171
  tu_id = b.get("tool_use_id")
164
172
  if not isinstance(tu_id, str):
165
173
  continue
166
- pending = pending_calls.pop(tu_id, None)
167
- if pending is None:
174
+ slot = pending_tool_calls.pop(tu_id, None)
175
+ if slot is None:
168
176
  continue
169
- tool_calls.append({
170
- "tool": pending["tool"],
171
- "input": pending["input"],
172
- "output": b.get("content"),
173
- })
174
- elif t == "assistant":
175
- text = last_text(content)
177
+ turn_idx, call_idx = slot
178
+ turns[turn_idx]["toolCalls"][call_idx]["output"] = b.get("content")
179
+
180
+ text = concat_text(content)
176
181
  if text:
177
- last_assistant_text = text
182
+ turns.append({
183
+ "role": "user",
184
+ "text": text,
185
+ "ts": ts,
186
+ })
187
+ # No user turn emitted when content is empty or only
188
+ # carries tool_result blocks — those landed on the
189
+ # owning assistant turn above.
190
+
191
+ elif t == "assistant":
192
+ msg_id = msg.get("id") if isinstance(msg.get("id"), str) else None
193
+ text = concat_text(content)
194
+ tool_use_blocks = []
178
195
  if isinstance(content, list):
179
196
  for b in content:
180
197
  if isinstance(b, dict) and b.get("type") == "tool_use":
181
- tu_id = b.get("id")
182
- if not isinstance(tu_id, str):
183
- continue
184
- pending_calls[tu_id] = {
185
- "tool": b.get("name"),
186
- "input": b.get("input"),
187
- }
198
+ tool_use_blocks.append(b)
199
+
200
+ # Thinking-only assistant records contribute nothing and
201
+ # do NOT create a turn entry on their own.
202
+ if not text and not tool_use_blocks:
203
+ continue
204
+
205
+ # Multi-block collapse: if a prior record with the same
206
+ # message.id created a turn, extend that one. Otherwise
207
+ # open a new assistant turn.
208
+ if msg_id is not None and msg_id in msg_id_to_turn_index:
209
+ idx = msg_id_to_turn_index[msg_id]
210
+ entry = turns[idx]
211
+ if text:
212
+ entry["text"] = (entry["text"] or "") + text
213
+ else:
214
+ entry = {
215
+ "role": "assistant",
216
+ "text": text,
217
+ "ts": ts,
218
+ }
219
+ turns.append(entry)
220
+ idx = len(turns) - 1
221
+ if msg_id is not None:
222
+ msg_id_to_turn_index[msg_id] = idx
223
+
224
+ for b in tool_use_blocks:
225
+ tc_list = entry.setdefault("toolCalls", [])
226
+ tc_list.append({
227
+ "tool": b.get("name"),
228
+ "input": b.get("input"),
229
+ "output": None,
230
+ })
231
+ tu_id = b.get("id")
232
+ if isinstance(tu_id, str):
233
+ pending_tool_calls[tu_id] = (idx, len(tc_list) - 1)
188
234
  except Exception:
189
235
  pass
190
236
 
191
- # Unanswered tool_use blocks (tool_result never landed) still surface, so
192
- # the recorder sees the call attempt; `output` is null.
193
- for tu_id, pending in pending_calls.items():
194
- tool_calls.append({
195
- "tool": pending["tool"],
196
- "input": pending["input"],
197
- "output": None,
198
- })
199
-
200
237
  envelope = {
201
- "operatorMessage": last_user_text,
202
- "assistantReply": last_assistant_text,
203
- "toolCalls": tool_calls,
204
- "conversationId": conversation_id,
205
- "accountId": account_id,
206
- "occurredAt": occurred_at,
238
+ "turns": turns,
239
+ "conversationId": conversation_id,
240
+ "accountId": account_id,
241
+ "occurredAt": occurred_at,
207
242
  }
208
243
  print(json.dumps(envelope, ensure_ascii=False), end="")
209
244
  PY
210
245
  )
211
246
 
212
- # Skip when the envelope carries no user text, no assistant text, and no
213
- # tool calls same skip surface as the prior `conversation-empty` reason.
247
+ # Skip when turns is empty no user text, no assistant text, no tool_use
248
+ # in the operator JSONL. Same skip surface as the prior `conversation-empty`
249
+ # reason; the meaning is now binary on turns.length === 0.
214
250
  EMPTY_CHECK=$(printf '%s' "$ENVELOPE" | python3 -c '
215
251
  import sys, json
216
252
  try:
217
253
  e = json.load(sys.stdin)
218
- empty = (
219
- not (e.get("operatorMessage") or "")
220
- and not (e.get("assistantReply") or "")
221
- and not (e.get("toolCalls") or [])
222
- )
223
- print("empty" if empty else "ok")
254
+ print("empty" if not (e.get("turns") or []) else "ok")
224
255
  except Exception:
225
256
  print("empty")
226
257
  ')
@@ -235,6 +266,23 @@ if [ "$EMPTY_CHECK" = "empty" ]; then
235
266
  exit 0
236
267
  fi
237
268
 
269
+ # Task 177 observability — one envelope summary line per recorder fire,
270
+ # emitted BEFORE the spawn POST so a future audit reads counts from
271
+ # server.log without re-walking the JSONL. `turnsCount` is monotone
272
+ # across spawns in the same operator session — flat or decreasing is
273
+ # the regression signature for "windowing crept back in".
274
+ ENVELOPE_COUNTS=$(printf '%s' "$ENVELOPE" | python3 -c '
275
+ import sys, json
276
+ e = json.load(sys.stdin)
277
+ turns = e.get("turns") or []
278
+ u = sum(1 for t in turns if t.get("role") == "user")
279
+ a = sum(1 for t in turns if t.get("role") == "assistant")
280
+ tc = sum(1 for t in turns if (t.get("toolCalls") or []))
281
+ print(f"{len(turns)} {u} {a} {tc}")
282
+ ')
283
+ read -r TURNS_COUNT USER_TURNS ASST_TURNS TC_TURNS <<<"$ENVELOPE_COUNTS"
284
+ emit_log "envelope sessionId=${ADMIN_SESSION_ID} turnsCount=${TURNS_COUNT} userTurns=${USER_TURNS} assistantTurns=${ASST_TURNS} toolCallTurns=${TC_TURNS}"
285
+
238
286
  # Compose the Sidebar-shape spawn body. Three overrides relative to a
239
287
  # plain Sidebar "New session" click: specialist, model, initialMessage.
240
288
  # `adminSessionId` is the loopback-bypass key the wrapper uses to resolve
@@ -77,7 +77,7 @@ grep '\[client-acquire\]' ~/.${brand}/logs/claude-agent-stream-*.log
77
77
 
78
78
  The Hono route `POST /api/admin/claude-sessions` at [`platform/ui/server/routes/admin/claude-sessions.ts`](../../../ui/server/routes/admin/claude-sessions.ts) is the single canonical entry point for any caller — UI click handler or programmatic — that needs to open an admin PTY session with a first user prompt. Direct calls to the session-manager's `/spawn` are not allowed; the wrapper owns the per-spawn enrichment (`aboutOwner`, `dormantPlugins`, `activePlugins`, `specialistDomains`, `tunnelUrl`) and the `senderId` resolution from the admin session cookie. The session manager binds to `127.0.0.1` only so the wrapper is the sole authorised caller; any ad-hoc spawn that bypasses it duplicates this contract and is a bug.
79
79
 
80
- **Body schema.** `{channel?, permissionMode?, initialMessage?, specialist?, model?}` for the cookie path (Sidebar — `senderId` resolved from the admin session cookie, `hidden: false`). The Stop-hook loopback caller adds `adminSessionId` and uses three overrides: `specialist: 'database-operator'`, `model: 'haiku'`, plus `initialMessage`. The wrapper rejects nothing structurally on the cookie path — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace). The loopback path refuses with `400 admin-session-id-required` if `adminSessionId` is missing or non-UUID, and `404 admin-session-not-found` if the manager has no StoredSession for it.
80
+ **Body schema.** `{channel?, permissionMode?, initialMessage?, specialist?, model?}` for the cookie path (Sidebar — `senderId` resolved from the admin session cookie, `hidden: false`). `permissionMode` accepts one of five values matching Claude Code's CLI: `'default'`, `'acceptEdits'`, `'plan'`, `'auto'`, `'bypassPermissions'`. The Stop-hook loopback caller adds `adminSessionId` and uses three overrides: `specialist: 'database-operator'`, `model: 'haiku'`, plus `initialMessage`. The wrapper rejects nothing structurally on the cookie path — string fields are coerced or defaulted (`channel` → `'browser'`, `permissionMode` → undefined, `initialMessage` → null if empty/whitespace). The loopback path refuses with `400 admin-session-id-required` if `adminSessionId` is missing or non-UUID, and `404 admin-session-not-found` if the manager has no StoredSession for it.
81
81
 
82
82
  **Auth surfaces.** Two: `cookie` (Sidebar — `requireAdminSession` validates `session_key` and resolves `senderId` from the in-memory session store) and `loopback` (Stop hook — `requireAdminSessionOrLoopback` skips cookie auth when method is `POST`, path is exactly `/api/admin/claude-sessions`, and the TCP peer is `127.0.0.1`/`::1`; the handler then resolves the operator's `senderId` from `GET {managerBase}/<adminSessionId>/meta`). Both the manager and the UI server bind to `127.0.0.1` only for internal traffic, so the trust boundary is reachable only from a process running on the device. Task 147 — one route, one body shape (the loopback variant differs only by overrides), no `/recorder-spawn` sibling, no XFF disambiguation.
83
83
 
@@ -96,20 +96,25 @@ Body overrides relative to a plain Sidebar click:
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 175).** A single JSON object stringified to a string. Top-level keys exactly match the database-operator agent file `## Input shape`:
99
+ **`initialMessage` JSON envelope (Task 177).** A single JSON object stringified to a string. Top-level keys exactly match the database-operator agent file `## Input shape`:
100
100
 
101
101
  ```
102
102
  {
103
- "operatorMessage": "<last role=user text block>",
104
- "assistantReply": "<last role=assistant text block, or '' if none>",
105
- "toolCalls": [ { "tool": "...", "input": {...}, "output": ... }, ... ],
106
- "conversationId": "<operator session_id>",
107
- "accountId": "<ACCOUNT_ID env, stamped on the manager systemd unit>",
108
- "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
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>"
109
112
  }
110
113
  ```
111
114
 
112
- `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`; 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 `operatorMessage`, `assistantReply`, and `toolCalls` are all empty after the walker runs, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned.
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.)
116
+
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".
113
118
 
114
119
  **Manager-side specialist branches.** The recorder is the first specialist subagent that exercises the full specialist flag matrix:
115
120
  - 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.
@@ -122,16 +127,17 @@ Body overrides relative to a plain Sidebar click:
122
127
 
123
128
  **Hook recursion gate.** `pty-spawner` stamps `MAXY_SPECIALIST=<specialist>` on every PTY env. The Stop hook short-circuits when `MAXY_SPECIALIST=database-operator` so the recorder's own end-of-turn does not re-fire the hook.
124
129
 
125
- **Observability.** The hook emits exactly two lines per operator turn via `/api/admin/log-ingest`: `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>` and `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The manager adds `pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter` and `pty-spawn-start … specialist=database-operator append-system-prompt-bytes=0` before the PTY launches. The remainder of the recorder lifecycle is covered by the Sidebar's existing lines (`pty-spawned`, JSONL events, `auto-archive`). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | conversation-empty`. Hook-side spawn errors emit `[turn-recorder] spawn-failed reason=loopback-http http=<code>`.
130
+ **Observability.** The hook emits exactly two lines per operator turn via `/api/admin/log-ingest`: `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>` and `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The manager adds `pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter`, `pty-spawn-start … specialist=database-operator append-system-prompt-bytes=0` before the PTY launches, and one `[pty-spawn-tool-inventory] sessionId=<rec> specialist=database-operator argv-tools=<n> mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>` line per spawn once the shadow probe of each MCP server returns. The remainder of the recorder lifecycle is covered by the Sidebar's existing lines (`pty-spawned`, JSONL events, `auto-archive`). Failure-mode names: `trigger-skipped reason=…` enumerates `role-not-admin | is-recorder | empty-stdin | missing-transcript | conversation-empty`. Hook-side spawn errors emit `[turn-recorder] spawn-failed reason=loopback-http http=<code>`. Probe-side failure on the inventory line emits `[pty-spawn-tool-inventory-failed] sessionId=<rec> specialist=<name> err=<json>`.
126
131
 
127
132
  ### Recorder lifecycle diagnostic
128
133
 
129
- When the operator reports "the recorder did nothing this turn", run these six greps in order. The first absent line names the phase that failed. `<op>` is the admin operator's session id from the original Stop hook; `<rec>` is the recorder session id returned in phase 2's response and reused across phases 3–6.
134
+ When the operator reports "the recorder did nothing this turn", run these seven greps in order. The first absent line names the phase that failed. `<op>` is the admin operator's session id from the original Stop hook; `<rec>` is the recorder session id returned in phase 2's response and reused across phases 3–6.
130
135
 
131
136
  1. **Stop hook fired.** `grep '\[turn-recorder\] trigger sessionId=<op>' ~/.<brand>/logs/server.log` — expects one line of shape `[turn-recorder] trigger sessionId=<op> turnIndex=0 transcriptBytes=<n> conversationBytes=<n>`. Absent: the Stop hook didn't run; check that `MAXY_SESSION_ROLE=admin` was set on the operator PTY and that `MAXY_SPECIALIST!=database-operator` (recursion gate).
132
137
  2. **/spawn accepted.** `grep '\[turn-recorder\] spawn-request sessionId=<op>' ~/.<brand>/logs/server.log` — expects `[turn-recorder] spawn-request sessionId=<op> specialist=database-operator initialMessageBytes=<n>`. The response body of this POST carries `<rec>`. Absent: the hook fired but `/api/admin/claude-sessions` rejected; look for `[turn-recorder] spawn-failed reason=loopback-http http=<code>` on the next line.
133
138
  3. **PTY started.** `grep 'pty-spawn-start .* specialist=database-operator' ~/.<brand>/logs/server.log` — expects `pty-spawn-start claudeBin=<…> argv-count=<n> append-system-prompt-bytes=0 … specialist=database-operator prompt-positional=yes prompt-bytes=<n>`. Absent: the wrapper accepted but the manager rejected before exec; check the spawn-failure surfaces (`which-claude-not-found | pty-spawn-failed | pid-file-timeout | host-context-unresolved | identity-unresolved | mcp-config-write-failed`).
134
139
  4. **Agent file resolved.** `grep 'pty-spawn-allowlist specialist=database-operator count=11 stripped=0 sourced-from=agent-frontmatter' ~/.<brand>/logs/server.log` — expects exactly one line per recorder spawn. Absent (or `count` not equal to the frontmatter's tool count, or `stripped>0` meaning the brand excluded a plugin the recorder template references — Task 173): the agent file at `$CLAUDE_CONFIG_DIR/agents/database-operator.md` is missing the `tools:` line, the symlink the installer creates never landed, or the brand-aware drift filter dropped tools. Re-run the installer; cross-reference `brand.json#plugins.excluded` against the agent frontmatter.
140
+ 4b. **Tool inventory exposed to the model.** `grep '\[pty-spawn-tool-inventory\] sessionId=<rec>' ~/.<brand>/logs/server.log` — expects one line of shape `[pty-spawn-tool-inventory] sessionId=<rec> specialist=database-operator argv-tools=11 mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>`. The line lands once per spawn after the manager's shadow probe finishes a `tools/list` against each MCP server in the per-spawn `mcp.json`. The four fields decode the recorder's runtime tool surface directly: `argv-tools` is the allowlist on the `claude` argv (= phase 4's `count`); `mcp-listed-tools` is the sum across servers of names the probe captured; `exposed` is the intersection (in frontmatter order); `not-exposed` is the allowlist minus `exposed`. The regression query for "model says no tools" is `exposed=$|exposed= ` — when the line ends `exposed= not-exposed=<all 11>`, the allowlist landed on the argv but none of the names came back from any MCP server (cause space: frontmatter format, name canonicalisation, MCP handshake repair). Absent line: probe is fire-and-forget, so absence either means the spawn was not a specialist (operator chat) or the probe itself crashed — in which case `[pty-spawn-tool-inventory-failed] sessionId=<rec> specialist=… err=…` is the partner line to grep. Note: the probe captures what each MCP server offers, not what claude code's own tools/list saw — CLI-side filtering after handshake is a separate observability concern.
135
141
  5. **Graph write outcome.** `grep '\[mcp:memory\] memory-write .* session=<rec>' ~/.<brand>/logs/server.log` — expects one line ending `result=ok elementId=<id>` or `result=error reason=<slug>` (slug enumerated by the memory MCP write-path observability work). Absent: the recorder loaded but produced no tool calls — model-side decision, not infra. Read the recorder JSONL at `<accountDir>/.claude/projects/<slug>/<rec>.jsonl` to see what the LLM did.
136
142
  6. **Auto-archive.** `grep 'auto-archive .* sessionId=<rec> .* specialist=database-operator reason=end-turn' ~/.<brand>/logs/server.log` — expects one line. Absent: the recorder finished but the manager's end-turn watcher didn't fire; the fs-watcher row will get reaped on its TTL but the recorder hung longer than expected. Investigate `pty-spawn-stop` and `pid-file-removed` lines on the same `<rec>`.
137
143
 
@@ -178,6 +184,43 @@ The existing `mcp-memory-stderr-<date>.log` capture is unchanged on error paths
178
184
 
179
185
  **Test contract (Task 168).** Each of the 20 wired tools carries a `*-emit.test.ts` fixture under `platform/plugins/memory/mcp/src/tools/__tests__/` that mocks `fetch` and asserts the wire body. A refactor that drops `withWriteEvent` from any tool fails the matching tool's tests with a clear `expected "vi.fn()" to be called 1 times, but got 0 times` error, naming the tool. A shared helper at `__tests__/_helpers/emit-capture.ts` standardises the fetch capture + Neo4j Session stub so each tool test stays a few lines of mocks + one happy + one error assertion. Drift between the helper's body shape and the route's validator is pinned by an end-to-end test at `platform/ui/server/routes/admin/__tests__/log-ingest-mcp-end-to-end.test.ts`, which routes the helper's POST through the real Hono `log-ingest` route with a loopback `remoteAddress` shim.
180
186
 
187
+ ## Diagnosing missing tools in production (Task 183)
188
+
189
+ When a database-operator spawn fails to call any of its 11 tools, the question is which of two layers dropped them:
190
+
191
+ - **Outcome A — tools missing at the model boundary.** The CLI / SDK exposes fewer tools than the framework's `--allowed-tools` argv listed. The model never sees the missing names, so it cannot call them and writes nothing to the graph.
192
+ - **Outcome B — tools present, model abstains.** The SDK exposes all 11 tools; the model has them but chooses not to call any.
193
+
194
+ Three log-line shapes, all keyed on the same recorder `sessionId`, distinguish the two:
195
+
196
+ ```
197
+ [pty-spawn-tool-inventory] sessionId=<id> specialist=<name> argv-tools=<n> mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>
198
+ [recorder-session-init] sessionId=<id> specialist=<name> toolsExposedCount=<n> toolsExposed=<csv> toolsArgvCount=<n> toolsArgvMissingFromExposed=<csv|—> source=<probe>
199
+ [recorder-turn] sessionId=<id> turnIndex=<n> stopReason=<r> textBytes=<n> textHead=<json-escaped first 240 chars> toolUseNames=<csv|—>
200
+ ```
201
+
202
+ - `[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:
204
+ - `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.
207
+ - `[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
+
209
+ 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:
210
+
211
+ ```bash
212
+ SID=<rec-sessionId>
213
+ grep -E "\[(pty-spawn-tool-inventory|recorder-session-init|recorder-turn|mcp:(memory|contacts|tasks))\] .*sessionId=${SID}" ~/.${brand}/logs/server.log
214
+ ```
215
+
216
+ **Reading the output.**
217
+
218
+ 1. The single `[pty-spawn-tool-inventory]` line proves what the framework provisioned. If `argv-tools < 11`, the bug is upstream of this evidence chain — the agent file or the spawn site.
219
+ 2. The single `[recorder-session-init]` line proves what the model received. If `toolsArgvMissingFromExposed` is non-`—`, the named tools were dropped by the CLI/SDK; that is Outcome A and the fix lives in the `--mcp-config` resolution or the CLI/SDK handshake.
220
+ 3. Each `[recorder-turn]` line records one assistant turn. If `toolUseNames=—` across every turn AND `toolsArgvMissingFromExposed=—`, the model could call the tools and chose not to; `textHead` carries the model's verbatim reasoning and is the next surface to read.
221
+
222
+ **Loopback gate.** The `/api/admin/log-ingest` route is loopback-only on both the socket address and (when present) the `X-Forwarded-For` header. A LAN client whose request reaches the route via a proxy still gets a 403 because XFF tokens are required to be loopback when XFF is set. Without this gate the evidence chain would be worthless — any LAN client could forge `[recorder-session-init]` lines under a spoofed sessionId.
223
+
181
224
  ## Out of scope
182
225
 
183
226
  - Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.
@@ -64,7 +64,7 @@ Every PTY spawn injects an `--append-system-prompt` block composed of these sent
64
64
  | `<attachment-ceiling>` | hard-coded 30 MiB rule | always present |
65
65
  | `<identity>` | `<accountDir>/agents/<role>/IDENTITY.md` | spawn refuses with `identity-unresolved` |
66
66
  | `<soul>` | `<accountDir>/agents/<role>/SOUL.md` | spawn refuses with `identity-unresolved` |
67
- | `<about-owner>` | upstream `loadUserProfile` + `formatProfileSummary` (UI process) | sentinel carries `coverage-unresolved: <reason>`; spawn proceeds |
67
+ | `<about-owner>` | upstream `loadUserProfile` + `formatProfileSummary` (UI process) | sentinel renders the prose body with `NOTHING (operator-data source unavailable: \`<reason>\`)` on line one; spawn proceeds |
68
68
  | `<specialist-domains>` | `<accountDir>/specialists/agents/*.md` frontmatter (UI process) | sentinel omitted when set is empty |
69
69
  | `<plugin-manifest>` | enabled plugins' PLUGIN.md frontmatter + skill SKILL.md frontmatter (UI process) | sentinel omitted when set is empty |
70
70
  | `<dormant-plugins>` | installed-minus-enabled set from `platform/plugins/` and `account.json` | sentinel omitted when set is empty |
@@ -77,7 +77,7 @@ Diagnostic line per spawn (`~/.<brand>/logs/server.log`):
77
77
 
78
78
  The `pty-spawn-start` line additionally carries `hooksResolved=<event1,event2,…>` — the hook event names Claude Code would actually load for that PTY (resolved by walking the same `$CLAUDE_CONFIG_DIR/settings.json` plus the cwd-to-.git path Claude Code itself uses). A Stop hook registered in a settings file outside the loader's scope shows up as a missing event in this list.
79
79
 
80
- Zero `aboutOwnerBytes` on a fresh-account spawn (sparse profile) is a regression: the upstream resolver returned ok:false with a reason, and the operator should see `coverage-unresolved: <reason>` in the agent's first turn. Zero `dormantPluginsBytes` on a Maxy install with `cloudflare` not enabled is likewise a regression. Zero `pluginManifestBytes` on any account with enabled plugins means the upstream walker failed silently.
80
+ Zero `aboutOwnerBytes` on any admin spawn is a regression: the upstream resolver dropped the field entirely. The prose body always carries the unconditional MAXIMISE imperative on line two, so the byte count is positive even on a fresh-account spawn whose line one is `NOTHING`. Zero `dormantPluginsBytes` on a Maxy install with `cloudflare` not enabled is likewise a regression. Zero `pluginManifestBytes` on any account with enabled plugins means the upstream walker failed silently.
81
81
 
82
82
  The manager also runs a boot-time self-test that renders a fixture compose call against synthetic inputs and refuses to start if any sentinel is missing:
83
83
 
@@ -504,6 +504,8 @@ This gate was Task 173. The `brand-excluded` branch closes the recurring crash-r
504
504
 
505
505
  **Recorder auto-archive (lifecycle, not user-initiated).** The session manager's `attachRecorderAutoArchive` ([`platform/services/claude-session-manager/src/http-server.ts:178`](../../../services/claude-session-manager/src/http-server.ts)) wires every spawn whose `senderId === 'turn-recorder'` to a JSONL watcher: as soon as the recorder's JSONL contains `"stop_reason":"end_turn"`, the manager calls `stopSession`, the PTY exits, the PID file is removed, and `fs-watcher.ts:275-297` demotes the row to `state: 'archived'`. This is the lifecycle archive path — the row stays in place, the JSONL stays on disk, no directory move. It is structurally distinct from the user-initiated `POST /api/admin/claude-sessions/:id/archive` route, which actually `mv`s the JSONL between `<slugDir>` and `<slugDir>/archive/`; that path is the operator pruning their visible session list, not the recorder's per-turn cleanup.
506
506
 
507
+ **Resume idempotency and specialist propagation (Task 179).** `POST /resume` accepts an optional `idempotencyKey` on the body; two POSTs sharing the same `${senderId}:${idempotencyKey}` within 5 seconds return the cached payload without spawning a second PTY. The client (`platform/ui/app/lib/session-actions.ts`) generates a fresh `crypto.randomUUID()` per Resume click and forwards it through the proxy. A synchronous `useRef` guard inside `resume()` short-circuits a same-frame re-fire (React StrictMode, rapid double-click) before the network is touched, and `Sidebar.tsx` disables the Resume button while `inFlight === 'resume'`. The resume route also accepts a `specialist` field on the body, validated by the same regex `/^[A-Za-z0-9_-]{1,64}$/` that `/spawn` uses; when absent, the route falls back to `resolveRow(deps.watcher, sessionId).agent` so a `database-operator` resume always lands as a `database-operator` session rather than collapsing to an admin row. Forensic log lines: `resume-dedup senderId=… idempotencyKey=… cacheHit=yes age-ms=<n>` on a deduplicated POST, and `resume-specialist-propagated source=<sid> specialist=<name>` on a resume that carried a non-default specialist.
508
+
507
509
  ## Tool Call Audit Trail
508
510
 
509
511
  Every tool invocation by the admin agent produces a durable `ToolCall` node in the knowledge graph, linked to the `Conversation` that triggered it. This covers all admin agent tool calls — the full history of what the agent did, when, and in what context.
@@ -88,7 +88,7 @@ The sidebar row's displayed name is `tail.aiTitle` verbatim, parsed by `jsonl-en
88
88
 
89
89
  **Stop vs. delete.** `POST /<id>/stop` sends SIGTERM, leaves the JSONL on disk for audit, and is idempotent against an already-dead row. `DELETE /<id>` removes the JSONL + per-session subdir and returns 409 if the PTY is still alive (stop first). Any unknown id returns 404; nothing returns a silent 204 against an id the manager does not know.
90
90
 
91
- **stopSession fd contract (Task 170).** Before `/stop` returns, the manager explicitly releases the pty master file descriptor by calling `pty.destroy()` on node-pty's `UnixTerminal` the only path that synchronously closes the internal tty socket. Without the explicit call, the master fd waits on V8 GC finalising the IPty object, which is non-deterministic and accumulates under heavy operator-stop traffic until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. The release is verified by the `stop-session-fd-release` integration test, and every `kill reason=operator-request` log line now ends in `master-fd=closed` (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful stop into a 500).
91
+ **PTY lifecycle contract (Tasks 170 + 176).** A PTY reaches its end via one of two branches: **operator-request** (operator clicks End or the auto-archive Stop hook calls `stopSession`) or **natural-exit** (the claude child exits on its own — operator typed `/quit`, SIGINT in the PTY, crash, network drop on `--remote-control`). Both branches honour a single invariant: the pty master file descriptor is released by an explicit `pty.destroy()` and the SessionStore row is removed before the next `/list` or `/events` tick. Without the explicit destroy, the master fd lingers in node-pty's internal socket until V8 GC finalises the IPty object non-deterministic and accumulates under load until the kernel pty cap (Linux 3072, macOS 511) refuses new spawns. Without the explicit row removal, the manager shutdown loop SIGTERMs PIDs that already logged `process-exited`, masking the leak only because the manager restarts every few hours. When both branches fire on the same exit (operator clicks End and node-pty's `onExit` fans out the SIGTERM to both listeners), a per-row `fdReleased` flag short-circuits the second branch so `pty.destroy()` runs exactly once on the live socket — without the flag, the second call throws "socket already destroyed" and the operator-request line would falsely log `master-fd=close-failed`. If the first branch's destroy throws and is rescued, the flag stays unset and the second branch retries (defense in depth). Every `kill … pid=<n>` log line carries a `master-fd=closed` suffix (or `master-fd=close-failed err=<msg>` on the rescued throw branch — a graceful degradation so a corner-case socket-state failure cannot turn a logically-successful exit into a 500); the operator-request line additionally identifies `reason=operator-request`, the natural-exit line identifies `reason=process-exited`. Both branches are verified by the `stop-session-fd-release` and `endpoint-stop-delete` integration tests (operator-request live and already-exited cycles + natural-exit cycle + throw-then-retry coordination, Linux kernel-level ptmx fd accounting on each).
92
92
 
93
93
  The metadata pane subscribes to the same /list projection. When an operator clicks End on an alive row, the DELETE returns 200 and the post-mutation refetch decides what happens next: a session that wrote a JSONL surfaces as a dehydrated `status: 'ended'` row (the pane swaps `End session` for `Purge JSONL` plus `Resume`), and a session that never wrote a JSONL (`Turns: 0`) leaves the list entirely (the pane shows a `Session ended without a transcript. Close this pane.` banner with a Close button and no destructive action). The manager's `/list` and `/meta` are the only authorities on post-End state; the client does not pre-empt either response with an optimistic mutation.
94
94
 
@@ -12,8 +12,9 @@
12
12
  *
13
13
  * Write contract (covered by load-user-profile-sparse.test.ts via
14
14
  * loadUserProfile + formatProfileSummary integration):
15
- * - notApplicable rows count as covered for Coverage purposes.
16
- * - notApplicable rows are filtered from the user-facing body.
15
+ * - notApplicable rows count as covered for observability (sparse-state
16
+ * log only the body never enumerates absent fields).
17
+ * - notApplicable rows are filtered from the user-facing prose body.
17
18
  *
18
19
  * Validation throws BEFORE any session.run / embed call, so these tests
19
20
  * don't need to mock neo4j-driver or the embeddings module — the throw
@@ -1 +1 @@
1
- {"version":3,"file":"profile-update-not-applicable.test.js","sourceRoot":"","sources":["../../../src/tools/__tests__/profile-update-not-applicable.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAErD,MAAM,IAAI,GAAG,sCAAsC,CAAC;AACpD,MAAM,IAAI,GAAG,qBAAqB,CAAC;AAEnC,QAAQ,CAAC,2CAA2C,EAAE,GAAG,EAAE;IACzD,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,aAAa;YACpB,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,IAAI,EAAE,OAAO;YACb,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,IAAI,EAAE,YAAY;YAClB,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6EAA6E,EAAE,KAAK,IAAI,EAAE;QAC3F,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;SACV,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;QAEvC,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,KAAK;YACZ,aAAa,EAAE,KAAK;SACrB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;QACzE,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,gBAAgB;YAC1B,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"profile-update-not-applicable.test.js","sourceRoot":"","sources":["../../../src/tools/__tests__/profile-update-not-applicable.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAErD,MAAM,IAAI,GAAG,sCAAsC,CAAC;AACpD,MAAM,IAAI,GAAG,qBAAqB,CAAC;AAEnC,QAAQ,CAAC,2CAA2C,EAAE,GAAG,EAAE;IACzD,EAAE,CAAC,sEAAsE,EAAE,KAAK,IAAI,EAAE;QACpF,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,aAAa;YACpB,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC1C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,IAAI,EAAE,OAAO;YACb,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,IAAI,EAAE,YAAY;YAClB,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,2BAA2B,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6EAA6E,EAAE,KAAK,IAAI,EAAE;QAC3F,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;SACV,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;QAEvC,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,eAAe;YACzB,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,KAAK;YACZ,aAAa,EAAE,KAAK;SACrB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;QACzE,MAAM,MAAM,CACV,aAAa,CAAC;YACZ,SAAS,EAAE,IAAI;YACf,MAAM,EAAE,IAAI;YACZ,QAAQ,EAAE,gBAAgB;YAC1B,GAAG,EAAE,kBAAkB;YACvB,KAAK,EAAE,EAAE;YACT,aAAa,EAAE,IAAI;SACpB,CAAC,CACH,CAAC,OAAO,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"profile-read.d.ts","sourceRoot":"","sources":["../../src/tools/profile-read.ts"],"names":[],"mappings":"AAcA,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IAInB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,UAAU,cAAc;IACtB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,wBAAyB,SAAQ,gBAAgB;IACzD,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,UAAU,iBAAiB;IACzB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,uBAAuB;IAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,wBAAwB,EAAE,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE;QACL,gBAAgB,EAAE,MAAM,CAAC;QACzB,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,EAAE,MAAM,CAAC;QACzB,cAAc,EAAE,MAAM,CAAC;QACvB,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED,wBAAsB,WAAW,CAC/B,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,iBAAiB,GAAG,uBAAuB,CAAC,CAmHtD;AA2CD,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAyC5D,CAAC;AA0CX,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAcjE;AASD,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAyC1B;AAID,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,WAAW,EAAE,gBAAgB,EAAE,EAC/B,cAAc,GAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAM,GACrD,MAAM,CA+DR"}
1
+ {"version":3,"file":"profile-read.d.ts","sourceRoot":"","sources":["../../src/tools/profile-read.ts"],"names":[],"mappings":"AAcA,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,UAAU,gBAAgB;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IAInB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,UAAU,cAAc;IACtB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,wBAAyB,SAAQ,gBAAgB;IACzD,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B;AAED,UAAU,iBAAiB;IACzB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,uBAAuB;IAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,WAAW,EAAE,wBAAwB,EAAE,CAAC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE;QACL,gBAAgB,EAAE,MAAM,CAAC;QACzB,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,EAAE,MAAM,CAAC;QACzB,cAAc,EAAE,MAAM,CAAC;QACvB,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAED,wBAAsB,WAAW,CAC/B,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,iBAAiB,GAAG,uBAAuB,CAAC,CAmHtD;AA4CD,eAAO,MAAM,uBAAuB,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAyC5D,CAAC;AA0CX,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAcjE;AASD,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAyC1B;AAQD,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,WAAW,EAAE,gBAAgB,EAAE,EAE/B,cAAc,GAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAM,GACrD,MAAM,CAMR"}