@rubytech/create-maxy-code 0.1.65 → 0.1.67

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 (34) 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 +317 -81
  4. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +183 -49
  5. package/payload/platform/plugins/docs/references/admin-session.md +23 -4
  6. package/payload/platform/plugins/docs/references/internals.md +2 -0
  7. package/payload/platform/plugins/docs/references/platform.md +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -0
  9. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/http-server.js +54 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts +36 -0
  13. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.d.ts.map +1 -0
  14. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js +131 -0
  15. package/payload/platform/services/claude-session-manager/dist/mcp-tools-probe.js.map +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +43 -0
  17. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +119 -5
  19. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +9 -0
  21. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  23. package/payload/platform/templates/specialists/agents/database-operator.md +35 -10
  24. package/payload/server/public/assets/{admin-ymReS1mN.js → admin-t8QqVwJF.js} +1 -1
  25. package/payload/server/public/assets/{data-D6U2e85B.js → data-vHmNQyXt.js} +1 -1
  26. package/payload/server/public/assets/{graph-C274PN27.js → graph-C7QOwigU.js} +1 -1
  27. package/payload/server/public/assets/graph-labels-Dm2RMEFy.js +1 -0
  28. package/payload/server/public/assets/{page-D-OLjort.js → page-B9GFro9g.js} +1 -1
  29. package/payload/server/public/assets/{page-DFFt9_Q0.js → page-BWKfXlHt.js} +1 -1
  30. package/payload/server/public/data.html +3 -3
  31. package/payload/server/public/graph.html +3 -3
  32. package/payload/server/public/index.html +4 -4
  33. package/payload/server/server.js +2 -0
  34. package/payload/server/public/assets/graph-labels-DQFzd1FY.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
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 177** — `initialMessage` is a JSON-stringified envelope conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
133
133
 
134
134
  ## Session identifiers (Task 135)
135
135
 
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env bash
2
- # Task 129/131 regression test for the Stop hook.
2
+ # Task 129/131/165/175/177 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 four keys declared by
15
+ # the Task 177 envelope: turns, conversationId, accountId, occurredAt.
16
+ # `turns` is the chronologically-ordered list of conversation turns.
17
+ # No top-level operatorMessage. No top-level assistantReply.
18
+ # No leading instruction prose. No `Conversation id:` prose.
19
+ # - The hook emits one `[turn-recorder] envelope ...` log line before
20
+ # `spawn-request`, naming turnsCount / userTurns / assistantTurns /
21
+ # toolCallTurns.
16
22
  # - The hook never writes the legacy `spawn-with-input` / `fired` stderr
17
23
  # lines.
18
24
  #
@@ -30,6 +36,7 @@ if [[ ! -x "$HOOK" ]]; then
30
36
  fi
31
37
 
32
38
  OP_ID='aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'
39
+ ACCT_ID='acct-test-0001'
33
40
 
34
41
  TMPFILES=()
35
42
  LISTENER_PIDS=()
@@ -92,6 +99,7 @@ run_hook() {
92
99
  MAXY_SESSION_ROLE="$role" \
93
100
  MAXY_SPECIALIST="$specialist" \
94
101
  MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
102
+ ACCOUNT_ID="$ACCT_ID" \
95
103
  bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
96
104
  HOOK_RC=$?
97
105
  HOOK_STDERR=$(cat "$stderr_file")
@@ -114,15 +122,12 @@ for raw in sys.stdin:
114
122
  ' || true
115
123
  }
116
124
 
117
- # Common envelope: a valid Stop-hook stdin shape with a real UUID + a
118
- # 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.
121
125
  start_listener
126
+ # Simple two-turn transcript with timestamps. One user, one assistant text.
122
127
  TRANSCRIPT=$(mktemp); TMPFILES+=("$TRANSCRIPT")
123
128
  {
124
- printf '{"type":"user","message":{"role":"user","content":"hello operator"}}\n'
125
- printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello, assistant reply"}]}}\n'
129
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:00.000Z","message":{"role":"user","content":"hello operator"}}\n'
130
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:27:01.000Z","message":{"id":"msg_001","role":"assistant","content":[{"type":"text","text":"hello, assistant reply"}]}}\n'
126
131
  } > "$TRANSCRIPT"
