@rubytech/create-maxy-code 0.1.146 → 0.1.148

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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-search/dist/index.d.ts +34 -11
  3. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/graph-search/dist/index.js +42 -13
  5. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  6. package/payload/platform/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +75 -40
  7. package/payload/platform/lib/graph-search/src/index.ts +45 -12
  8. package/payload/platform/neo4j/schema.cypher +38 -14
  9. package/payload/platform/plugins/admin/PLUGIN.md +0 -1
  10. package/payload/platform/plugins/admin/hooks/__tests__/post-turn-graph-pass.test.sh +472 -0
  11. package/payload/platform/plugins/admin/hooks/__tests__/session-end-retrospective.test.sh +2 -2
  12. package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +6 -3
  13. package/payload/platform/plugins/memory/mcp/dist/index.js +21 -19
  14. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js +4 -1
  16. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js.map +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-rewriter-operator-hint.test.d.ts +2 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-rewriter-operator-hint.test.d.ts.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-rewriter-operator-hint.test.js +90 -0
  20. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-rewriter-operator-hint.test.js.map +1 -0
  21. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +14 -0
  22. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -1
  23. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +27 -4
  24. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -1
  25. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +1 -0
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -1
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-operator-hint.test.d.ts +2 -0
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-operator-hint.test.d.ts.map +1 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-operator-hint.test.js +329 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-operator-hint.test.js.map +1 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-compiled-truth-rejection.test.d.ts +2 -0
  32. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-compiled-truth-rejection.test.d.ts.map +1 -0
  33. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-compiled-truth-rejection.test.js +39 -0
  34. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-compiled-truth-rejection.test.js.map +1 -0
  35. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +1 -11
  37. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  38. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +27 -18
  39. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  40. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +83 -70
  41. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  42. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  43. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +27 -0
  44. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  45. package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
  46. package/payload/platform/plugins/memory/references/schema-base.md +5 -4
  47. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +0 -1
  49. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  50. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  51. package/payload/server/server.js +9 -2
  52. package/payload/platform/plugins/admin/skills/commitment-followthrough/SKILL.md +0 -60
