@rubytech/create-realagent-code 0.1.249 → 0.1.250

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 (23) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/admin/hooks/__tests__/session-end-retrospective.test.sh +3 -3
  4. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +16 -10
  5. package/payload/platform/plugins/docs/PLUGIN.md +2 -2
  6. package/payload/platform/plugins/docs/references/admin-session.md +7 -67
  7. package/payload/platform/plugins/docs/references/admin-ui.md +3 -3
  8. package/payload/platform/plugins/docs/references/deployment.md +1 -1
  9. package/payload/platform/plugins/docs/references/internals.md +8 -2
  10. package/payload/platform/plugins/docs/references/platform.md +3 -3
  11. package/payload/platform/scripts/check-no-legacy-spawn-route.mjs +37 -0
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +57 -21
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/index.js +1 -0
  16. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +8 -0
  18. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +14 -4
  20. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  21. package/payload/server/server.js +120 -121
  22. package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +0 -601
  23. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +0 -441
@@ -1,441 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Stop hook — fires on every completed admin-agent turn and dispatches one
3
- # headless database-operator session against the operator's full
4
- # conversation transcript. The recorder is the only writer to the Neo4j
5
- # graph; the admin agent stays focused on the operator's request.
6
- #
7
- # Task 147 — the recorder spawn is byte-for-byte equivalent to a Sidebar
8
- # "New session" body, with two overrides:
9
- # - specialist: "database-operator"
10
- # - initialMessage: JSON-stringified envelope (Task 177)
11
- # The model is taken from the agent file frontmatter (Task 200).
12
- #
13
- # It POSTs to the SAME route the Sidebar uses
14
- # (`POST /api/admin/claude-sessions`). The wrapper accepts the loopback
15
- # request without a cookie, resolves the operator's `senderId` from the
16
- # manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn
17
- # body. No recorder-only carving on the manager side.
18
- #
19
- # Task 177 — `initialMessage` is a JSON object stringified to a string.
20
- # Top-level keys EXACTLY: turns, sessionId, accountId, occurredAt.
21
- # `turns` is the chronologically-ordered conversation window — every
22
- # user / assistant message in the operator's JSONL, oldest first, no
23
- # windowing or truncation. Each entry:
24
- # { role: "user"|"assistant", text: string, ts: string, toolCalls?: [...] }
25
- # `toolCalls` (assistant-only, omitted when empty) carries
26
- # `[{ tool, input, output }]` for tool_use/tool_result pairs in that turn.
27
- # Replaces the Task 175 `(operatorMessage, assistantReply)` pair contract,
28
- # which asserted a temporal pairing the walker never enforced.
29
- #
30
- # Gating (emits a `trigger-skipped` line via `/api/admin/log-ingest`;
31
- # stderr stays silent on the success path):
32
- # - MAXY_SESSION_ROLE must equal "admin" → reason=role-not-admin
33
- # - MAXY_SPECIALIST must NOT equal → reason=is-recorder
34
- # "database-operator" (recursion guard)
35
- # - Stop-hook stdin must be non-empty → reason=empty-stdin
36
- # - transcript_path must exist on disk → reason=missing-transcript
37
- # - turns array is empty after walker runs → reason=conversation-empty
38
- #
39
- # Input: Claude Code's Stop hook stdin shape
40
- # { "session_id": "<intrinsic>", "transcript_path": "<jsonl path>", ... }
41
-
42
- set -uo pipefail
43
-
44
- UI_PORT="${MAXY_UI_INTERNAL_PORT:-}"
45
- if [ -z "$UI_PORT" ]; then
46
- echo "[turn-recorder] spawn-failed sessionId=unknown reason=missing-env env=MAXY_UI_INTERNAL_PORT" >&2
47
- exit 0
48
- fi
49
- UI_BASE="http://127.0.0.1:${UI_PORT}"
50
- LOG_INGEST_URL="${UI_BASE}/api/admin/log-ingest"
51
-
52
- emit_log() {
53
- local line="$1"
54
- curl -sS -o /dev/null -X POST \
55
- -H 'Content-Type: application/json' \
56
- --max-time 2 \
57
- --data "$(python3 -c '
58
- import sys, json
59
- print(json.dumps({"tag": "turn-recorder", "level": "info", "line": sys.argv[1]}))
60
- ' "$line")" \
61
- "$LOG_INGEST_URL" 2>/dev/null || true
62
- }
63
-
64
- INPUT=""
65
- if [ -t 0 ]; then
66
- INPUT=""
67
- else
68
- INPUT=$(cat)
69
- fi
70
-
71
- PARSED=$(printf '%s' "$INPUT" | python3 -c '
72
- import sys, json
73
- try:
74
- d = json.load(sys.stdin)
75
- sid = d.get("session_id", "") or ""
76
- tpath = d.get("transcript_path", "") or ""
77
- print(f"{sid}\t{tpath}")
78
- except Exception:
79
- print("\t")
80
- ' 2>/dev/null)
81
- ADMIN_SESSION_ID="${PARSED%% *}"
82
- TRANSCRIPT_PATH="${PARSED#* }"
83
-
84
- if [ "${MAXY_SESSION_ROLE:-}" != "admin" ]; then
85
- emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=role-not-admin"
86
- exit 0
87
- fi
88
- # Recursion guard. The recorder spawn carries
89
- # `specialist=database-operator`; pty-spawner stamps MAXY_SPECIALIST on the
90
- # PTY env. Skip recording the recorder's own turn.
91
- if [ "${MAXY_SPECIALIST:-}" = "database-operator" ]; then
92
- emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=is-recorder"
93
- exit 0
94
- fi
95
- if [ -z "$INPUT" ]; then
96
- emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=empty-stdin"
97
- exit 0
98
- fi
99
- if [ -z "$ADMIN_SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
100
- emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID:-unknown} reason=missing-transcript"
101
- exit 0
102
- fi
103
-
104
- # Task 177 — build the envelope the database-operator agent file declares
105
- # as its stdin contract. One Python pass walks the transcript and emits:
106
- # {
107
- # "turns": [
108
- # { "role": "user"|"assistant", "text": "...", "ts": "...",
109
- # "toolCalls"?: [{ "tool": "...", "input": {...}, "output": ... }] },
110
- # ...
111
- # ],
112
- # "sessionId": "<session_id>",
113
- # "accountId": "<ACCOUNT_ID env>",
114
- # "occurredAt": "<hook fire time, ISO-8601 UTC, .000Z>"
115
- # }
116
- # `python3 json.dumps` handles every escape so the hook never has to
117
- # concatenate strings into JSON by hand.
118
- OCCURRED_AT=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
119
- ACCOUNT_ID_ENV="${ACCOUNT_ID:-}"
120
-
121
- ENVELOPE=$(python3 - "$TRANSCRIPT_PATH" "$ADMIN_SESSION_ID" "$ACCOUNT_ID_ENV" "$OCCURRED_AT" <<'PY'
122
- import sys, json
123
-
124
- def concat_text(content):
125
- """Concatenate every `text` block in a content list (or return the
126
- string content as-is). `thinking` blocks contribute nothing."""
127
- if isinstance(content, str):
128
- return content
129
- if not isinstance(content, list):
130
- return ""
131
- out = []
132
- for b in content:
133
- if isinstance(b, dict) and b.get("type") == "text":
134
- t = b.get("text")
135
- if isinstance(t, str):
136
- out.append(t)
137
- return "".join(out)
138
-
139
- path, session_id, account_id, occurred_at = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
140
-
141
- turns = [] # ordered output: { role, text, ts, toolCalls? }
142
- msg_id_to_turn_index = {} # collapse rule for assistant message.id
143
- pending_tool_calls = {} # tool_use_id -> (turn_index, toolCalls_index)
144
-
145
- try:
146
- with open(path, "r", encoding="utf-8") as f:
147
- for line in f:
148
- line = line.strip()
149
- if not line:
150
- continue
151
- try:
152
- rec = json.loads(line)
153
- except Exception:
154
- continue
155
- if not isinstance(rec, dict):
156
- continue
157
- t = rec.get("type")
158
- msg = rec.get("message")
159
- if not isinstance(msg, dict):
160
- continue
161
- content = msg.get("content")
162
- ts = rec.get("timestamp", "") or ""
163
-
164
- if t == "user":
165
- # tool_result blocks attach to the assistant turn that
166
- # owned the corresponding tool_use; they never create a
167
- # separate user turn entry.
168
- if isinstance(content, list):
169
- for b in content:
170
- if isinstance(b, dict) and b.get("type") == "tool_result":
171
- tu_id = b.get("tool_use_id")
172
- if not isinstance(tu_id, str):
173
- continue
174
- slot = pending_tool_calls.pop(tu_id, None)
175
- if slot is None:
176
- continue
177
- turn_idx, call_idx = slot
178
- turns[turn_idx]["toolCalls"][call_idx]["output"] = b.get("content")
179
-
180
- text = concat_text(content)
181
- if text:
182
- turns.append({
183
- "role": "user",
184
- "text": text,
185
- "ts": ts,
186
- })
187
- # No user turn emitted when content is empty or only
188
- # carries tool_result blocks — those landed on the
189
- # owning assistant turn above.
190
-
191
- elif t == "assistant":
192
- msg_id = msg.get("id") if isinstance(msg.get("id"), str) else None
193
- text = concat_text(content)
194
- tool_use_blocks = []
195
- if isinstance(content, list):
196
- for b in content:
197
- if isinstance(b, dict) and b.get("type") == "tool_use":
198
- tool_use_blocks.append(b)
199
-
200
- # Thinking-only assistant records contribute nothing and
201
- # do NOT create a turn entry on their own.
202
- if not text and not tool_use_blocks:
203
- continue
204
-
205
- # Multi-block collapse: if a prior record with the same
206
- # message.id created a turn, extend that one. Otherwise
207
- # open a new assistant turn.
208
- if msg_id is not None and msg_id in msg_id_to_turn_index:
209
- idx = msg_id_to_turn_index[msg_id]
210
- entry = turns[idx]
211
- if text:
212
- entry["text"] = (entry["text"] or "") + text
213
- else:
214
- entry = {
215
- "role": "assistant",
216
- "text": text,
217
- "ts": ts,
218
- }
219
- turns.append(entry)
220
- idx = len(turns) - 1
221
- if msg_id is not None:
222
- msg_id_to_turn_index[msg_id] = idx
223
-
224
- for b in tool_use_blocks:
225
- tc_list = entry.setdefault("toolCalls", [])
226
- tc_list.append({
227
- "tool": b.get("name"),
228
- "input": b.get("input"),
229
- "output": None,
230
- })
231
- tu_id = b.get("id")
232
- if isinstance(tu_id, str):
233
- pending_tool_calls[tu_id] = (idx, len(tc_list) - 1)
234
- except Exception:
235
- pass
236
-
237
- envelope = {
238
- "turns": turns,
239
- "sessionId": session_id,
240
- "accountId": account_id,
241
- "occurredAt": occurred_at,
242
- }
243
- print(json.dumps(envelope, ensure_ascii=False), end="")
244
- PY
245
- )
246
-
247
- # Skip when turns is empty — no user text, no assistant text, no tool_use
248
- # in the operator JSONL. Same skip surface as the prior `conversation-empty`
249
- # reason; the meaning is now binary on turns.length === 0.
250
- EMPTY_CHECK=$(printf '%s' "$ENVELOPE" | python3 -c '
251
- import sys, json
252
- try:
253
- e = json.load(sys.stdin)
254
- print("empty" if not (e.get("turns") or []) else "ok")
255
- except Exception:
256
- print("empty")
257
- ')
258
-
259
- CONVERSATION_BYTES=$(printf '%s' "$ENVELOPE" | wc -c | tr -d ' ')
260
- TRANSCRIPT_BYTES=$(wc -c <"$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ' || echo 0)
261
-
262
- emit_log "trigger sessionId=${ADMIN_SESSION_ID} turnIndex=0 transcriptBytes=${TRANSCRIPT_BYTES} conversationBytes=${CONVERSATION_BYTES}"
263
-
264
- if [ "$EMPTY_CHECK" = "empty" ]; then
265
- emit_log "trigger-skipped sessionId=${ADMIN_SESSION_ID} reason=conversation-empty"
266
- exit 0
267
- fi
268
-
269
- # Task 177 observability — one envelope summary line per recorder fire,
270
- # emitted BEFORE the spawn POST so a future audit reads counts from
271
- # server.log without re-walking the JSONL. `turnsCount` is monotone
272
- # across spawns in the same operator session — flat or decreasing is
273
- # the regression signature for "windowing crept back in".
274
- ENVELOPE_COUNTS=$(printf '%s' "$ENVELOPE" | python3 -c '
275
- import sys, json
276
- e = json.load(sys.stdin)
277
- turns = e.get("turns") or []
278
- u = sum(1 for t in turns if t.get("role") == "user")
279
- a = sum(1 for t in turns if t.get("role") == "assistant")
280
- tc = sum(1 for t in turns if (t.get("toolCalls") or []))
281
- print(f"{len(turns)} {u} {a} {tc}")
282
- ')
283
- read -r TURNS_COUNT USER_TURNS ASST_TURNS TC_TURNS <<<"$ENVELOPE_COUNTS"
284
- emit_log "envelope sessionId=${ADMIN_SESSION_ID} turnsCount=${TURNS_COUNT} userTurns=${USER_TURNS} assistantTurns=${ASST_TURNS} toolCallTurns=${TC_TURNS}"
285
-
286
- # Task 195 — substitute {schema} and {conversation} placeholders in the
287
- # database-operator agent body before spawning. The agent file on disk
288
- # carries the four-sentence template verbatim with both placeholders; the
289
- # hook renders the actual schema text and a flat `role: text` conversation
290
- # transcript here, then sends the filled paragraph as `initialMessage`.
291
- # The model's first input is the filled prompt — no JSON envelope, no
292
- # instruction prose, no placeholders.
293
- HOOK_DIR_RESOLVED="$(cd "$(dirname "$0")" && pwd)"
294
- PLATFORM_ROOT_RESOLVED="$(cd "${HOOK_DIR_RESOLVED}/../../.." && pwd)"
295
- SCHEMA_BASE_PATH="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/schema-base.md"
296
- BRAND_JSON_PATH="${PLATFORM_ROOT_RESOLVED}/config/brand.json"
297
- AGENT_BODY_PATH="${PLATFORM_ROOT_RESOLVED}/templates/specialists/agents/database-operator.md"
298
-
299
- if [ ! -f "$SCHEMA_BASE_PATH" ]; then
300
- emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=schema-base-missing path=${SCHEMA_BASE_PATH}"
301
- exit 0
302
- fi
303
- if [ ! -f "$AGENT_BODY_PATH" ]; then
304
- emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=agent-body-missing path=${AGENT_BODY_PATH}"
305
- exit 0
306
- fi
307
-
308
- # Brand-declared vertical (Task 193). Treated as null when brand.json is
309
- # absent, unparseable, lacks the field, or names a file that does not
310
- # exist — every one of those collapses to "schema-base only", never
311
- # loud-fails this hook. Task 193 itself is the loud-fail surface for
312
- # brand-config defects.
313
- VERTICAL_PATH=""
314
- if [ -f "$BRAND_JSON_PATH" ]; then
315
- VERTICAL_NAME=$(python3 -c '
316
- import sys, json
317
- try:
318
- with open(sys.argv[1]) as f:
319
- b = json.load(f)
320
- v = b.get("vertical")
321
- print(v if isinstance(v, str) and v else "")
322
- except Exception:
323
- print("")
324
- ' "$BRAND_JSON_PATH")
325
- if [ -n "$VERTICAL_NAME" ]; then
326
- CANDIDATE="${PLATFORM_ROOT_RESOLVED}/plugins/memory/references/${VERTICAL_NAME}.md"
327
- if [ -f "$CANDIDATE" ]; then
328
- VERTICAL_PATH="$CANDIDATE"
329
- fi
330
- fi
331
- fi
332
-
333
- # One Python pass:
334
- # - reads the agent file and strips the frontmatter to recover the
335
- # paragraph (the literal four sentences with `{schema}` and
336
- # `{conversation}` placeholders);
337
- # - reads schema-base.md (and concatenates the vertical when set);
338
- # - renders the envelope's `turns` array into a flat transcript — one
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;
346
- # - substitutes both placeholders in the paragraph;
347
- # - emits one tab-separated line of byte counts, then the
348
- # spawn-body JSON containing the filled paragraph as `initialMessage`.
349
- # The envelope rides through a tmpfile (not stdin) so the python script
350
- # can be sourced from the heredoc; the two stdin redirections would
351
- # otherwise collide and Python would see an empty envelope.
352
- ENVELOPE_FILE=$(mktemp)
353
- printf '%s' "$ENVELOPE" > "$ENVELOPE_FILE"
354
- COMBINED=$(python3 - \
355
- "$AGENT_BODY_PATH" "$SCHEMA_BASE_PATH" "$VERTICAL_PATH" "$ADMIN_SESSION_ID" "$ENVELOPE_FILE" <<'PY'
356
- import sys, json
357
-
358
- agent_path, schema_path, vertical_path, sid, envelope_path = sys.argv[1:6]
359
-
360
- with open(agent_path, "r", encoding="utf-8") as f:
361
- raw = f.read()
362
- parts = raw.split("---", 2)
363
- body_template = parts[2].lstrip("\n").rstrip() if len(parts) >= 3 else raw.rstrip()
364
-
365
- with open(schema_path, "r", encoding="utf-8") as f:
366
- schema_text = f.read()
367
- if vertical_path:
368
- with open(vertical_path, "r", encoding="utf-8") as f:
369
- schema_text = schema_text.rstrip() + "\n\n" + f.read()
370
-
371
- with open(envelope_path, "r", encoding="utf-8") as f:
372
- envelope = json.load(f)
373
- account_id = envelope.get("accountId", "") or ""
374
- turns = envelope.get("turns") or []
375
- lines = []
376
- for t in turns:
377
- role = t.get("role", "?")
378
- text_t = t.get("text", "") or ""
379
- lines.append(f"{role}: {text_t}")
380
- # Wrap the transcript block in newlines so each turn lands on its own
381
- # line in the filled prompt — the template's `{conversation}` sits
382
- # mid-sentence, so the leading newline separates the first transcript
383
- # line from the surrounding prose, and the trailing newline separates
384
- # the last one from the closing `, using the tools at your disposal.`
385
- conversation_text = "\n" + "\n".join(lines) + "\n"
386
-
387
- filled = (
388
- body_template
389
- .replace("{schema}", schema_text)
390
- .replace("{accountId}", account_id)
391
- .replace("{conversation}", conversation_text)
392
- )
393
-
394
- schema_bytes = len(schema_text.encode("utf-8"))
395
- conv_bytes = len(conversation_text.encode("utf-8"))
396
- body_bytes = len(filled.encode("utf-8"))
397
- account_id_present = "yes" if account_id else "no"
398
-
399
- spawn_body = {
400
- "adminSessionId": sid,
401
- "channel": "browser",
402
- "specialist": "database-operator",
403
- "initialMessage": filled,
404
- }
405
- # Task 500 — under the `claude rc` daemon, admin sessions are not
406
- # maxy-spawned, so the manager's `/<sid>/meta` carries no senderId. Pass
407
- # the hook's ACCOUNT_ID env as the authoritative senderId fallback. Empty
408
- # is omitted so the route's loopback guard rejects rather than minting an
409
- # empty-scope row.
410
- if account_id:
411
- spawn_body["accountId"] = account_id
412
-
413
- print(f"{schema_bytes}\t{conv_bytes}\t{body_bytes}\t{account_id_present}")
414
- print(json.dumps(spawn_body))
415
- PY
416
- )
417
- rm -f "$ENVELOPE_FILE"
418
-
419
- BYTES_LINE=$(printf '%s' "$COMBINED" | head -n1)
420
- SPAWN_BODY=$(printf '%s' "$COMBINED" | tail -n +2)
421
- IFS=$'\t' read -r SCHEMA_BYTES CONV_BYTES BODY_BYTES ACCOUNT_ID_PRESENT <<<"$BYTES_LINE"
422
-
423
- emit_log "substitution sessionId=${ADMIN_SESSION_ID} schemaBytes=${SCHEMA_BYTES} conversationBytes=${CONV_BYTES} bodyBytes=${BODY_BYTES} accountIdPresent=${ACCOUNT_ID_PRESENT}"
424
- emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${BODY_BYTES}"
425
-
426
- SPAWN_RES_FILE=$(mktemp)
427
- SPAWN_HTTP=$(curl -sS -o "$SPAWN_RES_FILE" -w '%{http_code}' -X POST \
428
- -H 'Content-Type: application/json' \
429
- --max-time 10 \
430
- --data "$SPAWN_BODY" \
431
- "${UI_BASE}/api/admin/claude-sessions" 2>/dev/null || echo 000)
432
-
433
- RES_BODY=$(cat "$SPAWN_RES_FILE" 2>/dev/null || true)
434
- rm -f "$SPAWN_RES_FILE"
435
-
436
- if [ "$SPAWN_HTTP" -lt 200 ] || [ "$SPAWN_HTTP" -ge 300 ]; then
437
- ERR_SHORT=$(printf '%s' "$RES_BODY" | tr -d '\r\n' | cut -c1-200)
438
- emit_log "spawn-failed sessionId=${ADMIN_SESSION_ID} reason=loopback-http http=${SPAWN_HTTP} err=$(python3 -c 'import sys,json; print(json.dumps(sys.argv[1]))' "${ERR_SHORT}")"
439
- fi
440
-
441
- exit 0