@rubytech/create-maxy-code 0.1.64 → 0.1.66

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 (101) 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 +140 -61
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +135 -49
  5. package/payload/platform/plugins/docs/references/admin-session.md +19 -4
  6. package/payload/platform/plugins/docs/references/internals.md +7 -1
  7. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.d.ts +22 -0
  8. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.d.ts.map +1 -0
  9. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.js +44 -0
  10. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.js.map +1 -0
  11. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.d.ts +2 -0
  12. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.d.ts.map +1 -0
  13. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.js +68 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.js.map +1 -0
  15. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.d.ts +2 -0
  16. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.d.ts.map +1 -0
  17. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.js +48 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.js.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.d.ts +2 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.d.ts.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.js +53 -0
  22. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.js.map +1 -0
  23. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.d.ts +2 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.d.ts.map +1 -0
  25. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.js +40 -0
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.js.map +1 -0
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.d.ts +2 -0
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.d.ts.map +1 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.js +39 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.js.map +1 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.d.ts +2 -0
  32. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.d.ts.map +1 -0
  33. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.js +88 -0
  34. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.js.map +1 -0
  35. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.d.ts +2 -0
  36. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.js +58 -0
  38. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.js.map +1 -0
  39. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.d.ts +2 -0
  40. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.js +58 -0
  42. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.js.map +1 -0
  43. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.d.ts +2 -0
  44. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.js +70 -0
  46. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.js.map +1 -0
  47. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.d.ts +2 -0
  48. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.d.ts.map +1 -0
  49. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.js +48 -0
  50. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.js.map +1 -0
  51. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.d.ts +2 -0
  52. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.d.ts.map +1 -0
  53. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.js +74 -0
  54. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.js.map +1 -0
  55. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.d.ts +2 -0
  56. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.d.ts.map +1 -0
  57. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.js +47 -0
  58. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.js.map +1 -0
  59. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.d.ts +2 -0
  60. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.d.ts.map +1 -0
  61. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.js +65 -0
  62. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.js.map +1 -0
  63. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.d.ts +2 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.d.ts.map +1 -0
  65. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.js +43 -0
  66. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.js.map +1 -0
  67. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.d.ts +2 -0
  68. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.d.ts.map +1 -0
  69. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.js +65 -0
  70. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.js.map +1 -0
  71. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.d.ts +2 -0
  72. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.d.ts.map +1 -0
  73. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.js +49 -0
  74. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.js.map +1 -0
  75. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.d.ts +2 -0
  76. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.d.ts.map +1 -0
  77. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +74 -0
  78. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -0
  79. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.d.ts +2 -0
  80. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.d.ts.map +1 -0
  81. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +74 -0
  82. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -0
  83. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.d.ts +2 -0
  84. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.d.ts.map +1 -0
  85. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.js +62 -0
  86. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.js.map +1 -0
  87. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.d.ts +2 -0
  88. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.d.ts.map +1 -0
  89. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.js +76 -0
  90. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.js.map +1 -0
  91. package/payload/platform/services/claude-session-manager/dist/index.js +75 -2
  92. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  93. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +8 -0
  94. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  95. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +14 -1
  96. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  97. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +24 -4
  98. package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
  99. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +39 -9
  100. package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
  101. package/payload/server/server-init.cjs +40 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -129,7 +129,7 @@ Tools are available via the `admin` MCP server.
129
129
  - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
130
130
  - `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
131
131
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
132
- - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_IS_RECORDER!=1` so it never recurses into the recorder PTY or fires on public sessions. **Task 134 redesign** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', initialMessage:'<instruction>\n\n<last-turn-text>', originatingSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<originatingSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 153** — `initialMessage` rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line equals `"update the graph with any new or missing information or intent\n\n<last-turn-text>"` verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|last-turn-empty>` line. The spawn body carries `originatingSessionId` as a structural top-level field so downstream loggers read the operator id without parsing prose. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
132
+ - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 175** — `initialMessage` is now a JSON-stringified envelope conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `operatorMessage`, `assistantReply`, `toolCalls`, `conversationId`, `accountId`, `occurredAt`. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
133
133
 