127
132
  ENVELOPE=$(python3 -c '
128
133
  import json, sys
@@ -177,20 +182,33 @@ else
177
182
  fail "case-4 expected trigger-skipped missing-transcript, got: $(ingest_lines)"
178
183
  fi
179
184
 
180
- # --- Case 5: happy path -------------------------------------------------
185
+ # --- Case 5 (Task 177): ordered-turns happy path -----------------------
186
+ # Transcript with four turns: user-A, asst-A-reply, user-B, asst-B-reply.
187
+ # Envelope `turns` array must carry exactly that order, oldest first.
188
+ # Envelope shape: { turns, conversationId, accountId, occurredAt } — no
189
+ # top-level operatorMessage / assistantReply.
190
+ ORDERED_TRANSCRIPT=$(mktemp); TMPFILES+=("$ORDERED_TRANSCRIPT")
191
+ {
192
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:42.258Z","message":{"role":"user","content":"New Real Agent session"}}\n'
193
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:27:45.158Z","message":{"id":"msg_a","role":"assistant","content":[{"type":"text","text":"Session noted. What would you like to work on?"}]}}\n'
194
+ printf '{"type":"user","timestamp":"2026-05-19T21:27:57.314Z","message":{"role":"user","content":"you tell me"}}\n'
195
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:28:00.847Z","message":{"id":"msg_b","role":"assistant","content":[{"type":"text","text":"One question to anchor the next move: what is your name?"}]}}\n'
196
+ } > "$ORDERED_TRANSCRIPT"
197
+ ORDERED_ENVELOPE=$(python3 -c '
198
+ import json, sys
199
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
200
+ ' "$OP_ID" "$ORDERED_TRANSCRIPT")
181
201
  : > "$REQ_LOG"
182
- run_hook "admin" "" "$ENVELOPE"
202
+ run_hook "admin" "" "$ORDERED_ENVELOPE"
183
203
  [[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC stderr=$HOOK_STDERR"
184
204
  [[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty, got: $HOOK_STDERR"
185
205
 
186
- # 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).
206
+ # 5a. exactly ONE `trigger` log-ingest line carrying the operator session id.
189
207
  TRIGGER_COUNT=$(ingest_lines | grep -cE "^trigger sessionId=${OP_ID} turnIndex=0 transcriptBytes=[0-9]+ conversationBytes=[0-9]+$" || true)
190
208
  if [[ "$TRIGGER_COUNT" -eq 1 ]]; then
191
- pass "case-5a exactly one trigger line emitted via log-ingest carrying operator id + conversationBytes"
209
+ pass "case-5a exactly one trigger line via log-ingest"
192
210
  else
193
- fail "case-5a expected exactly 1 trigger line carrying ${OP_ID}, got $TRIGGER_COUNT (lines: $(ingest_lines))"
211
+ fail "case-5a expected exactly 1 trigger line, got $TRIGGER_COUNT (lines: $(ingest_lines))"
194
212
  fi
195
213
 
196
214
  # 5b. exactly ONE POST to /api/admin/claude-sessions
@@ -201,40 +219,57 @@ else
201
219
  fail "case-5b expected exactly 1 recorder-spawn POST, got $RECORDER_LINES"
202
220
  fi
203
221
 
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).
222
+ # 5c. Envelope has exactly four keys (turns, conversationId, accountId,
223
+ # occurredAt). `turns` carries 4 entries in record order; each has
224
+ # role/text/ts. No `toolCalls` on turns where there were no tool_use
225
+ # blocks (omitted-when-empty rule).
208
226
  RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
209
227
  BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
210
- import sys, json
228
+ import sys, json, re
211
229
  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"
230
+ acct = sys.argv[2]
216
231
  try:
217
- b = json.load(sys.stdin)
218
- msg = b.get("initialMessage") or ""
219
- 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,
229
- )
230
- print("yes" if all(conds) else "no:"+repr(conds)+"|msg="+repr(msg))
232
+ outer = json.load(sys.stdin)
233
+ msg_str = outer.get("initialMessage")
234
+ if not isinstance(msg_str, str):
235
+ print("no:initialMessage-not-string:"+repr(type(msg_str).__name__))
236
+ sys.exit(0)
237
+ msg = json.loads(msg_str)
238
+ keys = set(msg.keys())
239
+ turns = msg.get("turns") or []
240
+ conds = []
241
+ conds.append(("envelope-keys", keys == {"turns", "conversationId", "accountId", "occurredAt"}))
242
+ conds.append(("turns-len", len(turns) == 4))
243
+ conds.append(("conversationId", msg.get("conversationId") == op))
244
+ conds.append(("accountId", msg.get("accountId") == acct))
245
+ conds.append(("occurredAt-shape", bool(re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$", msg.get("occurredAt") or ""))))
246
+ conds.append(("no-operatorMessage", "operatorMessage" not in keys))
247
+ conds.append(("no-assistantReply", "assistantReply" not in keys))
248
+ if len(turns) >= 4:
249
+ conds.append(("t0-role", turns[0].get("role") == "user"))
250
+ conds.append(("t0-text", turns[0].get("text") == "New Real Agent session"))
251
+ conds.append(("t0-ts", turns[0].get("ts") == "2026-05-19T21:27:42.258Z"))
252
+ conds.append(("t0-no-toolCalls", "toolCalls" not in turns[0]))
253
+ conds.append(("t1-role", turns[1].get("role") == "assistant"))
254
+ conds.append(("t1-text", turns[1].get("text") == "Session noted. What would you like to work on?"))
255
+ conds.append(("t1-ts", turns[1].get("ts") == "2026-05-19T21:27:45.158Z"))
256
+ conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
257
+ conds.append(("t2-role", turns[2].get("role") == "user"))
258
+ conds.append(("t2-text", turns[2].get("text") == "you tell me"))
259
+ conds.append(("t3-role", turns[3].get("role") == "assistant"))
260
+ conds.append(("t3-text", turns[3].get("text") == "One question to anchor the next move: what is your name?"))
261
+ conds.append(("outer-specialist", outer.get("specialist") == "database-operator"))
262
+ conds.append(("outer-model", outer.get("model") == "haiku"))
263
+ conds.append(("outer-adminSessionId", outer.get("adminSessionId") == op))
264
+ failed = [name for name, ok in conds if not ok]
265
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
231
266
  except Exception as e:
232
267
  print("parse-fail:"+str(e))
233
- ' "$OP_ID" 2>/dev/null)
268
+ ' "$OP_ID" "$ACCT_ID" 2>/dev/null)
234
269
  if [[ "$BODY_OK" == "yes" ]]; then
235
- pass "case-5c body carries full transcript layout (user: …\\nassistant: …) Task 165"
270
+ pass "case-5c envelope is { turns, conversationId, accountId, occurredAt }; turns is the 4-entry ordered list"
236
271
  else
237
- fail "case-5c body shape wrong ($BODY_OK): $RECORDER_BODY"
272
+ fail "case-5c envelope shape wrong ($BODY_OK): $RECORDER_BODY"
238
273
  fi
239
274
 
240
275
  # 5d. exactly ONE `spawn-request` log-ingest line
@@ -245,67 +280,268 @@ else
245
280
  fail "case-5d expected 1 spawn-request, got $SPAWN_REQ_COUNT (lines: $(ingest_lines))"
246
281
  fi
247
282
 
248
- # 5e. no legacy stderr emissions
283
+ # 5e. Task 177 observability — exactly ONE `[turn-recorder] envelope ...`
284
+ # line emitted before `spawn-request`. The line carries turnsCount,
285
+ # userTurns, assistantTurns, toolCallTurns.
286
+ ENVELOPE_LINE_COUNT=$(ingest_lines | grep -cE "^envelope sessionId=${OP_ID} turnsCount=4 userTurns=2 assistantTurns=2 toolCallTurns=0$" || true)
287
+ if [[ "$ENVELOPE_LINE_COUNT" -eq 1 ]]; then
288
+ pass "case-5e envelope log line emitted with correct counts"
289
+ else
290
+ fail "case-5e expected exactly 1 envelope line w/ turnsCount=4 userTurns=2 assistantTurns=2 toolCallTurns=0, got $ENVELOPE_LINE_COUNT (lines: $(ingest_lines))"
291
+ fi
292
+
293
+ # 5f. envelope line precedes spawn-request line.
294
+ ALL_LINES=$(ingest_lines)
295
+ ENV_POS=$(printf '%s\n' "$ALL_LINES" | grep -nE "^envelope sessionId=${OP_ID}" | head -1 | cut -d: -f1)
296
+ SPAWN_POS=$(printf '%s\n' "$ALL_LINES" | grep -nE "^spawn-request sessionId=${OP_ID}" | head -1 | cut -d: -f1)
297
+ if [[ -n "$ENV_POS" && -n "$SPAWN_POS" && "$ENV_POS" -lt "$SPAWN_POS" ]]; then
298
+ pass "case-5f envelope line precedes spawn-request"
299
+ else
300
+ fail "case-5f envelope line must precede spawn-request (env=$ENV_POS spawn=$SPAWN_POS)"
301
+ fi
302
+
303
+ # 5g. no legacy stderr emissions
249
304
  if echo "$HOOK_STDERR" | grep -qE 'spawn-with-input|spawn-with-stdin|turn-completed-graph-write fired'; then
250
- fail "case-5e legacy stderr line emitted: $HOOK_STDERR"
305
+ fail "case-5g legacy stderr line emitted: $HOOK_STDERR"
306
+ else
307
+ pass "case-5g no legacy stderr emissions"
308
+ fi
309
+
310
+ # --- Case 6 (Task 177): multi-block assistant collapse -----------------
311
+ # Two assistant JSONL records sharing `message.id`: first is thinking-only,
312
+ # second carries the text. Walker collapses both into ONE `turns` entry
313
+ # whose text is the text-record's text. Thinking records contribute no
314
+ # text and do not establish the turn; `ts` is the first CONTRIBUTING
315
+ # record's ts (here, the text record's ts).
316
+ MULTI_TRANSCRIPT=$(mktemp); TMPFILES+=("$MULTI_TRANSCRIPT")
317
+ {
318
+ printf '{"type":"user","timestamp":"2026-05-19T21:30:00.000Z","message":{"role":"user","content":"think then answer"}}\n'
319
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:30:01.000Z","message":{"id":"msg_multi","role":"assistant","content":[{"type":"thinking","thinking":"considering..."}]}}\n'
320
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:30:02.000Z","message":{"id":"msg_multi","role":"assistant","content":[{"type":"text","text":"answer is 42"}]}}\n'
321
+ } > "$MULTI_TRANSCRIPT"
322
+ MULTI_ENVELOPE=$(python3 -c '
323
+ import json, sys
324
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
325
+ ' "$OP_ID" "$MULTI_TRANSCRIPT")
326
+ : > "$REQ_LOG"
327
+ run_hook "admin" "" "$MULTI_ENVELOPE"
328
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC stderr=$HOOK_STDERR"
329
+ MULTI_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
330
+ MULTI_OK=$(printf '%s' "$MULTI_BODY" | python3 -c '
331
+ import sys, json
332
+ try:
333
+ outer = json.load(sys.stdin)
334
+ msg = json.loads(outer.get("initialMessage"))
335
+ turns = msg.get("turns") or []
336
+ conds = []
337
+ conds.append(("turns-len-2", len(turns) == 2))
338
+ if len(turns) >= 2:
339
+ conds.append(("t0-user", turns[0].get("role") == "user" and turns[0].get("text") == "think then answer"))
340
+ conds.append(("t1-assistant", turns[1].get("role") == "assistant"))
341
+ conds.append(("t1-text", turns[1].get("text") == "answer is 42"))
342
+ conds.append(("t1-ts-text-record", turns[1].get("ts") == "2026-05-19T21:30:02.000Z"))
343
+ conds.append(("t1-no-toolCalls", "toolCalls" not in turns[1]))
344
+ failed = [name for name, ok in conds if not ok]
345
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
346
+ except Exception as e:
347
+ print("parse-fail:"+str(e))
348
+ ' 2>/dev/null)
349
+ if [[ "$MULTI_OK" == "yes" ]]; then
350
+ pass "case-6 multi-block assistant collapses to one turn entry; ts is the first record's ts"
351
+ else
352
+ fail "case-6 multi-block collapse wrong ($MULTI_OK): $MULTI_BODY"
353
+ fi
354
+
355
+ # --- Case 7 (Task 177): tool-call pairing on owning assistant turn -----
356
+ # Assistant tool_use followed by user tool_result. Both attach as ONE
357
+ # `toolCalls` entry on the assistant turn that owned the tool_use.
358
+ # The tool_result-only user record does NOT create a separate user turn.
359
+ TOOL_TRANSCRIPT=$(mktemp); TMPFILES+=("$TOOL_TRANSCRIPT")
360
+ python3 - "$TOOL_TRANSCRIPT" <<'PY'
361
+ import json, sys
362
+ out = sys.argv[1]
363
+ recs = [
364
+ {"type":"user","timestamp":"2026-05-19T21:32:00.000Z","message":{"role":"user","content":"please write a node"}},
365
+ {"type":"assistant","timestamp":"2026-05-19T21:32:01.000Z","message":{"id":"msg_tu","role":"assistant","content":[
366
+ {"type":"tool_use","id":"toolu_001","name":"mcp__memory__memory-write","input":{"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}}
367
+ ]}},
368
+ {"type":"user","timestamp":"2026-05-19T21:32:02.000Z","message":{"role":"user","content":[
369
+ {"type":"tool_result","tool_use_id":"toolu_001","content":"ok: wrote LocalBusiness"}
370
+ ]}},
371
+ {"type":"assistant","timestamp":"2026-05-19T21:32:03.000Z","message":{"id":"msg_done","role":"assistant","content":[
372
+ {"type":"text","text":"done"}
373
+ ]}},
374
+ ]
375
+ with open(out,"w") as f:
376
+ for r in recs: f.write(json.dumps(r)+"\n")
377
+ PY
378
+ TOOL_ENVELOPE=$(python3 -c '
379
+ import json, sys
380
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
381
+ ' "$OP_ID" "$TOOL_TRANSCRIPT")
382
+ : > "$REQ_LOG"
383
+ run_hook "admin" "" "$TOOL_ENVELOPE"
384
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-7 rc=$HOOK_RC stderr=$HOOK_STDERR"
385
+ TOOL_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
386
+ TOOL_OK=$(printf '%s' "$TOOL_BODY" | python3 -c '
387
+ import sys, json
388
+ try:
389
+ outer = json.load(sys.stdin)
390
+ msg = json.loads(outer.get("initialMessage"))
391
+ turns = msg.get("turns") or []
392
+ conds = []
393
+ # Expected 3 turns: user "please write a node", assistant tool_use (text=""),
394
+ # assistant "done". The user tool_result record carries no separate user turn.
395
+ conds.append(("turns-len-3", len(turns) == 3))
396
+ if len(turns) >= 3:
397
+ conds.append(("t0", turns[0].get("role") == "user" and turns[0].get("text") == "please write a node"))
398
+ conds.append(("t1-role", turns[1].get("role") == "assistant"))
399
+ conds.append(("t1-text-empty", turns[1].get("text") == ""))
400
+ conds.append(("t1-toolCalls-len", len(turns[1].get("toolCalls") or []) == 1))
401
+ tc = (turns[1].get("toolCalls") or [{}])[0]
402
+ conds.append(("tc-tool", tc.get("tool") == "mcp__memory__memory-write"))
403
+ conds.append(("tc-input", tc.get("input") == {"nodeType":"LocalBusiness","name":"Smalleys","nested":{"k":1}}))
404
+ conds.append(("tc-output", tc.get("output") == "ok: wrote LocalBusiness"))
405
+ conds.append(("t2", turns[2].get("role") == "assistant" and turns[2].get("text") == "done"))
406
+ conds.append(("t2-no-toolCalls", "toolCalls" not in turns[2]))
407
+ failed = [name for name, ok in conds if not ok]
408
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
409
+ except Exception as e:
410
+ print("parse-fail:"+str(e))
411
+ ' 2>/dev/null)
412
+ if [[ "$TOOL_OK" == "yes" ]]; then
413
+ pass "case-7 tool_use + tool_result land as one toolCalls entry on owning assistant turn; no extra user turn"
251
414
  else
252
- pass "case-5e no legacy stderr emissions"
415
+ fail "case-7 tool pairing wrong ($TOOL_OK): $TOOL_BODY"
253
416
  fi
254
417
 
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.
418
+ # Envelope log line for case-7: turnsCount=3, userTurns=1, assistantTurns=2, toolCallTurns=1
419
+ ENV_LINE_7=$(ingest_lines | grep -cE "^envelope sessionId=${OP_ID} turnsCount=3 userTurns=1 assistantTurns=2 toolCallTurns=1$" || true)
420
+ if [[ "$ENV_LINE_7" -eq 1 ]]; then
421
+ pass "case-7 envelope log line counts tool-call turn correctly"
422
+ else
423
+ fail "case-7 expected envelope line w/ turnsCount=3 userTurns=1 assistantTurns=2 toolCallTurns=1, got $ENV_LINE_7 (lines: $(ingest_lines))"
424
+ fi
425
+
426
+ # --- Case 8 (Task 177): long-conversation 100 turns ------------------
427
+ # Transcript with 100 turns (50 user + 50 assistant, interleaved) →
428
+ # turns.length === 100. No drop, no truncation, no env-knob override.
429
+ LONG_TRANSCRIPT=$(mktemp); TMPFILES+=("$LONG_TRANSCRIPT")
430
+ python3 - "$LONG_TRANSCRIPT" <<'PY'
431
+ import json, sys
432
+ out = sys.argv[1]
433
+ with open(out,"w") as f:
434
+ for i in range(50):
435
+ ts_u = f"2026-05-19T21:{(i*2)//60:02d}:{(i*2)%60:02d}.000Z"
436
+ ts_a = f"2026-05-19T21:{(i*2+1)//60:02d}:{(i*2+1)%60:02d}.000Z"
437
+ f.write(json.dumps({"type":"user","timestamp":ts_u,"message":{"role":"user","content":f"user msg {i}"}})+"\n")
438
+ f.write(json.dumps({"type":"assistant","timestamp":ts_a,"message":{"id":f"msg_{i}","role":"assistant","content":[{"type":"text","text":f"asst reply {i}"}]}})+"\n")
439
+ PY
440
+ LONG_ENVELOPE=$(python3 -c '
441
+ import json, sys
442
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
443
+ ' "$OP_ID" "$LONG_TRANSCRIPT")
444
+ : > "$REQ_LOG"
445
+ run_hook "admin" "" "$LONG_ENVELOPE"
446
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-8 rc=$HOOK_RC stderr=$HOOK_STDERR"
447
+ LONG_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
448
+ LONG_OK=$(printf '%s' "$LONG_BODY" | python3 -c '
449
+ import sys, json
450
+ try:
451
+ outer = json.load(sys.stdin)
452
+ msg = json.loads(outer.get("initialMessage"))
453
+ turns = msg.get("turns") or []
454
+ conds = []
455
+ conds.append(("turns-len-100", len(turns) == 100))
456
+ if len(turns) == 100:
457
+ conds.append(("first-user-0", turns[0].get("role") == "user" and turns[0].get("text") == "user msg 0"))
458
+ conds.append(("last-asst-49", turns[99].get("role") == "assistant" and turns[99].get("text") == "asst reply 49"))
459
+ # ts strictly non-decreasing
460
+ tss = [t.get("ts","") for t in turns]
461
+ conds.append(("ts-ordered", tss == sorted(tss)))
462
+ failed = [name for name, ok in conds if not ok]
463
+ print("yes" if not failed else "no:"+repr(failed)+"|len="+str(len(turns)))
464
+ except Exception as e:
465
+ print("parse-fail:"+str(e))
466
+ ' 2>/dev/null)
467
+ if [[ "$LONG_OK" == "yes" ]]; then
468
+ pass "case-8 100-turn transcript → turns.length === 100, oldest first, no truncation"
469
+ else
470
+ fail "case-8 long-conversation wrong ($LONG_OK)"
471
+ fi
472
+
473
+ # --- Case 9 (Task 177): empty-skip ------------------------------------
474
+ # Transcript with no text content and no tool_use → turns array is empty
475
+ # → trigger-skipped reason=conversation-empty, no spawn POST.
259
476
  EMPTY_TRANSCRIPT=$(mktemp); TMPFILES+=("$EMPTY_TRANSCRIPT")
260
- printf '{"type":"system","summary":"noop"}\n' > "$EMPTY_TRANSCRIPT"
477
+ {
478
+ printf '{"type":"system","summary":"noop"}\n'
479
+ printf '{"type":"assistant","timestamp":"2026-05-19T21:40:00.000Z","message":{"id":"msg_t","role":"assistant","content":[{"type":"thinking","thinking":"silent"}]}}\n'
480
+ } > "$EMPTY_TRANSCRIPT"
261
481
  EMPTY_ENVELOPE=$(python3 -c '
262
482
  import json, sys
263
483
  print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
264
484
  ' "$OP_ID" "$EMPTY_TRANSCRIPT")
265
485
  : > "$REQ_LOG"
266
486
  run_hook "admin" "" "$EMPTY_ENVELOPE"
267
- [[ "$HOOK_RC" -eq 0 ]] || fail "case-6 rc=$HOOK_RC"
487
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-9 rc=$HOOK_RC"
268
488
  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"
489
+ pass "case-9 thinking-only transcript → trigger-skipped reason=conversation-empty"
270
490
  else
271
- fail "case-6 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
491
+ fail "case-9 expected trigger-skipped conversation-empty, got: $(ingest_lines)"
272
492
  fi
273
- # And no recorder-spawn POST is made.
274
493
  if grep -qE '^/api/admin/claude-sessions ' "$REQ_LOG"; then
275
- fail "case-6 recorder-spawn must NOT be called when conversation-empty"
494
+ fail "case-9 recorder-spawn must NOT be called when conversation-empty"
495
+ else
496
+ pass "case-9 no recorder-spawn POST"
276
497
  fi
277
498
 
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 '
499
+ # --- Case 10 (Task 177): escaping round-trip --------------------------
500
+ # Quotes, backslashes, newlines, non-ASCII all round-trip cleanly through
501
+ # the JSON envelope's `turns[*].text` field.
502
+ ESC_TRANSCRIPT=$(mktemp); TMPFILES+=("$ESC_TRANSCRIPT")
503
+ python3 - "$ESC_TRANSCRIPT" <<'PY'
504
+ import json, sys
505
+ out = sys.argv[1]
506
+ op_msg = 'hello "world"\\nline2\nlast — café 🦊'
507
+ asst_msg = 'reply with backslash \\\\ and newline\nhere'
508
+ recs = [
509
+ {"type":"user","timestamp":"2026-05-19T21:50:00.000Z","message":{"role":"user","content":op_msg}},
510
+ {"type":"assistant","timestamp":"2026-05-19T21:50:01.000Z","message":{"id":"msg_esc","role":"assistant","content":[{"type":"text","text":asst_msg}]}},
511
+ ]
512
+ with open(out,"w") as f:
513
+ for r in recs: f.write(json.dumps(r)+"\n")
514
+ PY
515
+ ESC_ENVELOPE=$(python3 -c '
287
516
  import json, sys
288
517
  print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
289
- ' "$OP_ID" "$THREE_TURN_TRANSCRIPT")
518
+ ' "$OP_ID" "$ESC_TRANSCRIPT")
290
519
  : > "$REQ_LOG"
291
- run_hook "admin" "" "$THREE_ENVELOPE"
292
- [[ "$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 '
520
+ run_hook "admin" "" "$ESC_ENVELOPE"
521
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-10 rc=$HOOK_RC stderr=$HOOK_STDERR"
522
+ ESC_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
523
+ ESC_OK=$(printf '%s' "$ESC_BODY" | python3 -c '
295
524
  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"
525
+ EXPECTED_OP = "hello \"world\"\\nline2\nlast café \U0001F98A"
526
+ EXPECTED_ASST = "reply with backslash \\\\ and newline\nhere"
298
527
  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))
528
+ outer = json.load(sys.stdin)
529
+ msg = json.loads(outer.get("initialMessage"))
530
+ turns = msg.get("turns") or []
531
+ conds = []
532
+ conds.append(("len-2", len(turns) == 2))
533
+ if len(turns) == 2:
534
+ conds.append(("op-text", turns[0].get("text") == EXPECTED_OP))
535
+ conds.append(("asst-text", turns[1].get("text") == EXPECTED_ASST))
536
+ failed = [name for name, ok in conds if not ok]
537
+ print("yes" if not failed else "no:"+repr(failed)+"|turns="+repr(turns))
302
538
  except Exception as e:
303
539
  print("parse-fail:"+str(e))
304
540
  ' 2>/dev/null)
305
- if [[ "$THREE_OK" == "yes" ]]; then
306
- pass "case-7 three-turn conversation walks user/assistant/user in order"
541
+ if [[ "$ESC_OK" == "yes" ]]; then
542
+ pass "case-10 escaping: quotes/backslashes/newlines/non-ASCII round-trip cleanly through turns[*].text"
307
543
  else
308
- fail "case-7 expected multi-turn layout, got: $THREE_OK | body: $THREE_BODY"
544
+ fail "case-10 escaping wrong ($ESC_OK): $ESC_BODY"
309
545
  fi
310
546
 
311
547
  # --- Summary ------------------------------------------------------------