@@ -0,0 +1,472 @@
1
+ #!/usr/bin/env bash
2
+ # Task 419 — post-turn-graph-pass Stop hook test suite.
3
+ #
4
+ # Modeled on session-end-retrospective.test.sh. A local Python HTTP server
5
+ # stands in for the admin server:
6
+ # POST /api/admin/log-ingest → records every line emit_log sent.
7
+ # GET /api/admin/post-turn-context → returns the contents of $CTX_FILE,
8
+ # or 500 if $CTX_FILE is missing/empty.
9
+ # Both kinds of request are appended to $REQ_LOG so assertions can see
10
+ # every POST body AND whether the GET was attempted at all.
11
+ #
12
+ # Contract under test:
13
+ # - Role/specialist gates: exit 0, single trigger-skipped log line, no
14
+ # stderr directive.
15
+ # - MAXY_UI_INTERNAL_PORT unset: exit 0, no stderr directive. The hook
16
+ # emits its missing-env diagnostic on its own stderr channel because
17
+ # /api/admin/log-ingest is unreachable; that loud-fail is acceptable.
18
+ # - Empty stdin / missing-transcript: exit 0, single trigger-skipped log
19
+ # line, no stderr directive.
20
+ # - Dispatch path (admin + populated JSONL): exit 2, stderr begins with
21
+ # `[system: post-turn-graph-pass-stop-hook]` and contains
22
+ # `<conversation>` + `<prior-writes>` blocks, with conversationId
23
+ # interpolated from CONVERSATION_NODE_ID.
24
+ # - Prior-writes fetch fails (context endpoint 500): exit 2, dispatch
25
+ # still fires, `<prior-writes>` block resolves to `(none)`.
26
+ # - CONVERSATION_NODE_ID unset: exit 2, prompt names
27
+ # `conversationId: <unset>`, and no GET to /api/admin/post-turn-context
28
+ # is attempted.
29
+ # - Multi-block assistant turn: conversation walk concatenates `text`
30
+ # blocks; `tool_use` blocks dropped.
31
+ # - User turn whose only content is `tool_result` blocks: skipped in
32
+ # the conversation walk.
33
+
34
+ set -u
35
+
36
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/post-turn-graph-pass.sh"
37
+ if [[ ! -x "$HOOK" ]]; then
38
+ echo "FAIL: $HOOK not executable" >&2
39
+ exit 1
40
+ fi
41
+
42
+ OP_ID='aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'
43
+ CONV_ID='conv-deadbeef-0001'
44
+
45
+ TMPFILES=()
46
+ LISTENER_PIDS=()
47
+ cleanup_test_state() {
48
+ for pid in "${LISTENER_PIDS[@]:-}"; do
49
+ if [[ -n "$pid" ]]; then
50
+ kill "$pid" 2>/dev/null || true
51
+ wait "$pid" 2>/dev/null || true
52
+ fi
53
+ done
54
+ for f in "${TMPFILES[@]:-}"; do
55
+ [[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
56
+ done
57
+ }
58
+ trap cleanup_test_state EXIT
59
+
60
+ PASS=0
61
+ FAIL=0
62
+ pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
63
+ fail() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
64
+
65
+ start_listener() {
66
+ REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
67
+ CTX_FILE=$(mktemp); TMPFILES+=("$CTX_FILE")
68
+ : > "$CTX_FILE"
69
+ LISTENER_PORT=$((39700 + RANDOM % 100))
70
+ python3 - "$LISTENER_PORT" "$REQ_LOG" "$CTX_FILE" <<'PY' &
71
+ import sys, http.server, json
72
+ port = int(sys.argv[1])
73
+ log_path = sys.argv[2]
74
+ ctx_path = sys.argv[3]
75
+
76
+ class H(http.server.BaseHTTPRequestHandler):
77
+ def log_message(self, *a, **k): pass
78
+ def do_POST(self):
79
+ n = int(self.headers.get('Content-Length','0') or 0)
80
+ body = self.rfile.read(n).decode('utf-8','replace')
81
+ with open(log_path, 'a', encoding='utf-8') as f:
82
+ f.write('POST\t' + self.path + '\t' + body + '\n')
83
+ self.send_response(200)
84
+ self.send_header('Content-Type','application/json')
85
+ self.end_headers()
86
+ self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
87
+ def do_GET(self):
88
+ with open(log_path, 'a', encoding='utf-8') as f:
89
+ f.write('GET\t' + self.path + '\t\n')
90
+ if self.path.startswith('/api/admin/post-turn-context'):
91
+ try:
92
+ with open(ctx_path, 'r', encoding='utf-8') as f:
93
+ body = f.read()
94
+ except FileNotFoundError:
95
+ body = ''
96
+ if not body.strip():
97
+ self.send_response(500)
98
+ self.end_headers()
99
+ return
100
+ self.send_response(200)
101
+ self.send_header('Content-Type','application/json')
102
+ self.end_headers()
103
+ self.wfile.write(body.encode('utf-8'))
104
+ else:
105
+ self.send_response(404)
106
+ self.end_headers()
107
+
108
+ http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()
109
+ PY
110
+ LISTENER_PIDS+=("$!")
111
+ for _ in $(seq 1 20); do
112
+ if curl -sS --max-time 1 -X POST "http://127.0.0.1:${LISTENER_PORT}/ping" -d '{}' >/dev/null 2>&1; then
113
+ break
114
+ fi
115
+ sleep 0.1
116
+ done
117
+ : > "$REQ_LOG"
118
+ }
119
+
120
+ run_hook() {
121
+ # Args: role, specialist, ui_port, conversation_node_id, stdin_json
122
+ local role="$1"; local specialist="$2"; local ui_port="$3"
123
+ local conv="$4"; local stdin_json="$5"
124
+ local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
125
+ local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
126
+ local env_cmd=(env)
127
+ env_cmd+=("MAXY_SESSION_ROLE=${role}")
128
+ env_cmd+=("MAXY_SPECIALIST=${specialist}")
129
+ if [[ -n "$ui_port" ]]; then
130
+ env_cmd+=("MAXY_UI_INTERNAL_PORT=${ui_port}")
131
+ fi
132
+ if [[ -n "$conv" ]]; then
133
+ env_cmd+=("CONVERSATION_NODE_ID=${conv}")
134
+ fi
135
+ env_cmd+=("ACCOUNT_ID=acct-test-0001")
136
+ printf '%s' "$stdin_json" | "${env_cmd[@]}" bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
137
+ HOOK_RC=$?
138
+ HOOK_STDERR=$(cat "$stderr_file")
139
+ HOOK_STDOUT=$(cat "$stdout_file")
140
+ sleep 0.1
141
+ }
142
+
143
+ ingest_lines() {
144
+ grep -E '^POST\s+/api/admin/log-ingest\s+' "$REQ_LOG" 2>/dev/null | python3 -c '
145
+ import sys, json
146
+ for raw in sys.stdin:
147
+ try:
148
+ parts = raw.rstrip("\n").split("\t", 2)
149
+ if len(parts) < 3: continue
150
+ body = parts[2]
151
+ d = json.loads(body)
152
+ if isinstance(d, dict) and isinstance(d.get("line"), str):
153
+ print(d["line"])
154
+ except Exception:
155
+ pass
156
+ ' || true
157
+ }
158
+
159
+ context_get_count() {
160
+ grep -E '^GET\s+/api/admin/post-turn-context' "$REQ_LOG" 2>/dev/null | wc -l | tr -d ' '
161
+ }
162
+
163
+ envelope_for() {
164
+ python3 -c '
165
+ import json, sys
166
+ print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
167
+ ' "$1" "$2"
168
+ }
169
+
170
+ write_simple_transcript() {
171
+ # write_simple_transcript <outfile> <user_text> <assistant_text>
172
+ local out="$1"; shift
173
+ python3 - "$out" "$@" <<'PY'
174
+ import sys, json
175
+ out = sys.argv[1]
176
+ records = []
177
+ i = 2
178
+ while i + 1 < len(sys.argv):
179
+ u, a = sys.argv[i], sys.argv[i+1]
180
+ records.append({
181
+ "type": "user",
182
+ "timestamp": "2026-05-24T10:00:00.000Z",
183
+ "message": {"role": "user", "content": u},
184
+ })
185
+ records.append({
186
+ "type": "assistant",
187
+ "timestamp": "2026-05-24T10:00:01.000Z",
188
+ "message": {
189
+ "id": "msg_a",
190
+ "role": "assistant",
191
+ "content": [{"type": "text", "text": a}],
192
+ },
193
+ })
194
+ i += 2
195
+ with open(out, "w", encoding="utf-8") as f:
196
+ for r in records:
197
+ f.write(json.dumps(r) + "\n")
198
+ PY
199
+ }
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Case 1: role != admin → trigger-skipped reason=role-not-admin, no directive
203
+ # ---------------------------------------------------------------------------
204
+ start_listener
205
+ TRANSCRIPT_BASIC=$(mktemp); TMPFILES+=("$TRANSCRIPT_BASIC")
206
+ write_simple_transcript "$TRANSCRIPT_BASIC" "hello" "hi there"
207
+ ENV_BASIC=$(envelope_for "$OP_ID" "$TRANSCRIPT_BASIC")
208
+ : > "$REQ_LOG"
209
+ run_hook "public" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_BASIC"
210
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-1 rc=$HOOK_RC"
211
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[system: post-turn-graph-pass-stop-hook\]'; then
212
+ fail "case-1 stderr must not contain dispatch directive"
213
+ fi
214
+ if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=role-not-admin$"; then
215
+ pass "case-1 role=public → trigger-skipped reason=role-not-admin"
216
+ else
217
+ fail "case-1 expected role-not-admin, got: $(ingest_lines)"
218
+ fi
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Case 2: MAXY_SPECIALIST set → trigger-skipped reason=is-specialist
222
+ # ---------------------------------------------------------------------------
223
+ : > "$REQ_LOG"
224
+ run_hook "admin" "database-operator" "$LISTENER_PORT" "$CONV_ID" "$ENV_BASIC"
225
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-2 rc=$HOOK_RC"
226
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[system: post-turn-graph-pass-stop-hook\]'; then
227
+ fail "case-2 stderr must not contain dispatch directive"
228
+ fi
229
+ if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=is-specialist specialist=database-operator$"; then
230
+ pass "case-2 specialist=database-operator → trigger-skipped reason=is-specialist"
231
+ else
232
+ fail "case-2 expected is-specialist, got: $(ingest_lines)"
233
+ fi
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Case 3: MAXY_UI_INTERNAL_PORT unset → exit 0, no dispatch directive.
237
+ # The hook prints a missing-env line on its own stderr (loud-fail);
238
+ # that's expected — /log-ingest is unreachable without the port.
239
+ # ---------------------------------------------------------------------------
240
+ : > "$REQ_LOG"
241
+ run_hook "admin" "" "" "$CONV_ID" "$ENV_BASIC"
242
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-3 rc=$HOOK_RC"
243
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[system: post-turn-graph-pass-stop-hook\]'; then
244
+ fail "case-3 stderr must not contain dispatch directive"
245
+ fi
246
+ if [[ "$(ingest_lines | wc -l | tr -d ' ')" == "0" ]]; then
247
+ pass "case-3 missing MAXY_UI_INTERNAL_PORT → exit 0, no dispatch"
248
+ else
249
+ fail "case-3 expected no ingest lines (port unset), got: $(ingest_lines)"
250
+ fi
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Case 4: empty stdin → trigger-skipped reason=empty-stdin
254
+ # ---------------------------------------------------------------------------
255
+ : > "$REQ_LOG"
256
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" ""
257
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-4 rc=$HOOK_RC"
258
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[system: post-turn-graph-pass-stop-hook\]'; then
259
+ fail "case-4 stderr must not contain dispatch directive"
260
+ fi
261
+ if ingest_lines | grep -qE '^trigger-skipped sessionId=unknown reason=empty-stdin$'; then
262
+ pass "case-4 empty stdin → trigger-skipped reason=empty-stdin"
263
+ else
264
+ fail "case-4 expected empty-stdin, got: $(ingest_lines)"
265
+ fi
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Case 5: transcript_path missing on disk → trigger-skipped reason=missing-transcript
269
+ # ---------------------------------------------------------------------------
270
+ MISSING_PATH="/tmp/post-turn-graph-pass-does-not-exist-$$.jsonl"
271
+ rm -f "$MISSING_PATH"
272
+ ENV_MISSING=$(envelope_for "$OP_ID" "$MISSING_PATH")
273
+ : > "$REQ_LOG"
274
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_MISSING"
275
+ [[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC"
276
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[system: post-turn-graph-pass-stop-hook\]'; then
277
+ fail "case-5 stderr must not contain dispatch directive"
278
+ fi
279
+ if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=missing-transcript$"; then
280
+ pass "case-5 missing transcript_path → trigger-skipped reason=missing-transcript"
281
+ else
282
+ fail "case-5 expected missing-transcript, got: $(ingest_lines)"
283
+ fi
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Case 6: Dispatch path — admin + populated JSONL → exit 2, dispatch directive
287
+ # on stderr with <conversation> + <prior-writes>, conversationId interpolated.
288
+ # ---------------------------------------------------------------------------
289
+ cat > "$CTX_FILE" <<'JSON'
290
+ {"writes":[{"elementId":"4:abc:1","labels":["KnowledgeDocument"],"properties":{"title":"prior note"}}]}
291
+ JSON
292
+ : > "$REQ_LOG"
293
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_BASIC"
294
+ [[ "$HOOK_RC" -eq 2 ]] || fail "case-6 expected rc=2, got $HOOK_RC"
295
+ if ! printf '%s' "$HOOK_STDERR" | head -n 1 | grep -q '^\[system: post-turn-graph-pass-stop-hook\]'; then
296
+ fail "case-6 stderr must begin with [system: post-turn-graph-pass-stop-hook]"
297
+ fi
298
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '<conversation>'; then
299
+ fail "case-6 stderr missing <conversation> block"
300
+ fi
301
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '<prior-writes>'; then
302
+ fail "case-6 stderr missing <prior-writes> block"
303
+ fi
304
+ if ! printf '%s' "$HOOK_STDERR" | grep -q "conversationId: ${CONV_ID}"; then
305
+ fail "case-6 stderr must interpolate conversationId=${CONV_ID}"
306
+ fi
307
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '\[user: hello\]'; then
308
+ fail "case-6 stderr must carry the user turn '[user: hello]'"
309
+ fi
310
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '\[assistant: hi there\]'; then
311
+ fail "case-6 stderr must carry the assistant turn '[assistant: hi there]'"
312
+ fi
313
+ if ! printf '%s' "$HOOK_STDERR" | grep -q 'KnowledgeDocument'; then
314
+ fail "case-6 stderr <prior-writes> must include fetched labels (KnowledgeDocument)"
315
+ fi
316
+ if ingest_lines | grep -qE "^dispatch sessionId=${OP_ID} conversationId=${CONV_ID} "; then
317
+ pass "case-6 dispatch path → exit 2 + directive with conversation, prior-writes, conversationId"
318
+ else
319
+ fail "case-6 expected dispatch log line, got: $(ingest_lines)"
320
+ fi
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Case 7: Prior-writes fetch fails (context endpoint 500) → exit 2, dispatch
324
+ # still fires, <prior-writes> resolves to `(none)`.
325
+ # ---------------------------------------------------------------------------
326
+ : > "$CTX_FILE"
327
+ : > "$REQ_LOG"
328
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_BASIC"
329
+ [[ "$HOOK_RC" -eq 2 ]] || fail "case-7 expected rc=2, got $HOOK_RC"
330
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '<prior-writes>'; then
331
+ fail "case-7 stderr missing <prior-writes> block"
332
+ fi
333
+ # Extract the prior-writes block and check it contains only `(none)`.
334
+ PRIOR_BODY=$(printf '%s' "$HOOK_STDERR" | awk '/<prior-writes>/{flag=1;next}/<\/prior-writes>/{flag=0}flag' | tr -d '[:space:]')
335
+ if [[ "$PRIOR_BODY" == "(none)" ]]; then
336
+ pass "case-7 context endpoint 500 → dispatch still fires, prior-writes = (none)"
337
+ else
338
+ fail "case-7 expected prior-writes=(none), got [$PRIOR_BODY]"
339
+ fi
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Case 8: CONVERSATION_NODE_ID unset → exit 2, prompt names
343
+ # `conversationId: <unset>`, no GET to /api/admin/post-turn-context.
344
+ # ---------------------------------------------------------------------------
345
+ cat > "$CTX_FILE" <<'JSON'
346
+ {"writes":[]}
347
+ JSON
348
+ : > "$REQ_LOG"
349
+ run_hook "admin" "" "$LISTENER_PORT" "" "$ENV_BASIC"
350
+ [[ "$HOOK_RC" -eq 2 ]] || fail "case-8 expected rc=2, got $HOOK_RC"
351
+ if ! printf '%s' "$HOOK_STDERR" | grep -q 'conversationId: <unset>'; then
352
+ fail "case-8 stderr must contain 'conversationId: <unset>'"
353
+ fi
354
+ GET_COUNT=$(context_get_count)
355
+ if [[ "$GET_COUNT" == "0" ]]; then
356
+ pass "case-8 CONVERSATION_NODE_ID unset → no GET /api/admin/post-turn-context attempted"
357
+ else
358
+ fail "case-8 expected zero context GETs, got: $GET_COUNT"
359
+ fi
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Case 9: Multi-block assistant turn → conversation walk concatenates text
363
+ # blocks; tool_use blocks dropped.
364
+ # ---------------------------------------------------------------------------
365
+ T_MULTI=$(mktemp); TMPFILES+=("$T_MULTI")
366
+ python3 - "$T_MULTI" <<'PY'
367
+ import sys, json
368
+ out = sys.argv[1]
369
+ records = [
370
+ {
371
+ "type": "user",
372
+ "timestamp": "2026-05-24T10:00:00.000Z",
373
+ "message": {"role": "user", "content": "go"},
374
+ },
375
+ {
376
+ "type": "assistant",
377
+ "timestamp": "2026-05-24T10:00:01.000Z",
378
+ "message": {
379
+ "id": "msg_a",
380
+ "role": "assistant",
381
+ "content": [
382
+ {"type": "text", "text": "part-one "},
383
+ {"type": "tool_use", "id": "tu_1", "name": "Read", "input": {}},
384
+ {"type": "text", "text": "part-two"},
385
+ ],
386
+ },
387
+ },
388
+ ]
389
+ with open(out, "w", encoding="utf-8") as f:
390
+ for r in records:
391
+ f.write(json.dumps(r) + "\n")
392
+ PY
393
+ ENV_MULTI=$(envelope_for "$OP_ID" "$T_MULTI")
394
+ cat > "$CTX_FILE" <<'JSON'
395
+ {"writes":[]}
396
+ JSON
397
+ : > "$REQ_LOG"
398
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_MULTI"
399
+ [[ "$HOOK_RC" -eq 2 ]] || fail "case-9 expected rc=2, got $HOOK_RC"
400
+ if ! printf '%s' "$HOOK_STDERR" | grep -q '\[assistant: part-one part-two\]'; then
401
+ fail "case-9 expected concatenated assistant text '[assistant: part-one part-two]'"
402
+ fi
403
+ if printf '%s' "$HOOK_STDERR" | grep -q '"tool_use"'; then
404
+ fail "case-9 tool_use block must not appear in dispatch stderr"
405
+ fi
406
+ if printf '%s' "$HOOK_STDERR" | grep -q '\[assistant: part-one part-two\]'; then
407
+ pass "case-9 multi-block assistant turn → text concatenated, tool_use dropped"
408
+ fi
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Case 10: User turn whose only content is tool_result blocks → skipped in
412
+ # the conversation walk (no `[user: ...]` line for that record).
413
+ # ---------------------------------------------------------------------------
414
+ T_TRONLY=$(mktemp); TMPFILES+=("$T_TRONLY")
415
+ python3 - "$T_TRONLY" <<'PY'
416
+ import sys, json
417
+ out = sys.argv[1]
418
+ records = [
419
+ {
420
+ "type": "user",
421
+ "timestamp": "2026-05-24T10:00:00.000Z",
422
+ "message": {"role": "user", "content": "real user turn"},
423
+ },
424
+ {
425
+ "type": "assistant",
426
+ "timestamp": "2026-05-24T10:00:01.000Z",
427
+ "message": {
428
+ "id": "msg_a",
429
+ "role": "assistant",
430
+ "content": [{"type": "text", "text": "assistant reply"}],
431
+ },
432
+ },
433
+ {
434
+ "type": "user",
435
+ "timestamp": "2026-05-24T10:00:02.000Z",
436
+ "message": {
437
+ "role": "user",
438
+ "content": [
439
+ {"type": "tool_result", "tool_use_id": "tu_x", "content": "tool plumbing payload"},
440
+ ],
441
+ },
442
+ },
443
+ ]
444
+ with open(out, "w", encoding="utf-8") as f:
445
+ for r in records:
446
+ f.write(json.dumps(r) + "\n")
447
+ PY
448
+ ENV_TRONLY=$(envelope_for "$OP_ID" "$T_TRONLY")
449
+ : > "$REQ_LOG"
450
+ run_hook "admin" "" "$LISTENER_PORT" "$CONV_ID" "$ENV_TRONLY"
451
+ [[ "$HOOK_RC" -eq 2 ]] || fail "case-10 expected rc=2, got $HOOK_RC"
452
+ CONV_BLOCK=$(printf '%s' "$HOOK_STDERR" | awk '/<conversation>/{flag=1;next}/<\/conversation>/{flag=0}flag')
453
+ if ! printf '%s' "$CONV_BLOCK" | grep -q '\[user: real user turn\]'; then
454
+ fail "case-10 conversation block must include real user turn"
455
+ fi
456
+ if printf '%s' "$CONV_BLOCK" | grep -q 'tool plumbing payload'; then
457
+ fail "case-10 tool_result-only user record must not appear in conversation walk"
458
+ fi
459
+ # The heredoc indents the conversation block; match leading whitespace.
460
+ TR_LINES=$(printf '%s\n' "$CONV_BLOCK" | grep -cE '\[user:' || true)
461
+ if [[ "$TR_LINES" == "1" ]]; then
462
+ pass "case-10 tool_result-only user record skipped in conversation walk"
463
+ else
464
+ fail "case-10 expected exactly one [user: ...] line, got $TR_LINES"
465
+ fi
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Summary
469
+ # ---------------------------------------------------------------------------
470
+ echo
471
+ echo "post-turn-graph-pass tests: $PASS passed, $FAIL failed"
472
+ [[ "$FAIL" -eq 0 ]]
@@ -294,7 +294,7 @@ write_transcript "$T_RELEASE" \
294
294
  "assistant|hello|" \
295
295
  "user|/end|" \
296
296
  "assistant|running retrospective|" \
297
- "assistant||tool_use:session-retrospective-mark-complete"
297
+ "assistant||tool_use:mcp__admin__session-retrospective-mark-complete"
298
298
  ENV_REL=$(envelope_for "$OP_ID" "$T_RELEASE")
299
299
  : > "$REQ_LOG"
300
300
  run_hook "admin" "" "$ENV_REL"
@@ -325,7 +325,7 @@ fi
325
325
  T_OTHER=$(mktemp); TMPFILES+=("$T_OTHER")
326
326
  write_transcript "$T_OTHER" \
327
327
  "user|/end|" \
328
- "assistant||tool_use:session-retrospective-mark-complete"
328
+ "assistant||tool_use:mcp__admin__session-retrospective-mark-complete"
329
329
  # Active gate inspects only T_ACTIVE (no sentinel inside).
330
330
  T_ACTIVE=$(mktemp); TMPFILES+=("$T_ACTIVE")
331
331
  write_transcript "$T_ACTIVE" "user|/end|"
@@ -20,7 +20,10 @@
20
20
  #
21
21
  # Sentinel detection. Completion is recognised by greping the operator's
22
22
  # own JSONL (transcript_path on the Stop envelope) for a `tool_use` block
23
- # whose `name` is exactly `session-retrospective-mark-complete`. The
23
+ # whose `name` is exactly `mcp__admin__session-retrospective-mark-complete`
24
+ # — that is the prefixed form Claude Code writes for MCP tools in the
25
+ # JSONL; the unprefixed name only appears in the operator-facing
26
+ # instruction text below. The
24
27
  # sentinel-grep doubles as the re-entry guard: every Stop that fires after
25
28
  # the agent's sentinel call sees the sentinel and exits 0 with
26
29
  # `gate-released`, so the agent's wrap-up turn never re-blocks.
@@ -128,12 +131,12 @@ fi
128
131
  # 1. The latest end-intent token in the most recent real-user message,
129
132
  # or empty if no real user message matches.
130
133
  # 2. Whether any `tool_use` block in the transcript names the sentinel
131
- # tool `session-retrospective-mark-complete`.
134
+ # tool `mcp__admin__session-retrospective-mark-complete`.
132
135
  # Output one line: <intent-token-or-empty>\t<sentinel-present-yes|no>
133
136
  INSPECTION=$(TRANSCRIPT_PATH="$TRANSCRIPT_PATH" python3 - <<'PY'
134
137
  import os, json
135
138
 
136
- SENTINEL_TOOL = "session-retrospective-mark-complete"
139
+ SENTINEL_TOOL = "mcp__admin__session-retrospective-mark-complete"
137
140
  INTENT_TOKENS = ("/end", "/archive", "end session", "archive this session")
138
141
 
139
142
  def is_real_user(rec):
@@ -866,11 +866,11 @@ if (!readOnly) {
866
866
  };
867
867
  }
868
868
  });
869
- server.tool("memory-update", "Update properties on an existing node in the knowledge graph. Finds the node by element ID, verifies account ownership, sets the provided properties, and recomputes the embedding. For :Person / :Organization / :Concept / :Project / :LocalBusiness, pass rewriteCompiledTruth=true to recompute the admin compiledTruth summary; when the label is in the public-twin set (:Organization / :Concept / :Project / :LocalBusiness) and the admin pass changes the truth, the public-twin compiledTruthPublic auto-rewrites in the same call. Pass compiledTruthPublic to override the public twin directly via chat. Direct writes to compiledTruth / compiledTruthUpdatedAt / compiledTruthSource / compiledTruthPublic / compiledTruthPublicUpdatedAt / compiledTruthPublicSource via the properties bag are refused — use the dedicated arguments.", {
869
+ server.tool("memory-update", "Update properties on an existing node in the knowledge graph. Finds the node by element ID, verifies account ownership, sets the provided properties, and recomputes the embedding. For :Person / :Organization / :Concept / :Project / :LocalBusiness, pass rewriteCompiledTruth=true to recompute the admin compiledTruth summary; when the label is in the public-twin set (:Organization / :Concept / :Project / :LocalBusiness) and the admin pass changes the truth, the public-twin compiledTruthPublic auto-rewrites in the same call. Pair rewriteCompiledTruth=true with operatorHint to feed the rewriter an operator correction from chat for the admin twin — the resulting write is stamped compiledTruthSource='operator', the 7-day cooldown does not block it, and the public-twin auto-fire is skipped. Pair rewriteCompiledTruth=true with publicOperatorHint to do the same for the public twin the public rewriter is invoked with the hint folded into the public-mode prompt regardless of whether the admin pass changed the truth, the public-cooldown is bypassed, and the write is stamped compiledTruthPublicSource='operator'. Direct writes to compiledTruth / compiledTruthUpdatedAt / compiledTruthSource / compiledTruthPublic / compiledTruthPublicUpdatedAt / compiledTruthPublicSource via the properties bag are refused — the rewriter is the only writer.", {
870
870
  nodeId: z.string().describe("Element ID of the node to update (from memory-search results)"),
871
871
  properties: z
872
872
  .record(z.string(), z.unknown())
873
- .describe("Properties to set or overwrite. Restricted keys (embedding, accountId, createdAt) are ignored. Compiled-truth fields are system-managed and rejected loudly here — use rewriteCompiledTruth or the compiledTruthPublic argument."),
873
+ .describe("Properties to set or overwrite. Restricted keys (embedding, accountId, createdAt) are ignored. Compiled-truth fields are system-managed and rejected loudly here — use rewriteCompiledTruth (optionally with operatorHint or publicOperatorHint)."),
874
874
  rewriteCompiledTruth: z
875
875
  .boolean()
876
876
  .optional()
@@ -879,11 +879,15 @@ if (!readOnly) {
879
879
  .boolean()
880
880
  .optional()
881
881
  .describe("Bypass the 7-day cooldown that protects operator-edited compiledTruth and compiledTruthPublic fields from automatic rewriter passes. Set only when the operator explicitly asks for a refresh."),
882
- compiledTruthPublic: z
882
+ operatorHint: z
883
883
  .string()
884
884
  .optional()
885
- .describe("Operator override for the public-facing compiled-truth twin. Writes the supplied string verbatim, stamps source='operator', and resets the public-twin cooldown. Length cap: 2000 characters. Only valid on :Organization / :Concept / :Project / :LocalBusiness (:Person has no public twin)."),
886
- }, async ({ nodeId, properties, rewriteCompiledTruth, forceRewrite, compiledTruthPublic }) => {
885
+ .describe("Operator correction for the admin twin, supplied via chat. Only valid with rewriteCompiledTruth=true. Folded into the rewriter prompt with precedence over the timeline; the resulting write is stamped compiledTruthSource='operator', the 7-day cooldown does not block this pass, and the public-twin auto-fire is skipped so admin-side corrections do not leak unreviewed to the public surface. Cap: 1000 characters."),
886
+ publicOperatorHint: z
887
+ .string()
888
+ .optional()
889
+ .describe("Operator correction for the public twin, supplied via chat. Only valid with rewriteCompiledTruth=true AND a public-twin label (:Organization / :Concept / :Project / :LocalBusiness — :Person has no public twin). Folded into the public-mode rewriter prompt with precedence over the timeline; the public rewriter fires regardless of whether the admin pass changed the truth, the public-cooldown is bypassed, and the write is stamped compiledTruthPublicSource='operator'. Cap: 1000 characters."),
890
+ }, async ({ nodeId, properties, rewriteCompiledTruth, forceRewrite, operatorHint, publicOperatorHint }) => {
887
891
  if (!accountId)
888
892
  return refuseNoAccount("memory-update");
889
893
  try {
@@ -898,13 +902,15 @@ if (!readOnly) {
898
902
  },
899
903
  rewriteCompiledTruth,
900
904
  forceRewrite,
901
- compiledTruthPublic,
905
+ operatorHint,
906
+ publicOperatorHint,
902
907
  });
903
908
  const rewriterLine = (() => {
904
909
  if (!result.rewriter)
905
910
  return "";
906
911
  if (result.rewriter.kind === "ok") {
907
- return `\nAdmin compiled-truth rewritten: ${result.rewriter.charsBefore} ${result.rewriter.charsAfter} chars.`;
912
+ const sourceFragment = result.rewriter.source === "operator" ? " (operator-attributed)" : "";
913
+ return `\nAdmin compiled-truth rewritten${sourceFragment}: ${result.rewriter.charsBefore} → ${result.rewriter.charsAfter} chars.`;
908
914
  }
909
915
  if (result.rewriter.kind === "refused") {
910
916
  return result.rewriter.reason === "operator-edit-cooldown"
@@ -920,7 +926,8 @@ if (!readOnly) {
920
926
  if (!result.publicRewriter)
921
927
  return "";
922
928
  if (result.publicRewriter.kind === "ok") {
923
- return `\nPublic compiled-truth rewritten: ${result.publicRewriter.charsBefore} ${result.publicRewriter.charsAfter} chars.`;
929
+ const sourceFragment = result.publicRewriter.source === "operator" ? " (operator-attributed)" : "";
930
+ return `\nPublic compiled-truth rewritten${sourceFragment}: ${result.publicRewriter.charsBefore} → ${result.publicRewriter.charsAfter} chars.`;
924
931
  }
925
932
  if (result.publicRewriter.kind === "refused") {
926
933
  return result.publicRewriter.reason === "operator-edit-cooldown"
@@ -932,21 +939,11 @@ if (!readOnly) {
932
939
  }
933
940
  return `\nPublic rewriter LLM call failed (cause=${result.publicRewriter.cause}); compiledTruthPublic unchanged.`;
934
941
  })();
935
- const overrideLine = (() => {
936
- if (!result.publicOverride)
937
- return "";
938
- if (result.publicOverride.kind === "applied") {
939
- return `\ncompiledTruthPublic overridden by operator: ${result.publicOverride.chars} chars.`;
940
- }
941
- return result.publicOverride.reason === "wrong-label"
942
- ? `\ncompiledTruthPublic override refused: label is not in the public-twin set (:Organization / :Concept / :Project / :LocalBusiness).`
943
- : `\ncompiledTruthPublic override refused: exceeds 2000 character cap.`;
944
- })();
945
942
  return {
946
943
  content: [
947
944
  {
948
945
  type: "text",
949
- text: `Node updated: [${result.labels.join(", ")}] (ID: ${result.nodeId})${rewriterLine}${publicRewriterLine}${overrideLine}`,
946
+ text: `Node updated: [${result.labels.join(", ")}] (ID: ${result.nodeId})${rewriterLine}${publicRewriterLine}`,
950
947
  },
951
948
  ],
952
949
  };
@@ -2348,6 +2345,11 @@ server.tool("memory-typed-edge-pass", "Run the typed-edge auto-extraction pass o
2348
2345
  return refuseNoAccount("memory-typed-edge-pass");
2349
2346
  const dbSession = getSession();
2350
2347
  try {
2348
+ // Cast: the McpServer instance is `any`-typed at the module level (see
2349
+ // file-top comment on TS2589), so `args` infers as the loose
2350
+ // ShapeOutput<ZodRawShapeCompat> instead of the Zod schema above.
2351
+ // Runtime shape is enforced by the schema; this re-asserts the typed
2352
+ // contract at the consumer boundary. Task 404.
2351
2353
  const result = await memoryTypedEdgePass(args, dbSession, accountId);
2352
2354
  return {
2353
2355
  content: [{