134
134
  ## Session identifiers (Task 135)
135
135
 
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env bash
2
- # Task 129/131 regression test for the Stop hook.
2
+ # Task 129/131/165/175 regression test for the Stop hook.
3
3
  #
4
4
  # Contract:
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
8
8
  # distinct `reason=` (role-not-admin | is-recorder | empty-stdin |
9
- # missing-transcript | last-turn-empty).
9
+ # missing-transcript | conversation-empty).
10
10
  # - Happy path emits exactly ONE `trigger` line carrying
11
- # `lastTurnBytes=<n>` (Task 131 replaces `filteredRecords`) and
12
- # exactly ONE `recorder-spawn` POST. The body carries the operator
13
- # session id as `originatingSessionId` and an `initialMessage` of
14
- # the form `<instruction>\n\n<last-turn-text>` no transcript tail,
15
- # no `Conversation id:` prose.
11
+ # `conversationBytes=<n>` and exactly ONE POST to
12
+ # `/api/admin/claude-sessions`. The body carries the operator
13
+ # session id as `adminSessionId` and an `initialMessage` whose value
14
+ # is a JSON-stringified object with EXACTLY the six keys declared by
15
+ # the database-operator agent file: operatorMessage, assistantReply,
16
+ # toolCalls, conversationId, accountId, occurredAt. No leading
17
+ # instruction prose. No `Conversation id:` prose. No
18
+ # `user:`/`assistant:` walk format.
16
19
  # - The hook never writes the legacy `spawn-with-input` / `fired` stderr
17
20
  # lines.
18
21
  #
@@ -30,6 +33,7 @@ if [[ ! -x "$HOOK" ]]; then
30
33
  fi
31
34
 
32
35
  OP_ID='aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'
36
+ ACCT_ID='acct-test-0001'
33
37
 
34
38
  TMPFILES=()
35
39
  LISTENER_PIDS=()
