@rubytech/create-maxy-code 0.1.102 → 0.1.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +5 -1
- package/payload/platform/plugins/admin/hooks/__tests__/session-end-retrospective.test.sh +396 -0
- package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +1 -1
- package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +268 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +69 -8
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/file-presentation/SKILL.md +14 -4
- package/payload/platform/plugins/admin/skills/unzip-attachment/SKILL.md +1 -1
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +235 -0
- package/payload/platform/plugins/docs/references/session-retrospective.md +20 -0
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/scripts/seed-neo4j.sh +18 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +12 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +4 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +43 -2
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +5 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +9 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +12 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/permission-mode-tail.d.ts +20 -0
- package/payload/platform/services/claude-session-manager/dist/permission-mode-tail.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/permission-mode-tail.js +71 -0
- package/payload/platform/services/claude-session-manager/dist/permission-mode-tail.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +49 -2
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +11 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +10 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +16 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/make-brochure/SKILL.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/index.html +8 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/template.html +8 -0
- package/payload/server/public/assets/{ChatInput-DeBN_eCm.css → ChatInput-CJ50oqWt.css} +1 -1
- package/payload/server/public/assets/{Checkbox-Dd6lh79E.js → Checkbox-DGZG9BKc.js} +1 -1
- package/payload/server/public/assets/{admin-B_-v60En.js → admin-BvzMvMGo.js} +54 -54
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Nnicj9Oi.js → architectureDiagram-Q4EWVU46-Dw16BhiX.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-NuGXhtpA.js → blockDiagram-DXYQGD6D-DIOpmf5Y.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-DusOs1B8.js → c4Diagram-AHTNJAMY-Tdb_HZeX.js} +1 -1
- package/payload/server/public/assets/channel-DThrH4QF.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-L4okIbRn.js → chunk-336JU56O-CebpwDDe.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-WhvqbTM0.js → chunk-426QAEUC-BtRCmfDU.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-Bby5RW_e.js → chunk-4TB4RGXK-BZ3GEWs3.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-veZxFPER.js → chunk-5FUZZQ4R-iDI6Xu0U.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-BwG8uJC1.js → chunk-5PVQY5BW-SQD_EpYa.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DgQhTM1z.js → chunk-EDXVE4YY-CtCcA7_e.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-DpBKyLeu.js → chunk-ENJZ2VHE-pXVGVCbb.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-Bb8kEMah.js → chunk-ICPOFSXX-Dkzg9o2N.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-BUrpyQuA.js → chunk-OYMX7WX6-1ZZWzf9F.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-D0mOjNKD.js → chunk-U2HBQHQK-CpQ3kzO0.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-CQ7VnONI.js → chunk-X2U36JSP-C2LkxroC.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BWgvd9aw.js → chunk-YZCP3GAM-Bl5jBOt5.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-UFB_t9gw.js → chunk-ZZ45TVLE-CHtnptPS.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-BC6oGTNX.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-COwC5Umh.js +1 -0
- package/payload/server/public/assets/clone-Chp7hvnA.js +1 -0
- package/payload/server/public/assets/{dagre-VrERS_-G.js → dagre-BziN0Nkh.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-w7RChnHk.js → dagre-KV5264BT-B7OG1g1Y.js} +1 -1
- package/payload/server/public/assets/data-BAgaPs4j.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-DDdrN3bf.js → diagram-5BDNPKRD-Bf31nIDs.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-B_lLNyeb.js → diagram-G4DWMVQ6-DQu85hhH.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-Da0x1Yf0.js → diagram-MMDJMWI5-5tstbs4y.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-DoFKKnVr.js → diagram-TYMM5635--MOV1U4o.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DBPmAPTe.js → erDiagram-SMLLAGMA-BtAnOJmd.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CnkJzYU4.js → flowDiagram-DWJPFMVM-CybiCIih.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-BM9c9VN5.js → ganttDiagram-T4ZO3ILL-B44jN7Cy.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-C-1OqQDw.js → gitGraphDiagram-UUTBAWPF-CT7iYcwg.js} +1 -1
- package/payload/server/public/assets/graph-Cma7EArf.js +1 -0
- package/payload/server/public/assets/{graph-labels-BkDqelbQ.js → graph-labels-DyKk6Sxf.js} +1 -1
- package/payload/server/public/assets/{graphlib-Be3_mRCh.js → graphlib-DP4o0pYL.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-o4FB6Zjb.js → infoDiagram-42DDH7IO-CvxFpFHN.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-BlnRfx6Z.js → ishikawaDiagram-UXIWVN3A-CUwWLPeR.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-Da26YPBj.js → journeyDiagram-VCZTEJTY-y-OnGgLi.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-C1jVbsV0.js → kanban-definition-6JOO6SKY-BhrSe8R4.js} +1 -1
- package/payload/server/public/assets/{lib-CKcXekKF.js → lib-CpkYtEDz.js} +5 -5
- package/payload/server/public/assets/{line-BS0h3YlR.js → line-C9uS7z4J.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-4FY7A26h.js → mermaid-parser.core-Cg4ZdKp-.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-CD1JRYmG.js → mermaid.core-BHdKOsex.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BmCJ5q81.js → mindmap-definition-QFDTVHPH-NBYiXHo7.js} +1 -1
- package/payload/server/public/assets/{page-D6QVxITy.js → page-B4IWl3aZ.js} +1 -1
- package/payload/server/public/assets/{page-1atlhFFT.js → page-D9YpwhIu.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-ClXRAXST.js → pieDiagram-DEJITSTG-CbojC64C.js} +1 -1
- package/payload/server/public/assets/{public-7fuvQJwF.js → public-BtOXjy3A.js} +5 -5
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-D17aBgPP.js → quadrantDiagram-34T5L4WZ-CnnoZXcL.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-CUpkUJlh.js → requirementDiagram-MS252O5E-DFRFRtyJ.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-DnrrGp2c.js → sankeyDiagram-XADWPNL6-DU5gcpzO.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-CMprmJg9.js → sequenceDiagram-FGHM5R23-BN7HZ6Hq.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BdpBUtXc.js → stateDiagram-FHFEXIEX-DG6cCjvg.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CvVd1Q_q.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-u7JucCMs.js → timeline-definition-GMOUNBTQ-qmV-8SEV.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-CP1Q9zf2.js → vennDiagram-DHZGUBPP-BMXQ1s3n.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-BOVxInz5.js → wardleyDiagram-NUSXRM2D-CoAdP1Gw.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-Dcx1f04K.js → xychartDiagram-5P7HB3ND-CZlfGTEn.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/public/assets/channel-sqt9QaiU.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-rYq1YLUo.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Cc4tKtqG.js +0 -1
- package/payload/server/public/assets/clone-SdNqIUjS.js +0 -1
- package/payload/server/public/assets/data-DQafmlMy.js +0 -1
- package/payload/server/public/assets/graph-CTM-V4lS.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-DPekwBDP.js +0 -1
- /package/payload/server/public/assets/{ChatInput-DBdyKbiU.js → ChatInput-DJsqm_Gf.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "admin",
|
|
3
|
-
"description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, file-attach, wifi,
|
|
3
|
+
"description": "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, file-attach, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform.",
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rubytech LLC"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: admin
|
|
3
|
-
description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, file-attach, wifi,
|
|
3
|
+
description: "Platform administration plugin. Provides system-status, public-hostname (deterministic Cloudflare public-URL resolver, single call returning the operator's canonical hostname so agents never guess property names on :CloudflareHostname nodes), brand-settings, account-manage, account-update, admin-add, admin-remove, admin-list, admin-update-pin, agent-list, agent-config-read, logs-read, plugin-read, skill-load (one-call resolve+read for SKILL.md by skill name, the canonical primitive for loading a named skill; plugin-read remains the reader for references/* and PLUGIN.md), store-skill (deterministic write counterpart to plugin-read; persists operator-authored skills as plugin files under the active account), session-reset, session-resume, file-attach, wifi, action-approval tools (action-pending, action-approve, action-reject, action-edit), and session-retrospective-mark-complete (the deterministic sentinel the session-end Stop hook reads via JSONL grep to release the retrospective gate; admin calls it once per session after running the three-pass retrospective) for managing the Maxy platform."
|
|
4
4
|
tools:
|
|
5
5
|
- name: system-status
|
|
6
6
|
publicAllowlist: false
|
|
@@ -68,6 +68,9 @@ tools:
|
|
|
68
68
|
- name: action-edit
|
|
69
69
|
publicAllowlist: false
|
|
70
70
|
adminAllowlist: true
|
|
71
|
+
- name: session-retrospective-mark-complete
|
|
72
|
+
publicAllowlist: false
|
|
73
|
+
adminAllowlist: true
|
|
71
74
|
hidden:
|
|
72
75
|
- remote-auth-status
|
|
73
76
|
- remote-auth-set-password
|
|
@@ -131,6 +134,7 @@ Tools are available via the `admin` MCP server.
|
|
|
131
134
|
- `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
|
|
132
135
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
133
136
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
137
|
+
- `hooks/session-end-retrospective.sh` — **Stop hook.** Fires on every assistant end_turn but exits 0 with `trigger-skipped reason=<role-not-admin|is-specialist|empty-stdin|missing-transcript|no-intent-match>` on every path EXCEPT when the operator's latest real-user message carries an end-intent token (`/end`, `/archive`, `end session`, `archive this session`, case-insensitive, must match the whole message or a standalone line — not embedded in prose). On that path the hook blocks (exit 2 + structured retrospective instruction on stderr per Stop-hook contract) and emits `[session-retrospective] gate-blocked sessionId=<id> reason=sentinel-absent` until the admin agent calls the `session-retrospective-mark-complete` MCP sentinel tool. Completion is recognised by greping the operator's own JSONL (`transcript_path` on the Stop envelope) for a `tool_use` block whose `name` equals that exact string — never by parsing prose ([[feedback_no_stdout_parsing_for_control_flow]], [[feedback_doctrine_paragraph_is_not_a_gate]]). The sentinel-grep doubles as the re-entry guard: every Stop after the sentinel call sees it and exits 0 with `gate-released sessionId=<id>`. Recursion guard: any session whose `MAXY_SPECIALIST` env is set (specialist subagent dispatched via Task tool) skips the gate. The Sidebar archive button POSTs `/:sessionId/archive` directly to the manager which kills the PTY — no Stop fires after that, so clicking archive is the operator's current escape hatch (opt-out remains deferred per the task spec). All emits go through `POST /api/admin/log-ingest`; the only stderr writer is the gate-blocked path (the instruction block the agent reads). The retrospective itself runs as one or more additional turns inside the operator's existing admin session — Task-tool delegation to `database-operator` is the existing IDENTITY.md "Recording" route, not a parallel admin session ([[feedback_deterministic_means_remove_llm]]).
|
|
134
138
|
- `hooks/turn-completed-graph-write.sh` — **Dormant since Task 214** — the Stop-hook registration is no longer written to account settings.json; admin delegates graph writes to `database-operator` via the Task tool. The script, envelope walker, loopback `/api/admin/claude-sessions` 127.0.0.1 bypass, `[turn-recorder]` emitters, recorder-auto-archive subscriber, and `initialMessage` envelope spec are preserved as reusable infrastructure for any future autonomous post-turn flow. Historical contract (still describes the dormant path): Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
|
|
135
139
|
|
|
136
140
|
## Session identifiers (Task 135)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Task 282 — session-end retrospective Stop hook test suite.
|
|
3
|
+
#
|
|
4
|
+
# Listener-mock pattern from turn-completed-graph-write.test.sh: a local
|
|
5
|
+
# Python HTTP server stands in for /api/admin/log-ingest and records every
|
|
6
|
+
# POST so the assertions can read every log line the hook emitted.
|
|
7
|
+
#
|
|
8
|
+
# Contract under test:
|
|
9
|
+
# - All log emissions go through POST /api/admin/log-ingest. Hook stderr
|
|
10
|
+
# stays silent on every path EXCEPT the gate-blocked path, which writes
|
|
11
|
+
# the three-pass retrospective instruction block to stderr (Stop-hook
|
|
12
|
+
# contract — that is how the agent receives the instruction).
|
|
13
|
+
# - Every gated-off path emits one `trigger-skipped reason=<r>` line and
|
|
14
|
+
# exits 0:
|
|
15
|
+
# role-not-admin | is-specialist | empty-stdin | missing-transcript |
|
|
16
|
+
# no-intent-match
|
|
17
|
+
# - End-intent triggers: `/end`, `/archive`, `end session`,
|
|
18
|
+
# `archive this session`, applied case-insensitively to the latest
|
|
19
|
+
# real-user message. `tool_result`-only user records are NOT the
|
|
20
|
+
# latest user message. Embedded prose ("I'll end session by 5pm")
|
|
21
|
+
# does NOT trigger.
|
|
22
|
+
# - When the intent matches and the sentinel `tool_use` of
|
|
23
|
+
# `session-retrospective-mark-complete` is absent from the JSONL:
|
|
24
|
+
# exit 2, stderr carries the instruction block,
|
|
25
|
+
# `gate-blocked sessionId=<id> reason=sentinel-absent` emitted.
|
|
26
|
+
# - When the intent matches and the sentinel `tool_use` is present:
|
|
27
|
+
# exit 0, `gate-released sessionId=<id>` emitted.
|
|
28
|
+
# - Re-entry: a second Stop fired after the sentinel call still releases
|
|
29
|
+
# (the sentinel-grep is the re-entry guard).
|
|
30
|
+
# - Per-session scoping: a sentinel in a DIFFERENT session's JSONL does
|
|
31
|
+
# NOT release the active gate.
|
|
32
|
+
|
|
33
|
+
set -u
|
|
34
|
+
|
|
35
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/session-end-retrospective.sh"
|
|
36
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
37
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
OP_ID='aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'
|
|
42
|
+
OTHER_ID='99999999-7777-6666-5555-444444444444'
|
|
43
|
+
|
|
44
|
+
TMPFILES=()
|
|
45
|
+
LISTENER_PIDS=()
|
|
46
|
+
cleanup_test_state() {
|
|
47
|
+
for pid in "${LISTENER_PIDS[@]:-}"; do
|
|
48
|
+
if [[ -n "$pid" ]]; then
|
|
49
|
+
kill "$pid" 2>/dev/null || true
|
|
50
|
+
wait "$pid" 2>/dev/null || true
|
|
51
|
+
fi
|
|
52
|
+
done
|
|
53
|
+
for f in "${TMPFILES[@]:-}"; do
|
|
54
|
+
[[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
|
|
55
|
+
done
|
|
56
|
+
}
|
|
57
|
+
trap cleanup_test_state EXIT
|
|
58
|
+
|
|
59
|
+
PASS=0
|
|
60
|
+
FAIL=0
|
|
61
|
+
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
|
62
|
+
fail() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
|
63
|
+
|
|
64
|
+
start_listener() {
|
|
65
|
+
REQ_LOG=$(mktemp); TMPFILES+=("$REQ_LOG")
|
|
66
|
+
LISTENER_PORT=$((39500 + RANDOM % 100))
|
|
67
|
+
python3 - "$LISTENER_PORT" "$REQ_LOG" <<'PY' &
|
|
68
|
+
import sys, http.server, json
|
|
69
|
+
port = int(sys.argv[1])
|
|
70
|
+
log_path = sys.argv[2]
|
|
71
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
72
|
+
def log_message(self, *a, **k): pass
|
|
73
|
+
def do_POST(self):
|
|
74
|
+
n = int(self.headers.get('Content-Length','0') or 0)
|
|
75
|
+
body = self.rfile.read(n).decode('utf-8','replace')
|
|
76
|
+
with open(log_path, 'a', encoding='utf-8') as f:
|
|
77
|
+
f.write(self.path + '\t' + body + '\n')
|
|
78
|
+
self.send_response(200)
|
|
79
|
+
self.send_header('Content-Type','application/json')
|
|
80
|
+
self.end_headers()
|
|
81
|
+
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
82
|
+
http.server.HTTPServer(('127.0.0.1', port), H).serve_forever()
|
|
83
|
+
PY
|
|
84
|
+
LISTENER_PIDS+=("$!")
|
|
85
|
+
for _ in $(seq 1 20); do
|
|
86
|
+
if curl -sS --max-time 1 -X POST "http://127.0.0.1:${LISTENER_PORT}/ping" -d '{}' >/dev/null 2>&1; then
|
|
87
|
+
break
|
|
88
|
+
fi
|
|
89
|
+
sleep 0.1
|
|
90
|
+
done
|
|
91
|
+
: > "$REQ_LOG"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
run_hook() {
|
|
95
|
+
local role="$1"; local specialist="$2"; local stdin_json="$3"
|
|
96
|
+
local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
|
|
97
|
+
local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
|
|
98
|
+
printf '%s' "$stdin_json" | \
|
|
99
|
+
MAXY_SESSION_ROLE="$role" \
|
|
100
|
+
MAXY_SPECIALIST="$specialist" \
|
|
101
|
+
MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
|
|
102
|
+
bash "$HOOK" >"$stdout_file" 2>"$stderr_file"
|
|
103
|
+
HOOK_RC=$?
|
|
104
|
+
HOOK_STDERR=$(cat "$stderr_file")
|
|
105
|
+
HOOK_STDOUT=$(cat "$stdout_file")
|
|
106
|
+
sleep 0.1
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ingest_lines() {
|
|
110
|
+
grep -E '^/api/admin/log-ingest ' "$REQ_LOG" 2>/dev/null | python3 -c '
|
|
111
|
+
import sys, json
|
|
112
|
+
for raw in sys.stdin:
|
|
113
|
+
try:
|
|
114
|
+
_, body = raw.rstrip("\n").split("\t", 1)
|
|
115
|
+
d = json.loads(body)
|
|
116
|
+
if isinstance(d, dict) and isinstance(d.get("line"), str):
|
|
117
|
+
print(d["line"])
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
' || true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Build a JSONL transcript from positional jq-style triples. Each triple is
|
|
124
|
+
# role|text|extra where extra is one of:
|
|
125
|
+
# "" → plain user/assistant text record
|
|
126
|
+
# "tool_use:<NAME>" → assistant record carrying a
|
|
127
|
+
# tool_use block of that name
|
|
128
|
+
# "tool_result" → user record carrying only a
|
|
129
|
+
# tool_result block (not a real
|
|
130
|
+
# user turn)
|
|
131
|
+
write_transcript() {
|
|
132
|
+
local out="$1"; shift
|
|
133
|
+
: > "$out"
|
|
134
|
+
python3 - "$out" "$@" <<'PY'
|
|
135
|
+
import sys, json
|
|
136
|
+
out = sys.argv[1]
|
|
137
|
+
spec = sys.argv[2:]
|
|
138
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
139
|
+
for entry in spec:
|
|
140
|
+
role, text, extra = entry.split("|", 2)
|
|
141
|
+
rec = {"type": role, "timestamp": "2026-05-24T10:00:00.000Z"}
|
|
142
|
+
if role == "user":
|
|
143
|
+
if extra == "tool_result":
|
|
144
|
+
rec["message"] = {
|
|
145
|
+
"role": "user",
|
|
146
|
+
"content": [
|
|
147
|
+
{"type": "tool_result",
|
|
148
|
+
"tool_use_id": "tu_x",
|
|
149
|
+
"content": text or "ok"},
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
else:
|
|
153
|
+
rec["message"] = {"role": "user", "content": text}
|
|
154
|
+
else:
|
|
155
|
+
content_blocks = []
|
|
156
|
+
if text:
|
|
157
|
+
content_blocks.append({"type": "text", "text": text})
|
|
158
|
+
if extra.startswith("tool_use:"):
|
|
159
|
+
tool_name = extra.split(":", 1)[1]
|
|
160
|
+
content_blocks.append({
|
|
161
|
+
"type": "tool_use",
|
|
162
|
+
"id": f"toolu_{tool_name}",
|
|
163
|
+
"name": tool_name,
|
|
164
|
+
"input": {},
|
|
165
|
+
})
|
|
166
|
+
rec["message"] = {
|
|
167
|
+
"id": f"msg_{role}_{hash(text) & 0xffff:04x}",
|
|
168
|
+
"role": "assistant",
|
|
169
|
+
"content": content_blocks,
|
|
170
|
+
}
|
|
171
|
+
f.write(json.dumps(rec) + "\n")
|
|
172
|
+
PY
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
envelope_for() {
|
|
176
|
+
python3 -c '
|
|
177
|
+
import json, sys
|
|
178
|
+
print(json.dumps({"session_id": sys.argv[1], "transcript_path": sys.argv[2]}))
|
|
179
|
+
' "$1" "$2"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Case 1: role != admin → trigger-skipped reason=role-not-admin
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
start_listener
|
|
186
|
+
TRANSCRIPT_PUBLIC=$(mktemp); TMPFILES+=("$TRANSCRIPT_PUBLIC")
|
|
187
|
+
write_transcript "$TRANSCRIPT_PUBLIC" "user|/end|"
|
|
188
|
+
ENV_PUBLIC=$(envelope_for "$OP_ID" "$TRANSCRIPT_PUBLIC")
|
|
189
|
+
: > "$REQ_LOG"
|
|
190
|
+
run_hook "public" "" "$ENV_PUBLIC"
|
|
191
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-1 rc=$HOOK_RC"
|
|
192
|
+
[[ -z "$HOOK_STDERR" ]] || fail "case-1 stderr must be empty, got: $HOOK_STDERR"
|
|
193
|
+
if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=role-not-admin$'; then
|
|
194
|
+
pass "case-1 role=public → trigger-skipped reason=role-not-admin"
|
|
195
|
+
else
|
|
196
|
+
fail "case-1 expected role-not-admin, got: $(ingest_lines)"
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Case 2: MAXY_SPECIALIST set → trigger-skipped is-specialist
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
: > "$REQ_LOG"
|
|
203
|
+
run_hook "admin" "database-operator" "$ENV_PUBLIC"
|
|
204
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-2 rc=$HOOK_RC"
|
|
205
|
+
[[ -z "$HOOK_STDERR" ]] || fail "case-2 stderr must be empty, got: $HOOK_STDERR"
|
|
206
|
+
if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=is-specialist specialist=database-operator$'; then
|
|
207
|
+
pass "case-2 specialist=database-operator → trigger-skipped is-specialist"
|
|
208
|
+
else
|
|
209
|
+
fail "case-2 expected is-specialist, got: $(ingest_lines)"
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Case 3: empty stdin → trigger-skipped reason=empty-stdin
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
: > "$REQ_LOG"
|
|
216
|
+
run_hook "admin" "" ""
|
|
217
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-3 rc=$HOOK_RC"
|
|
218
|
+
if ingest_lines | grep -qE '^trigger-skipped sessionId=.* reason=empty-stdin$'; then
|
|
219
|
+
pass "case-3 empty stdin → trigger-skipped reason=empty-stdin"
|
|
220
|
+
else
|
|
221
|
+
fail "case-3 expected empty-stdin, got: $(ingest_lines)"
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Case 4: missing transcript_path → trigger-skipped missing-transcript
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
: > "$REQ_LOG"
|
|
228
|
+
BAD_ENV=$(python3 -c 'import json,sys; print(json.dumps({"session_id": sys.argv[1]}))' "$OP_ID")
|
|
229
|
+
run_hook "admin" "" "$BAD_ENV"
|
|
230
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-4 rc=$HOOK_RC"
|
|
231
|
+
if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=missing-transcript$"; then
|
|
232
|
+
pass "case-4 missing transcript → trigger-skipped reason=missing-transcript"
|
|
233
|
+
else
|
|
234
|
+
fail "case-4 expected missing-transcript, got: $(ingest_lines)"
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Case 5: no end-intent token → trigger-skipped reason=no-intent-match
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
TRANSCRIPT_BORING=$(mktemp); TMPFILES+=("$TRANSCRIPT_BORING")
|
|
241
|
+
write_transcript "$TRANSCRIPT_BORING" \
|
|
242
|
+
"user|hello|" \
|
|
243
|
+
"assistant|hi|" \
|
|
244
|
+
"user|what's the weather|"
|
|
245
|
+
ENV_BORING=$(envelope_for "$OP_ID" "$TRANSCRIPT_BORING")
|
|
246
|
+
: > "$REQ_LOG"
|
|
247
|
+
run_hook "admin" "" "$ENV_BORING"
|
|
248
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-5 rc=$HOOK_RC"
|
|
249
|
+
[[ -z "$HOOK_STDERR" ]] || fail "case-5 stderr must be empty"
|
|
250
|
+
if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=no-intent-match$"; then
|
|
251
|
+
pass "case-5 no intent token → trigger-skipped reason=no-intent-match"
|
|
252
|
+
else
|
|
253
|
+
fail "case-5 expected no-intent-match, got: $(ingest_lines)"
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Case 6: each intent token triggers gate-blocked (sentinel absent)
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
for TOKEN in "/end" "/archive" "end session" "archive this session"; do
|
|
260
|
+
T_FILE=$(mktemp); TMPFILES+=("$T_FILE")
|
|
261
|
+
write_transcript "$T_FILE" "user|hi|" "assistant|hello|" "user|${TOKEN}|"
|
|
262
|
+
ENV_BLOCK=$(envelope_for "$OP_ID" "$T_FILE")
|
|
263
|
+
: > "$REQ_LOG"
|
|
264
|
+
run_hook "admin" "" "$ENV_BLOCK"
|
|
265
|
+
if [[ "$HOOK_RC" -ne 2 ]]; then
|
|
266
|
+
fail "case-6:${TOKEN} expected rc=2, got $HOOK_RC"
|
|
267
|
+
continue
|
|
268
|
+
fi
|
|
269
|
+
if ! ingest_lines | grep -qE "^trigger sessionId=${OP_ID} token=${TOKEN}$"; then
|
|
270
|
+
fail "case-6:${TOKEN} missing trigger line, got: $(ingest_lines)"
|
|
271
|
+
continue
|
|
272
|
+
fi
|
|
273
|
+
if ! ingest_lines | grep -qE "^gate-blocked sessionId=${OP_ID} reason=sentinel-absent$"; then
|
|
274
|
+
fail "case-6:${TOKEN} missing gate-blocked line, got: $(ingest_lines)"
|
|
275
|
+
continue
|
|
276
|
+
fi
|
|
277
|
+
if ! printf '%s' "$HOOK_STDERR" | grep -q "session-retrospective-mark-complete"; then
|
|
278
|
+
fail "case-6:${TOKEN} instruction block missing sentinel tool name on stderr"
|
|
279
|
+
continue
|
|
280
|
+
fi
|
|
281
|
+
if ! printf '%s' "$HOOK_STDERR" | grep -q "database-operator"; then
|
|
282
|
+
fail "case-6:${TOKEN} instruction block missing database-operator reference"
|
|
283
|
+
continue
|
|
284
|
+
fi
|
|
285
|
+
pass "case-6:${TOKEN} triggers gate-blocked exit 2 + instruction stderr"
|
|
286
|
+
done
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Case 7: intent token + sentinel present → gate-released exit 0
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
T_RELEASE=$(mktemp); TMPFILES+=("$T_RELEASE")
|
|
292
|
+
write_transcript "$T_RELEASE" \
|
|
293
|
+
"user|hi|" \
|
|
294
|
+
"assistant|hello|" \
|
|
295
|
+
"user|/end|" \
|
|
296
|
+
"assistant|running retrospective|" \
|
|
297
|
+
"assistant||tool_use:session-retrospective-mark-complete"
|
|
298
|
+
ENV_REL=$(envelope_for "$OP_ID" "$T_RELEASE")
|
|
299
|
+
: > "$REQ_LOG"
|
|
300
|
+
run_hook "admin" "" "$ENV_REL"
|
|
301
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-7 expected rc=0, got $HOOK_RC"
|
|
302
|
+
[[ -z "$HOOK_STDERR" ]] || fail "case-7 stderr must be empty, got: $HOOK_STDERR"
|
|
303
|
+
if ingest_lines | grep -qE "^gate-released sessionId=${OP_ID}$"; then
|
|
304
|
+
pass "case-7 sentinel present → gate-released exit 0"
|
|
305
|
+
else
|
|
306
|
+
fail "case-7 expected gate-released, got: $(ingest_lines)"
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Case 8: re-entry — Stop fires twice after sentinel call, both release
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
: > "$REQ_LOG"
|
|
313
|
+
run_hook "admin" "" "$ENV_REL"
|
|
314
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-8 second-stop rc=$HOOK_RC"
|
|
315
|
+
if ingest_lines | grep -qE "^gate-released sessionId=${OP_ID}$"; then
|
|
316
|
+
pass "case-8 re-entry: second Stop after sentinel still releases"
|
|
317
|
+
else
|
|
318
|
+
fail "case-8 second Stop did not emit gate-released, got: $(ingest_lines)"
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Case 9: per-session scoping — sentinel lives in a different session's
|
|
323
|
+
# JSONL; active gate must NOT release
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
T_OTHER=$(mktemp); TMPFILES+=("$T_OTHER")
|
|
326
|
+
write_transcript "$T_OTHER" \
|
|
327
|
+
"user|/end|" \
|
|
328
|
+
"assistant||tool_use:session-retrospective-mark-complete"
|
|
329
|
+
# Active gate inspects only T_ACTIVE (no sentinel inside).
|
|
330
|
+
T_ACTIVE=$(mktemp); TMPFILES+=("$T_ACTIVE")
|
|
331
|
+
write_transcript "$T_ACTIVE" "user|/end|"
|
|
332
|
+
ENV_ACTIVE=$(envelope_for "$OP_ID" "$T_ACTIVE")
|
|
333
|
+
: > "$REQ_LOG"
|
|
334
|
+
run_hook "admin" "" "$ENV_ACTIVE"
|
|
335
|
+
[[ "$HOOK_RC" -eq 2 ]] || fail "case-9 expected rc=2 (other session's sentinel must not release), got $HOOK_RC"
|
|
336
|
+
if ingest_lines | grep -qE "^gate-blocked sessionId=${OP_ID} reason=sentinel-absent$"; then
|
|
337
|
+
pass "case-9 per-session scoping: sentinel in another transcript does not release"
|
|
338
|
+
else
|
|
339
|
+
fail "case-9 expected gate-blocked, got: $(ingest_lines)"
|
|
340
|
+
fi
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Case 10: `tool_result`-only user record is NOT the latest user message;
|
|
344
|
+
# the prior real user message wins.
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
T_TR_ONLY=$(mktemp); TMPFILES+=("$T_TR_ONLY")
|
|
347
|
+
write_transcript "$T_TR_ONLY" \
|
|
348
|
+
"user|/end|" \
|
|
349
|
+
"assistant||tool_use:Read" \
|
|
350
|
+
"user|noise|tool_result"
|
|
351
|
+
ENV_TR=$(envelope_for "$OP_ID" "$T_TR_ONLY")
|
|
352
|
+
: > "$REQ_LOG"
|
|
353
|
+
run_hook "admin" "" "$ENV_TR"
|
|
354
|
+
[[ "$HOOK_RC" -eq 2 ]] || fail "case-10 expected rc=2 (tool_result must not mask /end), got $HOOK_RC"
|
|
355
|
+
if ingest_lines | grep -qE "^gate-blocked sessionId=${OP_ID} reason=sentinel-absent$"; then
|
|
356
|
+
pass "case-10 tool_result-only user record does not mask latest real user /end"
|
|
357
|
+
else
|
|
358
|
+
fail "case-10 expected gate-blocked, got: $(ingest_lines)"
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Case 11: embedded prose does NOT trigger ("I'll end session by 5pm")
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
T_PROSE=$(mktemp); TMPFILES+=("$T_PROSE")
|
|
365
|
+
write_transcript "$T_PROSE" "user|I'll end session by 5pm|"
|
|
366
|
+
ENV_PROSE=$(envelope_for "$OP_ID" "$T_PROSE")
|
|
367
|
+
: > "$REQ_LOG"
|
|
368
|
+
run_hook "admin" "" "$ENV_PROSE"
|
|
369
|
+
[[ "$HOOK_RC" -eq 0 ]] || fail "case-11 expected rc=0 (prose must not trigger), got $HOOK_RC"
|
|
370
|
+
if ingest_lines | grep -qE "^trigger-skipped sessionId=${OP_ID} reason=no-intent-match$"; then
|
|
371
|
+
pass "case-11 embedded prose 'end session' does not trigger gate"
|
|
372
|
+
else
|
|
373
|
+
fail "case-11 expected no-intent-match, got: $(ingest_lines)"
|
|
374
|
+
fi
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Case 12: case-insensitive match — `/END` triggers like `/end`
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
T_CASE=$(mktemp); TMPFILES+=("$T_CASE")
|
|
380
|
+
write_transcript "$T_CASE" "user|/END|"
|
|
381
|
+
ENV_CASE=$(envelope_for "$OP_ID" "$T_CASE")
|
|
382
|
+
: > "$REQ_LOG"
|
|
383
|
+
run_hook "admin" "" "$ENV_CASE"
|
|
384
|
+
[[ "$HOOK_RC" -eq 2 ]] || fail "case-12 expected rc=2 for /END, got $HOOK_RC"
|
|
385
|
+
if ingest_lines | grep -qE "^trigger sessionId=${OP_ID} token=/end$"; then
|
|
386
|
+
pass "case-12 case-insensitive: /END normalises to /end"
|
|
387
|
+
else
|
|
388
|
+
fail "case-12 expected token=/end, got: $(ingest_lines)"
|
|
389
|
+
fi
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# Summary
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
echo
|
|
395
|
+
echo "session-end-retrospective tests: $PASS passed, $FAIL failed"
|
|
396
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -182,7 +182,7 @@ except Exception:
|
|
|
182
182
|
BYTES="${GUARD_VERDICT##*:}"
|
|
183
183
|
echo "[pre-tool-use] guard=base64-tool-result bytes=${BYTES} tool=Bash reason=${REASON} action=reject" >&2
|
|
184
184
|
echo "Blocked: Bash command would emit binary as inline base64/hex to stdout, which lands in the assistant turn and overloads the model context (the 2026-05-09 Rubytech-invoice path hit 66% context after a single ~33 KB tool_result)." >&2
|
|
185
|
-
echo "Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, file-
|
|
185
|
+
echo "Instead: save the bytes directly to \$ACCOUNT_DIR/tmp/<sha1>.<ext> and operate on the file via path — Read for inspection, <img src=\"./<file>\"> for HTML embedding, the \`file-presentation\` skill for delivery. Decoding base64 (e.g. 'base64 -d in.b64 > out.bin') is allowed." >&2
|
|
186
186
|
exit 2
|
|
187
187
|
;;
|
|
188
188
|
*)
|