@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +140 -61
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +135 -49
- package/payload/platform/plugins/docs/references/admin-session.md +19 -4
- package/payload/platform/plugins/docs/references/internals.md +7 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.d.ts +22 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.js +44 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/_helpers/emit-capture.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.js +68 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-derive-insights-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.js +48 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-archive-enrich-rejection-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.js +53 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/conversation-memory-expunge-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.js +40 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-add-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.js +39 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/graph-prune-denylist-remove-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.js +88 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-archive-write-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.js +58 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-classify-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.js +58 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-delete-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.js +70 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-edit-attachment-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.js +48 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-empty-trash-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.js +74 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.js +47 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-extract-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.js +65 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-web-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.js +43 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-reindex-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.js +65 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-rename-attachment-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.js +49 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-restore-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +74 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +74 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.js +62 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-delete-emit.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.js +76 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-emit.test.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +75 -2
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +14 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts +24 -4
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js +39 -9
- package/payload/platform/services/claude-session-manager/dist/specialist-drift.js.map +1 -1
- package/payload/server/server-init.cjs +40 -0
package/package.json
CHANGED
|
@@ -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` + `
|
|
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 |
|
|
9
|
+
# missing-transcript | conversation-empty).
|
|
10
10
|
# - Happy path emits exactly ONE `trigger` line carrying
|
|
11
|
-
# `
|
|
12
|
-
#
|
|
13
|
-
# session id as `
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
msg
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"
|
|
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
|
|
248
|
+
pass "case-5c initialMessage is JSON envelope with exactly 6 declared keys"
|
|
236
249
|
else
|
|
237
|
-
fail "case-5c
|
|
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
|
|
256
|
-
#
|
|
257
|
-
#
|
|
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
|
|
279
|
-
#
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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" "$
|
|
315
|
+
' "$OP_ID" "$TOOL_TRANSCRIPT")
|
|
290
316
|
: > "$REQ_LOG"
|
|
291
|
-
run_hook "admin" "" "$
|
|
317
|
+
run_hook "admin" "" "$TOOL_ENVELOPE"
|
|
292
318
|
[[ "$HOOK_RC" -eq 0 ]] || fail "case-7 rc=$HOOK_RC stderr=$HOOK_STDERR"
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
300
|
-
msg =
|
|
301
|
-
|
|
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 [[ "$
|
|
306
|
-
pass "case-
|
|
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-
|
|
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
|
|
4
|
-
#
|
|
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:
|
|
12
|
-
#
|
|
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
|
-
# -
|
|
27
|
-
#
|
|
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
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
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:
|
|
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
|
|
103
|
-
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if not
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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":
|
|
251
|
+
"initialMessage": envelope_str,
|
|
166
252
|
}
|
|
167
253
|
print(json.dumps(body))
|
|
168
|
-
' "$ADMIN_SESSION_ID" "$
|
|
254
|
+
' "$ADMIN_SESSION_ID" "$ENVELOPE" 2>/dev/null)
|
|
169
255
|
|
|
170
|
-
INITIAL_BYTES=$(printf '%s
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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.
|