@@ -92,6 +96,7 @@ run_hook() {
92
96
  MAXY_SESSION_ROLE="$role" \
93
97
  MAXY_SPECIALIST="$specialist" \
94
98
  MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
99
+ ACCOUNT_ID="$ACCT_ID" \
95
100
  bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
96
101
  HOOK_RC=$?
97
102
  HOOK_STDERR=$(cat "$stderr_file")
@@ -116,8 +121,7 @@ for raw in sys.stdin:
116
121
 
117
122
  # Common envelope: a valid Stop-hook stdin shape with a real UUID + a
118
123
  # transcript file the hook can read. Transcript has one user turn and
119
- # one assistant turn — Task 131 picks the most-recent assistant text as
120
- # the last-turn payload.
124
+ # one assistant turn.
121
125
  start_listener
122
126
  TRANSCRIPT=$(mktemp); TMPFILES+=("$TRANSCRIPT")
123
127
  {
@@ -184,8 +188,7 @@ run_hook "admin" "" "$ENVELOPE"
184
188
  [[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty, got: $HOOK_STDERR"
185
189
 
186
190
  # 5a. exactly ONE `trigger` log-ingest line carrying the operator session id
187
- # and `conversationBytes=<n>` (Task 165 — full transcript walker
188
- # replaces last-turn-only extractor; field is renamed for clarity).
191
+ # and `conversationBytes=<n>`.
189
192
  TRIGGER_COUNT=$(ingest_lines | grep -cE "^trigger sessionId=${OP_ID} turnIndex=0 transcriptBytes=[0-9]+ conversationBytes=[0-9]+$" || true)
190
193
  if [[ "$TRIGGER_COUNT" -eq 1 ]]; then
191
194
  pass "case-5a exactly one trigger line emitted via log-ingest carrying operator id + conversationBytes"
@@ -201,40 +204,50 @@ else
201
204
  fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
202
205
  fi
203
206
 
204
- # 5c. Sidebar-shape body with three overrides (specialist, model,
205
- # initialMessage); adminSessionId carries the operator's session id as
206
- # the loopback-bypass lookup key. No senderId on the wire (the wrapper
207
- # resolves it from the manager's /meta).
207
+ # 5c. Task 175 `initialMessage` is a JSON-stringified object with
208
+ # EXACTLY the six declared top-level keys. No instruction prose.
209
+ # `operatorMessage` / `assistantReply` carry the last user / assistant
210
+ # text blocks. `toolCalls` is `[]` (no tool_use in this transcript).
208
211
  RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
209
212
  BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
210
- import sys, json
213
+ import sys, json, re
211
214
  op = sys.argv[1]
212
- INSTRUCTION = "update the graph with any new or missing information or intent"
213
- # Task 165 — initialMessage carries the full transcript layout
214
- # "user: <text>\nassistant: <text>\n…", not just the last assistant text.
215
- EXPECTED_CONVERSATION = "user: hello operator\nassistant: hello, assistant reply"
215
+ acct = sys.argv[2]
216
216
  try:
217
- b = json.load(sys.stdin)
218
- msg = b.get("initialMessage") or ""
217
+ outer = json.load(sys.stdin)
218
+ msg_str = outer.get("initialMessage")
219
+ # initialMessage must be a string carrying a JSON-stringified object.
220
+ if not isinstance(msg_str, str):
221
+ print("no:initialMessage-not-string:"+repr(type(msg_str).__name__))
222
+ sys.exit(0)
223
+ msg = json.loads(msg_str)
224
+ keys = set(msg.keys())
219
225
  conds = (
220
- "senderId" not in b,
221
- b.get("specialist") == "database-operator",
222
- b.get("model") == "haiku",
223
- b.get("adminSessionId") == op,
224
- b.get("channel") == "browser",
225
- msg == INSTRUCTION + "\n\n" + EXPECTED_CONVERSATION,
226
- "Conversation id:" not in msg,
227
- "Conversation transcript:" not in msg,
228
- "originatingSessionId" not in b,
226
+ keys == {"operatorMessage", "assistantReply", "toolCalls", "conversationId", "accountId", "occurredAt"},
227
+ msg.get("operatorMessage") == "hello operator",
228
+ msg.get("assistantReply") == "hello, assistant reply",
229
+ msg.get("toolCalls") == [],
230
+ msg.get("conversationId") == op,
231
+ msg.get("accountId") == acct,
232
+ bool(re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$", msg.get("occurredAt") or "")),
233
+ "update the graph" not in msg_str,
234
+ "user:" not in msg_str.lower() or "user:" not in (msg.get("operatorMessage") or "").lower(),
235
+ "Conversation id:" not in msg_str,
236
+ outer.get("specialist") == "database-operator",
237
+ outer.get("model") == "haiku",
238
+ outer.get("adminSessionId") == op,
239
+ outer.get("channel") == "browser",
240
+ "senderId" not in outer,
241
+ "originatingSessionId" not in outer,
229
242
  )
230
- print("yes" if all(conds) else "no:"+repr(conds)+"|msg="+repr(msg))
243
+ print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|msg="+repr(msg))
231
244
  except Exception as e:
232
245
  print("parse-fail:"+str(e))
233
- ' "$OP_ID" 2>/dev/null)
246
+ ' "$OP_ID" "$ACCT_ID" 2>/dev/null)
234
247
  if [[ "$BODY_OK" == "yes" ]]; then
235
- pass "case-5c body carries full transcript layout (user: …\\nassistant: …) — Task 165"
248
+ pass "case-5c initialMessage is JSON envelope with exactly 6 declared keys"
236
249
  else
237
- fail "case-5c body shape wrong ($BODY_OK): $RECORDER_BODY"
250
+ fail "case-5c envelope shape wrong ($BODY_OK): $RECORDER_BODY"
238
251
  fi
239
252
 
240
253
  # 5d. exactly ONE `spawn-request` log-ingest line
@@ -252,10 +265,9 @@ else
252
265
  pass "case-5e no legacy stderr emissions"
253
266
  fi
254
267
 
255
- # --- Case 6 (Task 165): transcript has zero qualifying records → --------
256
- # trigger-skipped reason=conversation-empty. The hook emits the
257
- # trigger line first (conversationBytes=0) and then the new skip
258
- # reason.
268
+ # --- Case 6 (Task 175): conversation-empty skip when there's no user/
269
+ # assistant text AND no tool_use. The hook emits the trigger line
270
+ # first (conversationBytes=0) and then the skip reason.
259
271
  EMPTY_TRANSCRIPT=$(mktemp); TMPFILES+=("$EMPTY_TRANSCRIPT")
260
272
  printf '{"type":"system","summary":"noop"}\n' > "$EMPTY_TRANSCRIPT"
261
273
  EMPTY_ENVELOPE=$(python3 -c '
@@ -266,7 +278,7 @@ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
266
278
  run_hook "admin" "" "$EMPTY_ENVELOPE"
267
279
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC"
268
280
  if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=conversation-empty$"; then
269
- pass "case-6 zero user/assistant records → trigger-skipped reason=conversation-empty emitted"
281
+ pass "case-6 zero user/assistant/tool records → trigger-skipped reason=conversation-empty emitted"
270
282
  else
271
283
  fail "case-6 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
272
284
  fi
@@ -275,37 +287,104 @@ if grep -qE '^/api/admin/claude-sessions ' "$REQ_LOG"; then
275
287
  fail "case-6 recorder-spawn must NOT be called when conversation-empty"
276
288
  fi
277
289
 
278
- # --- Case 7 (Task 165): three-turn conversation walks user/assistant/user
279
- # in transcript order; layout is "user: …\nassistant: …\nuser: …". ---
280
- THREE_TURN_TRANSCRIPT=$(mktemp); TMPFILES+=("$THREE_TURN_TRANSCRIPT")
281
- {
282
- printf '{"type":"user","message":{"role":"user","content":"hello"}}\n'
283
- printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"what'"'"'s your business?"}]}}\n'
284
- printf '{"type":"user","message":{"role":"user","content":"Smalleys"}}\n'
285
- } > "$THREE_TURN_TRANSCRIPT"
286
- THREE_ENVELOPE=$(python3 -c '
290
+ # --- Case 7 (Task 175): tool-call passthrough tool_use + tool_result
291
+ # pair surfaces in `toolCalls[]` with `input` and `output` as native
292
+ # JSON values, never re-stringified.
293
+ TOOL_TRANSCRIPT=$(mktemp); TMPFILES+=("$TOOL_TRANSCRIPT")
294
+ python3 - "$TOOL_TRANSCRIPT" <<'PY'
295
+ import json, sys
296
+ out = sys.argv[1]
297
+ recs = [
298
+ {"type":"user","message":{"role":"user","content":"please write a node"}},
299
+ {"type":"assistant","message":{"role":"assistant","content":[
300
+ {"type":"tool_use","id":"toolu_001","name":"mcp__memory__memory-write","input":{"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}}
301
+ ]}},
302
+ {"type":"user","message":{"role":"user","content":[
303
+ {"type":"tool_result","tool_use_id":"toolu_001","content":"ok: wrote LocalBusiness"}
304
+ ]}},
305
+ {"type":"assistant","message":{"role":"assistant","content":[
306
+ {"type":"text","text":"done"}
307
+ ]}},
308
+ ]
309
+ with open(out,"w") as f:
310
+ for r in recs: f.write(json.dumps(r)+"\n")
311
+ PY
312
+ TOOL_ENVELOPE=$(python3 -c '
287
313
  import json, sys
288
314
  print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
289
- ' "$OP_ID" "$THREE_TURN_TRANSCRIPT")
315
+ ' "$OP_ID" "$TOOL_TRANSCRIPT")
290
316
  : > "$REQ_LOG"
291
- run_hook "admin" "" "$THREE_ENVELOPE"
317
+ run_hook "admin" "" "$TOOL_ENVELOPE"
292
318
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-7 rc=$HOOK_RC stderr=$HOOK_STDERR"
293
- THREE_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
294
- THREE_OK=$(printf '%s' "$THREE_BODY" | python3 -c '
319
+ TOOL_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
320
+ TOOL_OK=$(printf '%s' "$TOOL_BODY" | python3 -c '
295
321
  import sys, json
296
- INSTRUCTION = "update the graph with any new or missing information or intent"
297
- EXPECTED = "user: hello\nassistant: what'"'"'s your business?\nuser: Smalleys"
298
322
  try:
299
- b = json.load(sys.stdin)
300
- msg = b.get("initialMessage") or ""
301
- print("yes" if msg == INSTRUCTION + "\n\n" + EXPECTED else "no:"+repr(msg))
323
+ outer = json.load(sys.stdin)
324
+ msg = json.loads(outer.get("initialMessage"))
325
+ calls = msg.get("toolCalls") or []
326
+ if len(calls) != 1:
327
+ print("no:expected-1-call:"+repr(calls)); sys.exit(0)
328
+ c = calls[0]
329
+ conds = (
330
+ c.get("tool") == "mcp__memory__memory-write",
331
+ c.get("input") == {"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}},
332
+ c.get("output") == "ok: wrote LocalBusiness",
333
+ msg.get("operatorMessage") == "please write a node",
334
+ msg.get("assistantReply") == "done",
335
+ )
336
+ print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|call="+repr(c))
337
+ except Exception as e:
338
+ print("parse-fail:"+str(e))
339
+ ' 2>/dev/null)
340
+ if [[ "$TOOL_OK" == "yes" ]]; then
341
+ pass "case-7 toolCalls passthrough: input/output are JSON values, not strings"
342
+ else
343
+ fail "case-7 toolCalls shape wrong ($TOOL_OK): $TOOL_BODY"
344
+ fi
345
+
346
+ # --- Case 8 (Task 175): escaping — quotes, backslashes, newlines, non-ASCII
347
+ # all round-trip cleanly through the JSON envelope.
348
+ ESC_TRANSCRIPT=$(mktemp); TMPFILES+=("$ESC_TRANSCRIPT")
349
+ python3 - "$ESC_TRANSCRIPT" <<'PY'
350
+ import json, sys
351
+ out = sys.argv[1]
352
+ op_msg = 'hello "world"\\nline2\nlast — café 🦊'
353
+ asst_msg = 'reply with backslash \\\\ and newline\nhere'
354
+ recs = [
355
+ {"type":"user","message":{"role":"user","content":op_msg}},
356
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":asst_msg}]}},
357
+ ]
358
+ with open(out,"w") as f:
359
+ for r in recs: f.write(json.dumps(r)+"\n")
360
+ PY
361
+ ESC_ENVELOPE=$(python3 -c '
362
+ import json, sys
363
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
364
+ ' "$OP_ID" "$ESC_TRANSCRIPT")
365
+ : > "$REQ_LOG"
366
+ run_hook "admin" "" "$ESC_ENVELOPE"
367
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-8 rc=$HOOK_RC stderr=$HOOK_STDERR"
368
+ ESC_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
369
+ ESC_OK=$(printf '%s' "$ESC_BODY" | python3 -c '
370
+ import sys, json
371
+ EXPECTED_OP = "hello \"world\"\\nline2\nlast — café \U0001F98A"
372
+ EXPECTED_ASST = "reply with backslash \\\\ and newline\nhere"
373
+ try:
374
+ outer = json.load(sys.stdin)
375
+ msg = json.loads(outer.get("initialMessage"))
376
+ conds = (
377
+ msg.get("operatorMessage") == EXPECTED_OP,
378
+ msg.get("assistantReply") == EXPECTED_ASST,
379
+ )
380
+ print("yes" if all(conds) else "no:"+repr([(i,c) for i,c in enumerate(conds) if not c])+"|op="+repr(msg.get("operatorMessage"))+"|asst="+repr(msg.get("assistantReply")))
302
381
  except Exception as e:
303
382
  print("parse-fail:"+str(e))
304
383
  ' 2>/dev/null)
305
- if [[ "$THREE_OK" == "yes" ]]; then
306
- pass "case-7 three-turn conversation walks user/assistant/user in order"
384
+ if [[ "$ESC_OK" == "yes" ]]; then
385
+ pass "case-8 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly"
307
386
  else
308
- fail "case-7 expected multi-turn layout, got: $THREE_OK | body: $THREE_BODY"
387
+ fail "case-8 escaping wrong ($ESC_OK): $ESC_BODY"
309
388
  fi
310
389
 
311
390
  # --- Summary ------------------------------------------------------------
@@ -1,21 +1,26 @@
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 turn
4
- # text. The recorder is the only writer to the Neo4j graph; the admin agent
5
- # stays focused on the operator's request.
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.
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: "update the graph with any new or missing information
12
- # or intent\n\n<last-turn-text>"
11
+ # - initialMessage: JSON-stringified envelope (Task 175)
12
+ #
13
13
  # It POSTs to the SAME route the Sidebar uses
14
14
  # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
15
15
  # request without a cookie, resolves the operator's `senderId` from the
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.
23
+ #
19
24
  # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
20
25
  # stderr stays silent on the success path):
21
26
  # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
@@ -23,8 +28,8 @@
23
28
  # "database-operator" (recursion guard)
24
29
  # - Stop-hook stdin must be non-empty → reason=empty-stdin
25
30
  # - transcript_path must exist on disk → reason=missing-transcript
26
- # - at least one user or assistant record with → reason=conversation-empty
27
- # non-empty text must exist in the JSONL (Task 165)
31
+ # - operatorMessage, assistantReply, toolCalls → reason=conversation-empty
32
+ # all empty after the walker runs
28
33
  #
29
34
  # Input: Claude Code's Stop hook stdin shape
30
35
  # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
@@ -91,58 +96,141 @@ if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIP
91
96
  exit 0
92
97
  fi
93
98
 
94
- # Task 165walk every user/assistant record in transcript order; emit
95
- # "<type>: <text>" per record; join with "\n". The recorder needs the
96
- # whole exchange to infer operator-asserted facts that span multiple
97
- # turns (e.g. "hello / what's your business? / Smalleys"). The earlier
98
- # "last-turn-only" extractor stripped the framing the recorder needed.
99
- CONVERSATION_TEXT=$(python3 - "$TRANSCRIPT_PATH" <<'PY'
99
+ # Task 175build the JSON envelope the database-operator agent file
100
+ # declares as its stdin contract. One Python pass walks the transcript
101
+ # and emits:
102
+ # {
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>
109
+ # }
110
+ # `python3 json.dumps` handles every escape so the hook never has to
111
+ # concatenate strings into JSON by hand.
112
+ OCCURRED_AT=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
113
+ ACCOUNT_ID_ENV="${ACCOUNT_ID:-}"
114
+
115
+ ENVELOPE=$(python3 - "$TRANSCRIPT_PATH" "$ADMIN_SESSION_ID" "$ACCOUNT_ID_ENV" "$OCCURRED_AT" <<'PY'
100
116
  import sys, json
101
117
 
102
- def block_text(block):
103
- if not isinstance(block, dict): return ""
104
- if block.get("type") != "text": return ""
105
- t = block.get("text")
106
- return t if isinstance(t, str) else ""
107
-
108
- def record_text(rec):
109
- if not isinstance(rec, dict): return ""
110
- msg = rec.get("message")
111
- if not isinstance(msg, dict): return ""
112
- content = msg.get("content")
113
- if isinstance(content, str): return content
114
- if isinstance(content, list):
115
- parts = [block_text(b) for b in content]
116
- return "\n".join(p for p in parts if p)
117
- return ""
118
-
119
- path = sys.argv[1]
120
- turns = []
118
+ def last_text(content):
119
+ """Return the last `text` block string in a content list, or '' if none."""
120
+ if isinstance(content, str):
121
+ return content
122
+ if not isinstance(content, list):
123
+ return ""
124
+ last = ""
125
+ for b in content:
126
+ if isinstance(b, dict) and b.get("type") == "text":
127
+ t = b.get("text")
128
+ if isinstance(t, str):
129
+ last = t
130
+ return last
131
+
132
+ path, conversation_id, account_id, occurred_at = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
133
+
134
+ last_user_text = ""
135
+ last_assistant_text = ""
136
+ pending_calls = {} # tool_use_id -> {tool, input}
137
+ tool_calls = [] # ordered as tool_results arrive
138
+
121
139
  try:
122
140
  with open(path, "r", encoding="utf-8") as f:
123
141
  for line in f:
124
142
  line = line.strip()
125
- if not line: continue
126
- try: rec = json.loads(line)
127
- except Exception: continue
128
- t = rec.get("type") if isinstance(rec, dict) else None
129
- if t not in ("user", "assistant"): continue
130
- tx = record_text(rec).strip()
131
- if not tx: continue
132
- turns.append(f"{t}: {tx}")
143
+ if not line:
144
+ continue
145
+ try:
146
+ rec = json.loads(line)
147
+ except Exception:
148
+ continue
149
+ if not isinstance(rec, dict):
150
+ continue
151
+ t = rec.get("type")
152
+ msg = rec.get("message")
153
+ if not isinstance(msg, dict):
154
+ continue
155
+ content = msg.get("content")
156
+ if t == "user":
157
+ text = last_text(content)
158
+ if text:
159
+ last_user_text = text
160
+ if isinstance(content, list):
161
+ for b in content:
162
+ if isinstance(b, dict) and b.get("type") == "tool_result":
163
+ tu_id = b.get("tool_use_id")
164
+ if not isinstance(tu_id, str):
165
+ continue
166
+ pending = pending_calls.pop(tu_id, None)
167
+ if pending is None:
168
+ 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)
176
+ if text:
177
+ last_assistant_text = text
178
+ if isinstance(content, list):
179
+ for b in content:
180
+ 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
+ }
133
188
  except Exception:
134
189
  pass
135
190
 
136
- print("\n".join(turns), end="")
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
+ 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,
207
+ }
208
+ print(json.dumps(envelope, ensure_ascii=False), end="")
137
209
  PY
138
210
  )
139
211
 
140
- CONVERSATION_BYTES=$(printf '%s' "$CONVERSATION_TEXT" | wc -c | tr -d ' ')
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.
214
+ EMPTY_CHECK=$(printf '%s' "$ENVELOPE" | python3 -c '
215
+ import sys, json
216
+ try:
217
+ 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")
224
+ except Exception:
225
+ print("empty")
226
+ ')
227
+
228
+ CONVERSATION_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
141
229
  TRANSCRIPT_BYTES=$(wc -c <"$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ' || echo 0)
142
230
 
143
231
  emit_log "trigger sessionId=${ADMIN_SESSION_ID} turnIndex=0 transcriptBytes=${TRANSCRIPT_BYTES} conversationBytes=${CONVERSATION_BYTES}"
144
232
 
145
- if [ -z "$CONVERSATION_TEXT" ]; then
233
+ if [ "$EMPTY_CHECK" = "empty" ]; then
146
234
  emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID} reason=conversation-empty"
147
235
  exit 0
148
236
  fi
@@ -154,20 +242,18 @@ fi
154
242
  SPAWN_BODY=$(python3 -c '
155
243
  import sys, json
156
244
  sid = sys.argv[1]
157
- conversation = sys.argv[2]
158
- instruction = "update the graph with any new or missing information or intent"
159
- text = instruction + "\n\n" + conversation
245
+ envelope_str = sys.argv[2]
160
246
  body = {
161
247
  "adminSessionId": sid,
162
248
  "channel": "browser",
163
249
  "specialist": "database-operator",
164
250
  "model": "haiku",
165
- "initialMessage": text,
251
+ "initialMessage": envelope_str,
166
252
  }
167
253
  print(json.dumps(body))
168
- ' "$ADMIN_SESSION_ID" "$CONVERSATION_TEXT" 2>/dev/null)
254
+ ' "$ADMIN_SESSION_ID" "$ENVELOPE" 2>/dev/null)
169
255
 
170
- INITIAL_BYTES=$(printf '%s\n\n%s' "update the graph with any new or missing information or intent" "$CONVERSATION_TEXT" | wc -c | tr -d ' ')
256
+ INITIAL_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
171
257
  emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${INITIAL_BYTES}"
172
258
 
173
259
  SPAWN_RES_FILE=$(mktemp)
@@ -92,11 +92,24 @@ 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: "update the graph with any new or missing information or intent\n\n<conversation-text>"`
95
+ - `initialMessage: <json-envelope>` (string carrying a JSON-stringified object 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
- **`<conversation-text>`** is the operator JSONL walked in transcript order: every `user`/`assistant` record with non-empty text emitted as `"<type>: <text>"`, joined by `\n`. The full exchange is preserved so the recorder can infer operator-asserted facts that span multiple turns (e.g. "hello / what's your business? / Smalleys"). The earlier "last-turn-only" extractor stripped the framing the recorder needed. If no qualifying record exists, the hook emits `trigger-skipped reason=conversation-empty` and no recorder is spawned.
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`:
100
+
101
+ ```
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>"
109
+ }
110
+ ```
111
+
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.
100
113
 
101
114
  **Manager-side specialist branches.** The recorder is the first specialist subagent that exercises the full specialist flag matrix:
102
115
  - 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.
@@ -109,7 +122,7 @@ Body overrides relative to a plain Sidebar click:
109
122
 
110
123
  **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.
111
124
 
112
- **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 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>`.
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>`.
113
126
 
114
127
  ### Recorder lifecycle diagnostic
115
128
 
@@ -118,7 +131,7 @@ When the operator reports "the recorder did nothing this turn", run these six gr
118
131
  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).
119
132
  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.
120
133
  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`).
121
- 4. **Agent file resolved.** `grep 'pty-spawn-allowlist specialist=database-operator count=11 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): the agent file at `$CLAUDE_CONFIG_DIR/agents/database-operator.md` is missing the `tools:` line, or the symlink the installer creates never landed. Re-run the installer.
134
+ 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.
122
135
  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.
123
136
  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>`.
124
137
 
@@ -163,6 +176,8 @@ grep '\[mcp:memory\] log-ingest-failed' ~/.${brand}/logs/mcp-memory-stderr-*.log
163
176
 
164
177
  The existing `mcp-memory-stderr-<date>.log` capture is unchanged on error paths — the new structured line is additive, not a relocation. Crash-mode debugging continues to read the stderr log; routine "did it land?" questions read `server.log`.
165
178
 
179
+ **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
+
166
181
  ## Out of scope
167
182
 
168
183
  - Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.