@rubytech/create-maxy-code 0.1.80 → 0.1.82

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 (89) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-memory-write-revoke.test.sh +130 -0
  3. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +19 -17
  4. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +34 -0
  5. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +8 -16
  6. package/payload/platform/plugins/admin/skills/access-manager/SKILL.md +2 -2
  7. package/payload/platform/plugins/admin/skills/access-manager/references/operations.md +10 -25
  8. package/payload/platform/plugins/admin/skills/business-profile/SKILL.md +14 -14
  9. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +5 -5
  10. package/payload/platform/plugins/docs/references/admin-session.md +4 -4
  11. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +20 -14
  13. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  14. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -0
  16. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  17. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -0
  18. package/payload/platform/templates/specialists/agents/database-operator.md +1 -1
  19. package/payload/server/public/assets/{admin-Bn3SKHdv.js → admin-Dl273yTS.js} +1 -1
  20. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-VMeuB5OP.js → architectureDiagram-Q4EWVU46-Iyf6H93s.js} +1 -1
  21. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-B7mq0Me0.js → blockDiagram-DXYQGD6D-e7l5Mhun.js} +1 -1
  22. package/payload/server/public/assets/{brand-CZQNuuLo.css → brand-CTMGYFDd.css} +1 -1
  23. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Btu6ejD1.js → c4Diagram-AHTNJAMY-bDICnX2_.js} +1 -1
  24. package/payload/server/public/assets/channel-CkgyfA91.js +1 -0
  25. package/payload/server/public/assets/{chunk-336JU56O-DAL_d_Xa.js → chunk-336JU56O-CbnUj19H.js} +2 -2
  26. package/payload/server/public/assets/{chunk-426QAEUC-DPD9ot_N.js → chunk-426QAEUC-DxGLqjw5.js} +1 -1
  27. package/payload/server/public/assets/{chunk-4TB4RGXK-BexM9T-f.js → chunk-4TB4RGXK-CzvvHuMJ.js} +1 -1
  28. package/payload/server/public/assets/{chunk-5FUZZQ4R-EwDdji3T.js → chunk-5FUZZQ4R-DxqHPDro.js} +1 -1
  29. package/payload/server/public/assets/{chunk-5PVQY5BW-BP9uoVws.js → chunk-5PVQY5BW-BA6nMu9F.js} +1 -1
  30. package/payload/server/public/assets/{chunk-EDXVE4YY-DSFmUMEW.js → chunk-EDXVE4YY-gUSTktie.js} +1 -1
  31. package/payload/server/public/assets/{chunk-ENJZ2VHE-IVAy2Qk9.js → chunk-ENJZ2VHE-C-wW17kd.js} +1 -1
  32. package/payload/server/public/assets/{chunk-ICPOFSXX-DkUupPox.js → chunk-ICPOFSXX-zLc9NsO5.js} +1 -1
  33. package/payload/server/public/assets/{chunk-OYMX7WX6-BPnzam8G.js → chunk-OYMX7WX6-Bt9RJ_rb.js} +1 -1
  34. package/payload/server/public/assets/{chunk-U2HBQHQK-xYNchOVT.js → chunk-U2HBQHQK-BAM6MO6g.js} +1 -1
  35. package/payload/server/public/assets/{chunk-X2U36JSP-C1V_V8v8.js → chunk-X2U36JSP-DbOwgg1R.js} +1 -1
  36. package/payload/server/public/assets/{chunk-YZCP3GAM-CUibW87w.js → chunk-YZCP3GAM-BXUfYLeM.js} +1 -1
  37. package/payload/server/public/assets/{chunk-ZZ45TVLE-HkKx4xBt.js → chunk-ZZ45TVLE-BBIHnMjI.js} +1 -1
  38. package/payload/server/public/assets/classDiagram-6PBFFD2Q-PyvJekA9.js +1 -0
  39. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-6hFNhiTb.js +1 -0
  40. package/payload/server/public/assets/clone-CFIJrRLl.js +1 -0
  41. package/payload/server/public/assets/{dagre-DIaJ9oMM.js → dagre-Bv8aHCaJ.js} +1 -1
  42. package/payload/server/public/assets/{dagre-KV5264BT-Oyox5ACd.js → dagre-KV5264BT-BZ0q7dDA.js} +1 -1
  43. package/payload/server/public/assets/data-BgADjmk1.js +1 -0
  44. package/payload/server/public/assets/{device-url-actions-BjDrEVoY.js → device-url-actions-CK6x8-FQ.js} +1 -1
  45. package/payload/server/public/assets/{diagram-5BDNPKRD-DoGhkzBd.js → diagram-5BDNPKRD-Bbzy2KoW.js} +1 -1
  46. package/payload/server/public/assets/{diagram-G4DWMVQ6-Dc3Ix-Dh.js → diagram-G4DWMVQ6-CadVHWrV.js} +1 -1
  47. package/payload/server/public/assets/{diagram-MMDJMWI5-CFM9_Ggj.js → diagram-MMDJMWI5-DwJ6fVMv.js} +1 -1
  48. package/payload/server/public/assets/{diagram-TYMM5635-nMWgzxC3.js → diagram-TYMM5635-Bh8d1vg-.js} +1 -1
  49. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DcQWiGgF.js → erDiagram-SMLLAGMA-B1oTY2ob.js} +1 -1
  50. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CcX5waal.js → flowDiagram-DWJPFMVM-BZU76iLs.js} +1 -1
  51. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-BTQWoD8W.js → ganttDiagram-T4ZO3ILL-PjhJx62i.js} +1 -1
  52. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHZcRznS.js → gitGraphDiagram-UUTBAWPF-DLrxzJYi.js} +1 -1
  53. package/payload/server/public/assets/graph-B1_erzSo.js +1 -0
  54. package/payload/server/public/assets/{graph-labels-CqsRzbp0.js → graph-labels-CpFvcba2.js} +1 -1
  55. package/payload/server/public/assets/{graphlib-BpUb9z9y.js → graphlib-D2ExKXiT.js} +1 -1
  56. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CBoiO8yF.js → infoDiagram-42DDH7IO-Cn0r9nCK.js} +1 -1
  57. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-mTDI_0As.js → ishikawaDiagram-UXIWVN3A-Ed8ANWEq.js} +1 -1
  58. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CcuU_0rp.js → journeyDiagram-VCZTEJTY-DF1gG775.js} +1 -1
  59. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-BWA7Do5Y.js → kanban-definition-6JOO6SKY-D8VcaKAp.js} +1 -1
  60. package/payload/server/public/assets/{line-BlGptbDR.js → line-CgMZIaVM.js} +1 -1
  61. package/payload/server/public/assets/{mermaid-parser.core-hslUiqJ-.js → mermaid-parser.core-ez9lChuL.js} +1 -1
  62. package/payload/server/public/assets/{mermaid.core-DWU_5Faf.js → mermaid.core-BiVW_Lka.js} +3 -3
  63. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-B3_sQr-2.js → mindmap-definition-QFDTVHPH-C-BlLBbo.js} +1 -1
  64. package/payload/server/public/assets/{page-eQPbgLXP.js → page-BQCQwUG5.js} +1 -1
  65. package/payload/server/public/assets/{page-lPEIoiHs.js → page-DStDaJgc.js} +1 -1
  66. package/payload/server/public/assets/{pieDiagram-DEJITSTG-02u2dI6Z.js → pieDiagram-DEJITSTG-BK0-LxVl.js} +1 -1
  67. package/payload/server/public/assets/{public-j7U67c6E.js → public-DMk9cZKl.js} +3 -3
  68. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BfgiMQiy.js → quadrantDiagram-34T5L4WZ-vKAQHu-t.js} +1 -1
  69. package/payload/server/public/assets/{requirementDiagram-MS252O5E-JKZoibIp.js → requirementDiagram-MS252O5E-Df9sXR35.js} +1 -1
  70. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-N1iaY19u.js → sankeyDiagram-XADWPNL6-DG6xirA4.js} +1 -1
  71. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DjU3oQ0_.js → sequenceDiagram-FGHM5R23-B1a38KHX.js} +1 -1
  72. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BuFcldiV.js → stateDiagram-FHFEXIEX-CRiIDR4O.js} +1 -1
  73. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-C8cVmi_o.js +1 -0
  74. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-3vztYjFm.js → timeline-definition-GMOUNBTQ-CkBOE7Y8.js} +1 -1
  75. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Zdv9WVqJ.js → vennDiagram-DHZGUBPP-CwKBydYc.js} +1 -1
  76. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CkTAZI7f.js → wardleyDiagram-NUSXRM2D-BWXYfzIr.js} +1 -1
  77. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CgMCxpms.js → xychartDiagram-5P7HB3ND-D-4v5Dy_.js} +1 -1
  78. package/payload/server/public/data.html +5 -5
  79. package/payload/server/public/graph.html +5 -5
  80. package/payload/server/public/index.html +7 -7
  81. package/payload/server/public/public.html +4 -4
  82. package/payload/server/public/assets/channel-C_bSLH4h.js +0 -1
  83. package/payload/server/public/assets/classDiagram-6PBFFD2Q-B6oW43xg.js +0 -1
  84. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-D0237d4h.js +0 -1
  85. package/payload/server/public/assets/clone-AMRCmnen.js +0 -1
  86. package/payload/server/public/assets/data-Mm3pTL1b.js +0 -1
  87. package/payload/server/public/assets/graph-Cd4jsDi3.js +0 -1
  88. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-rZLYem3J.js +0 -1
  89. /package/payload/server/public/assets/{brand-BnMilSyt.js → brand-dPgPNs7n.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bash
2
+ # Task 213 regression test for the memory-write / memory-update revoke
3
+ # block in pre-tool-use.sh. The block sits at the top of the admin branch
4
+ # and ensures any admin tool_use targeting the two writers exits 2 with
5
+ # stderr explaining the recorder ownership, and emits one structured
6
+ # `admin-tool-revoke` line through `/api/admin/log-ingest`.
7
+
8
+ set -u
9
+
10
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/pre-tool-use.sh"
11
+ if [[ ! -x "$HOOK" ]]; then
12
+ echo "FAIL: $HOOK not executable" >&2
13
+ exit 1
14
+ fi
15
+
16
+ TMPFILES=()
17
+ LISTENER_PIDS=()
18
+ cleanup() {
19
+ for pid in "${LISTENER_PIDS[@]:-}"; do
20
+ [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
21
+ [[ -n "$pid" ]] && wait "$pid" 2>/dev/null || true
22
+ done
23
+ for f in "${TMPFILES[@]:-}"; do
24
+ [[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
25
+ done
26
+ }
27
+ trap cleanup EXIT
28
+
29
+ PASS=0
30
+ FAIL=0
31
+ pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
32
+ fail() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
33
+
34
+ start_listener() {
35
+ REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
36
+ LISTENER_PORT=$((39500 + RANDOM % 100))
37
+ python3 - "$LISTENER_PORT" "$REQ_LOG" <<'PY' &
38
+ import sys, http.server
39
+ port = int(sys.argv[1]); log = sys.argv[2]
40
+ class H(http.server.BaseHTTPRequestHandler):
41
+ def log_message(self, *a, **k): pass
42
+ def do_POST(self):
43
+ n = int(self.headers.get('Content-Length','0') or 0)
44
+ body = self.rfile.read(n).decode('utf-8','replace')
45
+ with open(log,'a',encoding='utf-8') as f:
46
+ f.write(self.path + '\t' + body + '\n')
47
+ self.send_response(200); self.end_headers(); self.wfile.write(b'{}')
48
+ http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()
49
+ PY
50
+ LISTENER_PIDS+=("$!")
51
+ for _ in $(seq 1 20); do
52
+ curl -sS --max-time 1 -X POST "http://127.0.0.1:${LISTENER_PORT}/ping" -d '{}' >/dev/null 2>&1 && break
53
+ sleep 0.1
54
+ done
55
+ : > "$REQ_LOG"
56
+ }
57
+
58
+ run_with_tool() {
59
+ local tool="$1"
60
+ local sid="$2"
61
+ local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
62
+ local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
63
+ python3 -c '
64
+ import json, sys
65
+ print(json.dumps({
66
+ "hook_event_name": "PreToolUse",
67
+ "session_id": sys.argv[1],
68
+ "tool_name": sys.argv[2],
69
+ "tool_input": {"name":"Smalleys","accountId":"acct-x","scope":"shared"},
70
+ }, separators=(",", ":")))
71
+ ' "$sid" "$tool" | \
72
+ MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
73
+ bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
74
+ HOOK_RC=$?
75
+ HOOK_STDERR=$(cat "$stderr_file")
76
+ sleep 0.1 # let listener flush
77
+ }
78
+
79
+ start_listener
80
+
81
+ # --- memory-write rejected ---------------------------------------------
82
+ : > "$REQ_LOG"
83
+ run_with_tool "mcp__plugin_memory_memory__memory-write" "sess-abc"
84
+ [[ "$HOOK_RC" -eq 2 ]] || fail "memory-write expected exit 2, got $HOOK_RC"
85
+ echo "$HOOK_STDERR" | grep -qF "admin agent does not write to the graph" \
86
+ || fail "memory-write stderr missing rationale, got: $HOOK_STDERR"
87
+ if grep -qE '/api/admin/log-ingest .*"tag":\s*"admin-tool-revoke".*sessionId=sess-abc.*tool=mcp__plugin_memory_memory__memory-write.*source=permission-hook' "$REQ_LOG"; then
88
+ pass "memory-write rejection emits admin-tool-revoke with sessionId, tool, source"
89
+ else
90
+ fail "memory-write rejection did not emit admin-tool-revoke; log: $(cat "$REQ_LOG")"
91
+ fi
92
+
93
+ # --- memory-update rejected --------------------------------------------
94
+ : > "$REQ_LOG"
95
+ run_with_tool "mcp__plugin_memory_memory__memory-update" "sess-xyz"
96
+ [[ "$HOOK_RC" -eq 2 ]] || fail "memory-update expected exit 2, got $HOOK_RC"
97
+ if grep -qE '/api/admin/log-ingest .*"tag":\s*"admin-tool-revoke".*sessionId=sess-xyz.*tool=mcp__plugin_memory_memory__memory-update.*source=permission-hook' "$REQ_LOG"; then
98
+ pass "memory-update rejection emits admin-tool-revoke with sessionId, tool, source"
99
+ else
100
+ fail "memory-update rejection did not emit admin-tool-revoke; log: $(cat "$REQ_LOG")"
101
+ fi
102
+
103
+ # --- memory-search (allowed read tool) NOT rejected --------------------
104
+ : > "$REQ_LOG"
105
+ run_with_tool "mcp__plugin_memory_memory__memory-search" "sess-read"
106
+ [[ "$HOOK_RC" -eq 0 ]] || fail "memory-search expected exit 0, got $HOOK_RC stderr: $HOOK_STDERR"
107
+ if grep -qE '"tag":\s*"admin-tool-revoke"' "$REQ_LOG"; then
108
+ fail "memory-search must NOT emit admin-tool-revoke; log: $(cat "$REQ_LOG")"
109
+ else
110
+ pass "memory-search passes through; no admin-tool-revoke emitted"
111
+ fi
112
+
113
+ # --- the old mcp__memory__ namespace is NOT matched -------------------
114
+ # The revoke block targets the current runtime namespace literally; the
115
+ # old namespace was never the surface admin saw, so matching it would
116
+ # be a regression.
117
+ : > "$REQ_LOG"
118
+ run_with_tool "mcp__memory__memory-write" "sess-old-ns"
119
+ [[ "$HOOK_RC" -eq 0 ]] || fail "pre-209 namespace expected exit 0, got $HOOK_RC"
120
+ if grep -qE '"tag":\s*"admin-tool-revoke"' "$REQ_LOG"; then
121
+ fail "pre-209 namespace must NOT emit admin-tool-revoke"
122
+ else
123
+ pass "pre-209 mcp__memory__memory-write passes through (block is namespace-literal)"
124
+ fi
125
+
126
+ # --- Summary ------------------------------------------------------------
127
+ echo "---"
128
+ echo "PASSED: $PASS FAILED: $FAIL"
129
+ [[ "$FAIL" -eq 0 ]] || exit 1
130
+ exit 0
@@ -14,10 +14,11 @@
14
14
  # is the FILLED database-operator paragraph — the four-sentence
15
15
  # template with `{schema}` replaced by `schema-base.md` (plus the
16
16
  # brand-declared vertical when set) and `{conversation}` replaced by
17
- # a flat `role: text` rendering of the envelope's `turns` array with
18
- # inline `[tool: name(args)]` markers on assistant turns that called
19
- # tools. The initialMessage is a plain string, not a JSON-stringified
20
- # envelope; no instruction prose, no leading JSON.
17
+ # a flat `role: text` rendering of the envelope's `turns` array.
18
+ # Task 213 strips the prior `[tool: name(args)]` markers they
19
+ # taught the recorder to copy the shape into prose. The initialMessage
20
+ # is a plain string, not a JSON-stringified envelope; no instruction
21
+ # prose, no leading JSON.
21
22
  # - The hook emits exactly one `[turn-recorder] envelope ...` line and
22
23
  # one `[turn-recorder] substitution ...` line before `spawn-request`.
23
24
  # The substitution line carries `schemaBytes`, `conversationBytes`,
@@ -406,10 +407,12 @@ else
406
407
  fail "case-6 multi-block collapse wrong ($MULTI_OK): $MULTI_BODY"
407
408
  fi
408
409
 
409
- # --- Case 7 (Task 177): tool-call pairing on owning assistant turn -----
410
- # Assistant tool_use followed by user tool_result. Both attach as ONE
411
- # `toolCalls` entry on the assistant turn that owned the tool_use.
412
- # The tool_result-only user record does NOT create a separate user turn.
410
+ # --- Case 7 (Task 177 + Task 213): tool-call pairing kept in envelope, -
411
+ # stripped from the transcript. Assistant tool_use + user tool_result still
412
+ # fold into one `toolCalls` entry on the assistant turn (envelope-level
413
+ # observability via `[turn-recorder] envelope toolCallTurns=N`). The
414
+ # rendered transcript carries NO `[tool: …]` markers — pre-213 they taught
415
+ # the recorder to emit `<tool_call>` as text.
413
416
  TOOL_TRANSCRIPT=$(mktemp); TMPFILES+=("$TOOL_TRANSCRIPT")
414
417
  python3 - "$TOOL_TRANSCRIPT" <<'PY'
415
418
  import json, sys
@@ -454,14 +457,13 @@ try:
454
457
  # text body), one carrying "done".
455
458
  conds.append(("two-asst-lines", msg.count("\nassistant: ") == 2))
456
459
  conds.append(("has-asst-done", "\nassistant: done" in msg))
457
- # Tool-call marker on the owning assistant turn name and full JSON
458
- # input round-trip inline.
459
- conds.append(("has-tool-marker", "[tool: mcp__memory__memory-write(" in msg))
460
- conds.append(("tool-marker-name", "\"name\": \"Smalleys\"" in msg or "\"name\":\"Smalleys\"" in msg))
461
- conds.append(("tool-marker-nested", "\"nested\"" in msg))
462
- conds.append(("tool-marker-nodeType", "\"nodeType\": \"LocalBusiness\"" in msg or "\"nodeType\":\"LocalBusiness\"" in msg))
463
- # The tool_result content does NOT bleed in (the rendering chose
464
- # input-only markers; output goes nowhere in the filled prompt).
460
+ # Task 213 no `[tool: …]` markup in the rendered transcript.
461
+ conds.append(("no-tool-marker", "[tool:" not in msg))
462
+ conds.append(("no-tool-input-leak", "Smalleys" not in msg))
463
+ # The owning assistant turn lands as an empty-bodied line: the tool_use
464
+ # block contributed no text, and the marker that used to fill it is gone.
465
+ conds.append(("has-empty-asst-line", "\nassistant: \n" in msg))
466
+ # The tool_result content does not bleed in either.
465
467
  conds.append(("no-output-leakage", "ok: wrote LocalBusiness" not in msg))
466
468
  failed = [name for name, ok in conds if not ok]
467
469
  print("yes" if not failed else "no:"+repr(failed))
@@ -469,7 +471,7 @@ except Exception as e:
469
471
  print("parse-fail:"+str(e))
470
472
  ' 2>/dev/null)
471
473
  if [[ "$TOOL_OK" == "yes" ]]; then
472
- pass "case-7 assistant tool_use renders as [tool: name(args)] marker on the owning turn; tool_result does not create a separate user line"
474
+ pass "case-7 assistant tool_use leaves no [tool: ] markup in transcript; tool_result does not create a separate user line; toolCallTurns count survives via envelope log"
473
475
  else
474
476
  fail "case-7 tool pairing wrong ($TOOL_OK): $TOOL_BODY"
475
477
  fi
@@ -21,6 +21,40 @@ TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"'
21
21
  # ---------------------------------------------------------------------------
22
22
  if [ "$AGENT_TYPE" = "admin" ]; then
23
23
 
24
+ # ── Task 213: memory-write/memory-update revocation ──────────────────────
25
+ # The admin agent does not write to the graph; the Stop-hook recorder
26
+ # (database-operator specialist) owns these tools. Session d2aaa85e
27
+ # 2026-05-21 showed admin emitting `tool_use: memory-update`, filling in
28
+ # when the recorder produced no tool_use. `pty-spawner.ts` already passes
29
+ # `--disallowed-tools` on the spawn argv; this hook is the observability
30
+ # surface — every relapse lands in server.log as `[admin-tool-revoke]`,
31
+ # which the `--disallowed-tools` path itself does not produce.
32
+ case "$TOOL_NAME" in
33
+ mcp__plugin_memory_memory__memory-write|mcp__plugin_memory_memory__memory-update)
34
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c '
35
+ import sys, json
36
+ try:
37
+ d = json.load(sys.stdin)
38
+ print(d.get("session_id", "") or "unknown")
39
+ except Exception:
40
+ print("unknown")
41
+ ' 2>/dev/null || echo "unknown")
42
+ UI_PORT="${MAXY_UI_INTERNAL_PORT:-}"
43
+ if [ -n "$UI_PORT" ]; then
44
+ curl -sS -o /dev/null -X POST \
45
+ -H 'Content-Type: application/json' \
46
+ --max-time 2 \
47
+ --data "$(python3 -c '
48
+ import sys, json
49
+ print(json.dumps({"tag":"admin-tool-revoke","level":"info","line":f"role=admin sessionId={sys.argv[1]} tool={sys.argv[2]} result=rejected source=permission-hook"}))
50
+ ' "$SESSION_ID" "$TOOL_NAME")" \
51
+ "http://127.0.0.1:${UI_PORT}/api/admin/log-ingest" 2>/dev/null || true
52
+ fi
53
+ echo "Blocked: admin agent does not write to the graph. The Stop-hook database-operator recorder owns memory-write and memory-update." >&2
54
+ exit 2
55
+ ;;
56
+ esac
57
+
24
58
  # ── Code directory protection ────────────────────────────────────────────
25
59
  case "$TOOL_NAME" in
26
60
  Write|Edit)
@@ -336,9 +336,13 @@ fi
336
336
  # `{conversation}` placeholders);
337
337
  # - reads schema-base.md (and concatenates the vertical when set);
338
338
  # - renders the envelope's `turns` array into a flat transcript — one
339
- # line per turn, `role: text`, with inline `[tool: name(args)]`
340
- # markers where the assistant called tools (so the model sees the
341
- # tool-shape it has been calling without having to read JSON);
339
+ # line per turn, `role: text`. The recorder is the only writer to the
340
+ # graph; rendering the operator's tool_use blocks inline taught the
341
+ # model (Task 213 evidence in sessions d2aaa85e / 481799d1) to copy
342
+ # the shape and emit `<tool_call>{…}</tool_call>` as text instead of
343
+ # real tool_use blocks. The envelope's `toolCalls` are still captured
344
+ # for the `[turn-recorder] envelope … toolCallTurns=N` observability
345
+ # line; they no longer reach the spawn prompt;
342
346
  # - substitutes both placeholders in the paragraph;
343
347
  # - emits one tab-separated line of byte counts, then the
344
348
  # spawn-body JSON containing the filled paragraph as `initialMessage`.
@@ -372,19 +376,7 @@ lines = []
372
376
  for t in turns:
373
377
  role = t.get("role", "?")
374
378
  text_t = t.get("text", "") or ""
375
- marks = []
376
- for tc in (t.get("toolCalls") or []):
377
- tool = tc.get("tool", "?")
378
- inp = tc.get("input")
379
- try:
380
- inp_s = json.dumps(inp, ensure_ascii=False) if inp is not None else ""
381
- except Exception:
382
- inp_s = str(inp)
383
- marks.append(f"[tool: {tool}({inp_s})]")
384
- if marks:
385
- lines.append(f"{role}: {text_t} {' '.join(marks)}".rstrip())
386
- else:
387
- lines.append(f"{role}: {text_t}")
379
+ lines.append(f"{role}: {text_t}")
388
380
  # Wrap the transcript block in newlines so each turn lands on its own
389
381
  # line in the filled prompt — the template's `{conversation}` sits
390
382
  # mid-sentence, so the leading newline separates the first transcript
@@ -17,9 +17,9 @@ Applies when the business owner wants to control who can access a gated or paid
17
17
 
18
18
  ## Constraints
19
19
 
20
- - **Admin-scope only.** All AccessGrant nodes must be written with `scope: "admin"`. Credential data must never be visible to public agents.
20
+ - **Admin-scope only.** Every AccessGrant node lands with `scope: "admin"`. Credential data must never be visible to public agents — the recorder enforces the scope tag on its write turn.
21
21
  - **Agent must exist.** Before any grant operation, verify the target agent directory exists under `agents/` and read its `config.json`.
22
- - **Confirm before mutating.** Present a `confirm` component before creating grants, revoking access, or changing access modes.
22
+ - **Confirm before mutating.** Present a `confirm` component before grants are created, access is revoked, or access modes change. The recorder writes off the confirmed transcript.
23
23
  - **Domain required for email invitations.** Resolve the public hostname via `tunnel-status`. Without a confirmed hostname, magic link URLs cannot be constructed.
24
24
  - **Deterministic grant queries.** AccessGrant nodes are not vector-indexed. Query them via `cypher-shell` (password at `~/.maxy/.neo4j-password`), not via `memory-search`.
25
25
 
@@ -25,7 +25,7 @@ Use these exact property names when creating or updating grants:
25
25
  | `contactValue` | string | Yes | No | Email address (lowercase) or phone number (E.164) |
26
26
  | `status` | string | Yes | Yes | `"invited"` / `"active"` / `"expired"` / `"revoked"` |
27
27
  | `expiresAt` | datetime | Yes | Yes | ISO 8601 datetime. `null` means no expiry |
28
- | `createdAt` | datetime | Auto | No | Auto-set by `memory-write` |
28
+ | `createdAt` | datetime | Auto | No | Stamped at write time by the recorder |
29
29
  | `magicToken` | string | Email only | Yes | Cryptographically random UUID. Nulled after first use |
30
30
  | `magicTokenExpiresAt` | datetime | Email only | Yes | 15 minutes from generation |
31
31
  | `otpCode` | string | Phone only | Yes | Random 6-digit numeric code |
@@ -34,11 +34,9 @@ Use these exact property names when creating or updating grants:
34
34
  | `otpAttempts` | integer | Phone only | Yes | Failed OTP entries. Max 3 before regeneration required |
35
35
  | `scope` | `"admin"` | Yes | No | **Mandatory.** Omitting scope exposes grants to public agents |
36
36
 
37
- ### Tool Parameters
37
+ ### Recorder write shape
38
38
 
39
- `memory-write` requires `labels`, `properties`, `accountId`, `scope`, and optionally `relationships`. The `accountId` and `scope` are top-level parameters not inside `properties`.
40
-
41
- `memory-update` requires `nodeId` (element ID from a prior query), `properties` (fields to update), and `accountId`. Always include all three.
39
+ Each AccessGrant write the recorder produces names a label (`AccessGrant`), the property bag from the table above, the operator's `accountId`, `scope: "admin"`, and the inbound `HAS_ACCESS` relationship from the Person. Admin's job is to confirm those five things in conversation; the recorder reads them off the transcript and writes the node. Updates name the existing `AccessGrant` by element ID (from the most recent `cypher-shell` lookup) and the new properties.
42
40
 
43
41
  ### Querying Grants
44
42
 
@@ -71,24 +69,11 @@ RETURN elementId(p) AS personId, p.givenName AS name
71
69
 
72
70
  ### Person node
73
71
 
74
- Search for an existing Person by email using a `cypher-shell` query. If found, reuse its element ID. If not found, create one via `memory-write`:
75
-
76
- - Labels: `["Person"]`
77
- - Properties: `givenName` (if the admin provided a name), `email` (lowercase)
78
- - `accountId`: from `account.json`
79
- - `scope`: `"admin"`
72
+ Search for an existing Person by email using a `cypher-shell` query. If found, reuse its element ID. If not found, name the new Person in the confirmation — labels `["Person"]`, `givenName` (when the admin provided a name), `email` (lowercase), `accountId` from `account.json`, `scope: "admin"`. The recorder writes it on its next turn.
80
73
 
81
74
  ### AccessGrant node
82
75
 
83
- Create via `memory-write`:
84
-
85
- - Labels: `["AccessGrant"]`
86
- - Properties: all fields from the data model table. Set `status` to `"invited"`, generate the `magicToken` (random UUID), calculate `magicTokenExpiresAt` (15 minutes from now), and calculate `expiresAt` from the admin's requested duration (or `null` for no expiry).
87
- - `accountId`: from `account.json`
88
- - `scope`: `"admin"`
89
- - Relationships: `[{ type: "HAS_ACCESS", direction: "incoming", targetNodeId: "<person-element-id>" }]`
90
-
91
- The `incoming` direction creates `(Person)-[:HAS_ACCESS]->(AccessGrant)`.
76
+ Confirm the grant with the operator carrying all fields from the data model table — `status: "invited"`, the freshly-generated `magicToken` (random UUID), the `magicTokenExpiresAt` (15 minutes from now), and the `expiresAt` from the admin's requested duration (or `null` for no expiry) — together with `accountId` from `account.json`, `scope: "admin"`, and the inbound `HAS_ACCESS` edge from the Person. The `incoming` direction yields `(Person)-[:HAS_ACCESS]->(AccessGrant)` once the recorder writes it.
92
77
 
93
78
  ### Magic link
94
79
 
@@ -160,15 +145,15 @@ If no grants exist for the agent, say so in text rather than emitting an empty l
160
145
 
161
146
  **Outcome:** Grant status is `"revoked"`. The visitor is blocked on their next login attempt.
162
147
 
163
- Find the grant via `cypher-shell` to get its element ID. Update via `memory-update` with `properties: { status: "revoked" }`.
148
+ Find the grant via `cypher-shell` to get its element ID. Confirm the revocation with the operator naming the element ID and the new `status: "revoked"`; the recorder writes the status change in its next turn.
164
149
 
165
- Present a `confirm` component before revoking. Explain that active sessions continue until they naturally expire (max 24 hours) — revocation takes effect on the visitor's next login.
150
+ Present a `confirm` component before the revocation lands. Explain that active sessions continue until they naturally expire (max 24 hours) — revocation takes effect on the visitor's next login.
166
151
 
167
152
  ## Extend Access
168
153
 
169
154
  **Outcome:** Grant's `expiresAt` is pushed forward by the requested duration.
170
155
 
171
- Find the grant via `cypher-shell`. Calculate the new `expiresAt` by adding the requested duration to the current `expiresAt` (or to now, if the grant has already expired). Update via `memory-update` with the new `expiresAt` value.
156
+ Find the grant via `cypher-shell`. Calculate the new `expiresAt` by adding the requested duration to the current `expiresAt` (or to now, if the grant has already expired). Confirm the new `expiresAt` and the grant's element ID with the operator; the recorder folds the new property in.
172
157
 
173
158
  Present a `confirm` component showing the current expiry date and the new expiry date after extension.
174
159
 
@@ -192,6 +177,6 @@ Present a `confirm` component before changing, explaining the current mode, the
192
177
 
193
178
  Find the existing grant via `cypher-shell`. The grant must be in `invited` or `active` status.
194
179
 
195
- **For email grants:** Generate a new UUID for `magicToken`, set `magicTokenExpiresAt` to 15 minutes from now. Update via `memory-update`. Send the invitation email using the same format as the original invite.
180
+ **For email grants:** Generate a new UUID for `magicToken`, set `magicTokenExpiresAt` to 15 minutes from now. Confirm both with the operator and the grant's element ID — the recorder folds them in. Send the invitation email using the same format as the original invite.
196
181
 
197
- **For phone grants:** Generate a new 6-digit `otpCode`, reset `otpAttempts` to `0`, set `otpExpiresAt` to 10 minutes from now. Update via `memory-update`. Then deliver the new OTP via the SMS delivery command described in the "Invite by Phone" section.
182
+ **For phone grants:** Generate a new 6-digit `otpCode`, reset `otpAttempts` to `0`, set `otpExpiresAt` to 10 minutes from now. Confirm all three with the operator and the grant's element ID — the recorder folds them in. Then deliver the new OTP via the SMS delivery command described in the "Invite by Phone" section.
@@ -10,28 +10,28 @@ Applies when the business owner wants to set up, complete, or update their busin
10
10
  ## When to Activate
11
11
 
12
12
  - Admin asks to set up, edit, or complete their business profile
13
- - Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode**. Personal mode bootstraps a `Person` with `role: "admin-personal"` directly inside step 9 and does NOT invoke this skill.
13
+ - Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode**. Personal mode bootstraps a personal-profile `Person` directly inside step 9 and does NOT invoke this skill.
14
14
  - Session-start graph check shows a `LocalBusiness` node with missing data (no hours, no services, empty description)
15
- - A `memory-write` or `memory-update` returns `no-admin-user` or `no-local-business` — the error message directs the agent here. (For personal-mode accounts, this rejection should not fire because the personal-profile `Person` already satisfies the precondition — if it does fire, the personal-profile node is missing or its `role` is wrong.)
15
+ - A recorder cycle reports `no-admin-user` or `no-local-business` — the graph lacks the prerequisite node. (For personal-mode accounts, this signal should not fire because the personal-profile `Person` already satisfies the precondition — if it does fire, the personal-profile node is missing or its `role` is wrong.)
16
16
  - Admin asks questions like "update my address", "add our opening hours", "change the phone number"
17
17
 
18
18
  ## First-run path (no LocalBusiness yet)
19
19
 
20
- The `AdminUser` and the operator's personal-profile `Person` are written deterministically at PIN setup time, so this skill no longer creates the AdminUser. When onboarding step 9 invokes this skill in `business-owner` mode, or when a write returns `no-local-business`, create the LocalBusiness:
20
+ The `AdminUser` and the operator's personal-profile `Person` are written deterministically at PIN setup time, so this skill no longer creates the AdminUser. When onboarding step 9 invokes this skill in `business-owner` mode, or when the operator's graph has no `LocalBusiness`:
21
21
 
22
- 1. **LocalBusiness.** Ask the user for the business name first — this is the only mandatory property. Call `memory-write` with `labels: ["LocalBusiness"]`, minimal `properties: { name }`, `scope: "shared"`, `relationships: [{ type: "OWNS", direction: "incoming", targetNodeId: "<AdminUser-elementId>" }]`. Find the AdminUser elementId via `memory-search` for the operator's name; the AdminUser already exists from PIN setup.
23
- 2. **Capture the remaining domains** (address, hours, services, FAQs, brand assets) via `memory-update` on the `LocalBusiness` node and `memory-write` for related nodes (`OpeningHoursSpecification`, `Service`, `FAQPage`, `ImageObject`).
22
+ 1. **LocalBusiness.** Ask the operator for the business name first — this is the only mandatory property. Confirm the name and the OWNS link to the AdminUser. The recorder writes the `LocalBusiness` node with `scope: "shared"` and the OWNS edge in its next turn.
23
+ 2. **Capture the remaining domains** (address, hours, services, FAQs, brand assets) in conversation. After the operator confirms each batch, the recorder folds those properties onto the `LocalBusiness` and adds the related nodes (`OpeningHoursSpecification`, `Service`, `FAQPage`, `ImageObject`).
24
24
 
25
- Adapt the order if the user provides data in a different sequence — the invariant is: `LocalBusiness` must exist before any subsequent write. Confirm each batch with the user before writing.
25
+ Adapt the order if the operator provides data in a different sequence — the invariant is: the `LocalBusiness` is named and confirmed before any related-node batch is discussed. Confirm each batch with the operator before moving on; the recorder reads the confirmation off the transcript.
26
26
 
27
27
  ## Constraints
28
28
 
29
- - **Schema-first.** Before any write, load `memory/references/schema-base.md` via `plugin-read`. If `businessType` is known on the `LocalBusiness` node, also load the matching vertical schema. Confirm which schemas were loaded.
30
- - **Read-first.** Search for the existing `LocalBusiness` node and its related entities before asking the user anything. Do not re-ask for data that is already in the graph — reference it and confirm it is still correct. On first run (no `LocalBusiness` yet), skip the search and proceed with the first-run path above.
31
- - **Confirm before writing.** Present each batch of data back to the user and get explicit confirmation before calling `memory-write` or `memory-update`.
29
+ - **Schema-first.** Before discussing a batch, load `memory/references/schema-base.md` via `plugin-read`. If `businessType` is known on the `LocalBusiness` node, also load the matching vertical schema. Confirm which schemas were loaded so the recorder uses the right property names.
30
+ - **Read-first.** Search for the existing `LocalBusiness` node and its related entities before asking the operator anything. Do not re-ask for data that is already in the graph — reference it and confirm it is still correct. On first run (no `LocalBusiness` yet), skip the search and proceed with the first-run path above.
31
+ - **Confirm before recording.** Present each batch of data back to the operator and get explicit confirmation. The recorder writes off the confirmed transcript; unconfirmed prose is not a record.
32
32
  - **Exact property names.** Use Schema.org camelCase property names exactly as defined in the loaded schema reference. No synonyms, no abbreviations.
33
- - **No placeholders.** Never write empty strings, "TBD", or "unknown" as property values — they degrade search quality via poor embeddings. If a value is unknown, skip it.
34
- - **Conversational, not a form.** Adapt to what the user provides. Accept partial input and infer where reasonable (e.g. "9-5 weekdays" means Mon-Fri 09:00-17:00). Allow skipping sections and returning later.
33
+ - **No placeholders.** Never propose empty strings, "TBD", or "unknown" as property values — they degrade search quality via poor embeddings. If a value is unknown, skip it.
34
+ - **Conversational, not a form.** Adapt to what the operator provides. Accept partial input and infer where reasonable (e.g. "9-5 weekdays" means Mon-Fri 09:00-17:00). Allow skipping sections and returning later.
35
35
 
36
36
  ## Data Domains
37
37
 
@@ -46,8 +46,8 @@ Six categories of operational data, each mapping to graph nodes defined in `sche
46
46
  | FAQs (question + answer pairs) | `FAQPage` → `Question` | `HAS_FAQ`, then `CONTAINS` |
47
47
  | Services (name, description, offers/pricing) | `Service` | `OFFERS` (from LocalBusiness) |
48
48
 
49
- ## Writing Nodes
49
+ ## Recording Nodes
50
50
 
51
- When creating new related nodes (hours, FAQs, assets, services), capture the `LocalBusiness` element ID from the initial search result and use it as `targetNodeId` in the `memory-write` relationships array. For FAQ questions, first create the `FAQPage` node, then create `Question` nodes related to the `FAQPage` via `CONTAINS`.
51
+ When the operator confirms a new related node (hours, FAQs, assets, services), make sure the conversation names the `LocalBusiness` it attaches to and the edge type the recorder reads this off the transcript and writes the node with the right adjacency. For FAQ questions, that means the operator confirms the `FAQPage` first, then the individual `Question`s that hang off it via `CONTAINS`.
52
52
 
53
- When updating existing data (changing a phone number, correcting an address), use `memory-update` with the node's element ID from the search result.
53
+ When the operator changes existing data (a phone number, an address), reference the existing node by name in conversation; the recorder finds it via search and folds the new properties in.
@@ -67,7 +67,7 @@ During creation (Step 7) and editing, offer image upload when the user has an im
67
67
 
68
68
  Nodes in the graph can be directly tagged for a specific agent by adding the agent's slug to the node's `agents` array property. This is the explicit tagging mode — the admin decides exactly which nodes an agent can see. Direct tagging and keyword subscriptions (`knowledgeKeywords`) are independent routing mechanisms — each is configured and approved separately, and neither implies the other.
69
69
 
70
- To tag nodes: use `memory-update` to add the agent's slug to a node's `agents` array (read current array first via `memory-search` to avoid overwriting existing slugs). To untag: read the current `agents` array, remove the slug, and update. To see what's tagged for an agent: search with `memory-search` (no `agentSlug` param) and identify results whose `agents` property contains the target slug.
70
+ To tag nodes: read the node's current `agents` array via `memory-search` (no `agentSlug` param), confirm with the operator which nodes get the new slug, and the recorder folds the slug onto each `agents` array in its next turn. To untag: same shape — read the current array, confirm removal, and the recorder rewrites the array without the slug. To see what's tagged for an agent: search with `memory-search` (no `agentSlug` param) and identify results whose `agents` property contains the target slug.
71
71
 
72
72
  ### Model Options
73
73
 
@@ -189,8 +189,8 @@ After creation, no template metadata persists in the agent's files. The resultin
189
189
 
190
190
  Ask the user whether any specific knowledge nodes should be tagged with this agent's slug. Explain that direct tagging makes specific nodes always visible to this agent regardless of keyword matches, and is useful for content that doesn't match any keyword but should still be available. If keyword subscriptions already cover the desired content, tagging is unnecessary.
191
191
 
192
- - If the user approves: present the knowledge discovery results from Step 4 for selection, then tag selected nodes (add slug to `agents` array via `memory-update`). Read each node's current `agents` array first to avoid overwriting existing slugs.
193
- - If the user declines: skip tagging entirely. Do not tag nodes without explicit approval.
192
+ - If the user approves: present the knowledge discovery results from Step 4 for selection, read each node's current `agents` array first, and confirm the new slug-tag set with the user. The recorder folds the slug onto each node's `agents` array in its next turn.
193
+ - If the user declines: skip tagging entirely. Do not propose tagging without explicit approval.
194
194
  9. **KNOWLEDGE.md generation** — populate from the now-tagged set plus keyword matches using the `update-knowledge` skill workflow
195
195
  10. Write `config.json` with selected model, plugins, status "active", `liveMemory`, and `knowledgeKeywords`. This is the last gated write — placed after IDENTITY.md, SOUL.md, and KNOWLEDGE.md to prevent cascade failure if one gate stalls.
196
196
  11. Check context budget — auto-summarise if over threshold
@@ -226,7 +226,7 @@ For agent image changes:
226
226
 
227
227
  For knowledge scope changes:
228
228
  - Show currently tagged nodes (search `memory-search` without `agentSlug`, identify results whose `agents` property contains the agent's slug) and active keyword subscriptions (from `config.json`).
229
- - Allow adding/removing direct tags (add/remove the slug from nodes' `agents` arrays via `memory-write`).
229
+ - Allow adding/removing direct tags. Read each node's current `agents` array first, confirm the change with the operator, and the recorder updates the arrays in its next turn.
230
230
  - Allow adding/removing keyword subscriptions (update `knowledgeKeywords` in `config.json`).
231
231
  - Allow toggling `liveMemory` on/off (update `config.json`).
232
232
  - After changes, offer to refresh KNOWLEDGE.md using the `update-knowledge` skill.
@@ -248,7 +248,7 @@ Deletion removes the entire agent directory (`agents/{slug}/`) — not individua
248
248
  - Verify the directory no longer exists
249
249
  - Report what was removed (list the files that were in the directory)
250
250
 
251
- **Graph tag cleanup (optional):** Knowledge nodes in the graph may have the deleted agent's slug in their `agents` array. These tags become orphaned after deletion — no agent will query them, so they are inert. Offer the user the choice: clean up the orphaned tags (remove the slug from each node's `agents` array via `memory-update`, reading the current array first to preserve other agents' tags) or leave them. If the user declines, note that the tags are harmless and can be cleaned up later.
251
+ **Graph tag cleanup (optional):** Knowledge nodes in the graph may have the deleted agent's slug in their `agents` array. These tags become orphaned after deletion — no agent will query them, so they are inert. Offer the operator the choice: clean up the orphaned tags (read each affected node's current `agents` array first, confirm the slug removal, and the recorder rewrites the arrays in its next turn) or leave them. If the operator declines, note that the tags are harmless and can be cleaned up later.
252
252
 
253
253
  ### Set as Default
254
254
 
@@ -108,7 +108,7 @@ You are not user-facing and your text goes nowhere — do not emit it.
108
108
  At fire time the Stop hook substitutes both placeholders and sends the filled paragraph as `initialMessage` — a plain string, not a JSON envelope, no instruction prose, no leading object.
109
109
 
110
110
  - `{schema}` ← the concatenated text of `platform/plugins/memory/references/schema-base.md` plus, when `brand.json#vertical` names a vertical file that exists in the same directory, that file's content joined with a blank-line separator (Task 193). When the brand declares `vertical: null`, the field is missing, or the named file is absent, the schema is `schema-base.md` alone — never loud-fail here; Task 193 owns the brand-config gate.
111
- - `{conversation}` ← a flat rendering of the envelope's `turns` array. Format: one line per turn, `role: text`, wrapped in leading and trailing newlines so each line lands on its own line in the filled prompt. Assistant turns that called tools carry inline `[tool: name(args)]` markers at the end of their line, with `args` being the call's input as a JSON literal. Tool outputs are not included only the call shape the model has been emitting. The user record carrying only `tool_result` blocks does not create a separate transcript line; thinking-only records contribute nothing.
111
+ - `{conversation}` ← a flat rendering of the envelope's `turns` array. Format: one line per turn, `role: text`, wrapped in leading and trailing newlines so each line lands on its own line in the filled prompt. Task 213 strips the prior inline `[tool: name(args)]` markers on assistant turns they taught the recorder to copy the shape and emit `<tool_call>` as text instead of real `tool_use` blocks (evidence in sessions d2aaa85e / 481799d1). Assistant turns that only called tools land as an empty-bodied `assistant: ` line; the `toolCallTurns` counter on `[turn-recorder] envelope …` remains the independent observability surface. The user record carrying only `tool_result` blocks does not create a separate transcript line; thinking-only records contribute nothing.
112
112
 
113
113
  The envelope walker still runs (so `[turn-recorder] envelope ...` continues to land for observability and the `trigger-skipped reason=conversation-empty` gate still fires on a turns-empty transcript), but its JSON shape never reaches the model.
114
114
 
@@ -200,7 +200,7 @@ Three log-line shapes, all keyed on the same recorder `sessionId`, distinguish t
200
200
  ```
201
201
  [pty-spawn-tool-inventory] sessionId=<id> specialist=<name> argv-tools=<n> mcp-listed-tools=<n> exposed=<csv> not-exposed=<csv>
202
202
  [recorder-session-init] sessionId=<id> specialist=<name> toolsExposedCount=<n> toolsExposed=<csv> toolsArgvCount=<n> toolsArgvMissingFromExposed=<csv|—> source=<probe>
203
- [recorder-turn] sessionId=<id> turnIndex=<n> stopReason=<r> textBytes=<n> textHead=<json-escaped first 240 chars> toolUseNames=<csv|—>
203
+ [recorder-turn] sessionId=<id> turnIndex=<n> stopReason=<r> textBytes=<n> text=<full json-escaped> toolUseNames=<csv|—> containsToolCallText=true|false
204
204
  ```
205
205
 
206
206
  - `[pty-spawn-tool-inventory]` (Task 178) measures the **framework** layer — the argv built and the MCP servers' published tools. Argv-tools count must be 11; mcp-listed-tools must be ≥ 11.
@@ -209,7 +209,7 @@ Three log-line shapes, all keyed on the same recorder `sessionId`, distinguish t
209
209
  - `source=headless-argv-fallback` ⇒ Task 185 finding (2026-05-20): headless `claude --verbose` PTY mode (which the database-operator recorder uses) does **not** write any tool-list record to the JSONL — none of the three probe shapes ever appears. The tail falls back to the spawner's `--allowed-tools` argv as the exposed set, independently verified by `[pty-spawn-tool-inventory]`'s `exposed=` field. Treat this source identically to a positive JSONL match: the argv set is authoritative for headless spawns.
210
210
  - `toolsArgvMissingFromExposed=—` ⇒ all argv tools made it through; Outcome A is ruled out.
211
211
  - Any non-`—` value enumerates the names the SDK dropped and is the diagnosis. Only meaningful when `source` is JSONL-derived; under `headless-argv-fallback` the exposed set equals argv by construction, so this field is always `—`.
212
- - `[recorder-turn]` measures the **model** layer — Haiku's verbatim words (`textHead`) and the tool names it actually called (`toolUseNames`). `toolUseNames=—` paired with `toolsArgvMissingFromExposed=—` is the Outcome B signature: tools present, model abstained.
212
+ - `[recorder-turn]` measures the **model** layer — Haiku's verbatim words (Task 213: full `text=`, not the prior 240-char `textHead`) and the tool names it actually called (`toolUseNames`). `toolUseNames=—` paired with `toolsArgvMissingFromExposed=—` is the Outcome B signature: tools present, model abstained. `containsToolCallText=true` flags any assistant text that contains `<tool_call>`, `<function_calls>`, or `</invoke>` — the recorder model copying the tool-call shape into prose (the regression Task 213 closed).
213
213
 
214
214
  The three lines are emitted into `server.log` via the platform UI's `/api/admin/log-ingest` loopback route, so a single grep against `server.log` answers the question for any past, present, or future recorder spawn:
215
215
 
@@ -222,7 +222,7 @@ grep -E "\[(pty-spawn-tool-inventory|recorder-session-init|recorder-turn|mcp:(me
222
222
 
223
223
  1. The single `[pty-spawn-tool-inventory]` line proves what the framework provisioned. If `argv-tools < 11`, the bug is upstream of this evidence chain — the agent file or the spawn site.
224
224
  2. The single `[recorder-session-init]` line proves what the model received. If `toolsArgvMissingFromExposed` is non-`—`, the named tools were dropped by the CLI/SDK; that is Outcome A and the fix lives in the `--mcp-config` resolution or the CLI/SDK handshake.
225
- 3. Each `[recorder-turn]` line records one assistant turn. If `toolUseNames=—` across every turn AND `toolsArgvMissingFromExposed=—`, the model could call the tools and chose not to; `textHead` carries the model's verbatim reasoning and is the next surface to read.
225
+ 3. Each `[recorder-turn]` line records one assistant turn. If `toolUseNames=—` across every turn AND `toolsArgvMissingFromExposed=—`, the model could call the tools and chose not to; `text=` carries the model's verbatim reasoning (Task 213 dropped the 240-char head cap so the whole turn is observable from server.log) and is the next surface to read. A `containsToolCallText=true` row is the recorder-relapse signature: the model emitted tool-call shape as prose instead of a real `tool_use` block.
226
226
 
227
227
  **Loopback gate.** The `/api/admin/log-ingest` route is loopback-only on both the socket address and (when present) the `X-Forwarded-For` header. A LAN client whose request reaches the route via a proxy still gets a 403 because XFF tokens are required to be loopback when XFF is set. Without this gate the evidence chain would be worthless — any LAN client could forge `[recorder-session-init]` lines under a spoofed sessionId.
228
228
 
@@ -1 +1 @@
1
- {"version":3,"file":"jsonl-tail.d.ts","sourceRoot":"","sources":["../src/jsonl-tail.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAKxC;;;uDAGuD;AACvD,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;AAEjG,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,aAAa,CAAA;IAC1B,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,SAAS;IACxB;;qDAEiD;IACjD,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,GAAG,IAAI,CAAA;IAC9D;;;8CAG0C;IAC1C,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACzD,iDAAiD;IACjD,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,0EAA0E;IAC1E,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CACxG;AAkBD;;oDAEoD;AACpD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAY3F;AAoHD,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CAgL9D"}
1
+ {"version":3,"file":"jsonl-tail.d.ts","sourceRoot":"","sources":["../src/jsonl-tail.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAWxC;;;uDAGuD;AACvD,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;AAEjG,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,aAAa,CAAA;IAC1B,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,SAAS;IACxB;;qDAEiD;IACjD,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,GAAG,IAAI,CAAA;IAC9D;;;8CAG0C;IAC1C,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACzD,iDAAiD;IACjD,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACnC,0EAA0E;IAC1E,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CACxG;AAkBD;;oDAEoD;AACpD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAY3F;AA4GD,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CAkL9D"}
@@ -22,11 +22,17 @@
22
22
  // with argvTools as the exposed set and tag the source.
23
23
  //
24
24
  // Line 2 — `[recorder-turn] sessionId=<id> turnIndex=<n> stopReason=<r>
25
- // textBytes=<n> textHead=<json-escaped first 240 chars>
26
- // toolUseNames=<csv|—>`
25
+ // textBytes=<n> text=<full json-escaped>
26
+ // toolUseNames=<csv|—> containsToolCallText=true|false`
27
27
  // Emitted once per newly-appended `assistant` record. Records with the
28
28
  // same `message.id` collapse to one line (only the first sighting
29
29
  // emits). Records that contain only `thinking` blocks emit nothing.
30
+ // `text=` is the full assistant text, json-escaped — Task 213 dropped
31
+ // the 240-char head cap so a recorder relapse that emits
32
+ // `<tool_call>{…}</tool_call>` as text is observable from server.log
33
+ // without downloading the jsonl. `containsToolCallText=true` flags any
34
+ // assistant text containing `<tool_call>`, `<function_calls>`, or
35
+ // `</invoke>` — the recorder model copying tool-call shape into prose.
30
36
  //
31
37
  // The cursor is append-only: `cursorOffset` is the byte position past the
32
38
  // last `\n` we successfully parsed; the trailing fragment lives in
@@ -35,7 +41,13 @@
35
41
  // record — exactly the failure mode this task closes.
36
42
  import { existsSync, openSync, closeSync, readSync, statSync } from 'node:fs';
37
43
  const HEAD_READ_CHUNK = 64 * 1024;
38
- const TEXT_HEAD_CHARS = 240;
44
+ // Task 213 — regex against assistant text. Matches `<tool_call>`,
45
+ // `<function_calls>`, and `</invoke>` — three shapes the recorder model
46
+ // emitted as prose in sessions d2aaa85e / 481799d1 (and similar evidence
47
+ // in 596a42bc, c3539844, 4d4775bd, bad79fb9, 06ac2eb3, 6828bae0,
48
+ // c4c10cf3, 5cb8fd16) instead of real `tool_use` blocks. `\b` against the
49
+ // `>` boundary keeps the match precise.
50
+ const TOOL_CALL_TEXT_PATTERN = /<tool_call\b|<function_calls\b|<\/invoke\b/;
39
51
  /** Production implementation of LogLinePoster. POSTs to the brand UI's
40
52
  * loopback log-ingest route. Fire-and-forget; failure is logged via the
41
53
  * manager's own logger and otherwise swallowed. */
@@ -118,18 +130,10 @@ function probeInitTools(rec) {
118
130
  /** JSON-escape a string for inclusion in a log line. Renders control
119
131
  * characters as `\uXXXX`, quotes the result. Callers strip the quotes
120
132
  * if they want; this module keeps them so the boundaries of
121
- * `textHead=...` are explicit on screen. */
133
+ * `text=...` are explicit on screen. */
122
134
  function jsonEscape(s) {
123
135
  return JSON.stringify(s);
124
136
  }
125
- /** First N CHARACTERS (not bytes) of a string. The 240-char cap is per
126
- * spec; we operate on UTF-16 code units which is what JavaScript's
127
- * default substring semantics already give us. */
128
- function head(s, max) {
129
- if (s.length <= max)
130
- return s;
131
- return s.slice(0, max);
132
- }
133
137
  function summarizeAssistantRecord(rec) {
134
138
  if (rec.type !== 'assistant')
135
139
  return null;
@@ -210,11 +214,13 @@ export function createJsonlTail(deps) {
210
214
  state.nextTurnIndex += 1;
211
215
  const textBytes = Buffer.byteLength(summary.text, 'utf8');
212
216
  const namesField = summary.toolUseNames.length === 0 ? '—' : summary.toolUseNames.join(',');
217
+ const containsToolCallText = TOOL_CALL_TEXT_PATTERN.test(summary.text);
213
218
  const line = `sessionId=${state.sessionId} turnIndex=${idx}` +
214
219
  ` stopReason=${summary.stopReason || '—'}` +
215
220
  ` textBytes=${textBytes}` +
216
- ` textHead=${jsonEscape(head(summary.text, TEXT_HEAD_CHARS))}` +
217
- ` toolUseNames=${namesField}`;
221
+ ` text=${jsonEscape(summary.text)}` +
222
+ ` toolUseNames=${namesField}` +
223
+ ` containsToolCallText=${containsToolCallText}`;
218
224
  deps.postLogLine('recorder-turn', 'info', line);
219
225
  }
220
226
  function processRecord(state, rec) {