@rubytech/create-maxy-code 0.1.569 → 0.1.570

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 (28) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +3 -3
  3. package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
  4. package/payload/platform/plugins/admin/hooks/pdf-text-layer-inject.sh +137 -0
  5. package/payload/platform/plugins/admin/skills/a4-print-documents/SKILL.md +12 -1
  6. package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
  7. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  8. package/payload/platform/plugins/docs/references/admin-session.md +15 -0
  9. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +15 -39
  10. package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
  11. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +8 -31
  12. package/payload/platform/scripts/setup-account.sh +7 -62
  13. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts +129 -0
  14. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
  15. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
  16. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
  17. package/payload/platform/services/claude-session-manager/dist/index.js +20 -0
  18. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  19. package/payload/platform/templates/account-settings.json +6 -0
  20. package/payload/server/server.js +750 -656
  21. package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
  22. package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
  23. package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
  24. package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
  25. package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
  26. package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
  27. package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
  28. package/payload/platform/scripts/lib/account-settings-public-surface.sh +0 -109
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.569",
3
+ "version": "0.1.570",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -276,15 +276,15 @@ Tools are available via the `admin` MCP server.
276
276
  - `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Third check — a running header on every physical page.** Chrome decides physical pagination at print time, so a document whose header is a hand-placed `<div>` next to a hand-placed page break loses that header on any page the content bled onto (job 1854, 2026-08-04: 8 headerless pages out of 21, with `run-head` and `page-break` counts matching at 20 apiece, which is why counting the two against each other is blind to the fault). This check is deliberately **receipt-free and path-agnostic**, because template-seeded documents — budget estimates, valuations, works orders, survey sheets — never pass through `quote-render` and are exactly the ones that reproduce it. It resolves the source HTML from the render receipt when the PDF is a gated client quote, else from the sibling `<same-basename>.html` beside the PDF (the documented `browser-pdf-save` convention in `plugins/business-assistant/references/invoicing.md` step 4 and `plugins/business-assistant/references/document-management.md` step 4). The **header marker** is the literal string `content` of the first `@page` top margin box that carries one, tried `@top-center` → `@top-right` → `@top-left`, with CSS escapes and HTML entities resolved; a `content:url()` logo box carries no extractable text and is skipped, and a marker under 4 characters counts as absent. Pages are read one at a time with `pdftotext -f N -l N` **cropped to the top 12% of the page** and compared on whitespace-collapsed text. The crop is load-bearing, not an optimisation: a running footer routinely repeats the same reference as the header (the GLS footer is `… Builders Est. 1987 · Quote <ref>`), so a whole-page match reports a header on a page that carries only a footer — measured on a real 10-page print, where page 1 matched on its footer alone while its header was correctly suppressed. Twelve percent covers a top margin as deep as 30mm on A4 and excludes the footer. Checking starts at **page 2** when the source declares `@page :first` (which suppresses the boxes so page 1 keeps its own masthead) and from page 1 otherwise. It blocks (exit 2) when any checked page lacks the marker. **Only a document that declares a running header is ever gated** — a brochure or any document without one passes untouched. **Block message:** `Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages (…). The header must be a property of the page itself — a page margin box — not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page.` **Log line:** `<ISO> [quote-render] op=pdf-header-conformance file=<basename> pages=<N> pagesChecked=<n> pagesHeaderless=<k> session=<sid>`, emitted on **every** decisive call including a clean one (`pagesHeaderless=0`), and on each skip path as `pagesChecked=0 reason=<no-source-html|no-running-header|single-page|no-poppler>` — so a clean run is distinguishable from a check that never ran. A block additionally emits `op=bypass file=<basename> reason=pdf-header-conformance detail=pagesHeaderless=<k> pages=<comma list>`. **Sink:** `<logDir>/quote-render-conformance.log`, resolved by the `LOG_DIR` → `CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `ACCOUNT_DIR/logs` ladder that `mailbox-inject.sh` uses, **not** `server.log` — a hook's stderr on exit 0 reaches no sink at all (measured 2026-08-06 on the laptop's `sitedesk-code` install: no bracketed hook line appears in `server.log` live or rotated, nor in the JSONL transcripts), so a stderr-only line would have been an audit nothing reads. The line is echoed to stderr as well, so a blocking run still shows it to the agent. **Fail-open** additionally on absent `pdfinfo`/`pdftotext`, an unresolvable source HTML, and a source that declares no running header. **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
277
277
  - `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.
278
278
  - `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.
279
- - `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your answer and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_askgate` (`platform/scripts/lib/account-settings-askgate.sh`) on every install to retrofit the carrier gate onto every existing account's `settings.json` idempotent surgical jq patch of the `AskUserQuestion` matcher, one `[backfill-1683] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
280
- - `hooks/public-tool-surface.sh` — **PreToolUse matcher-less (every tool) + UserPromptSubmit + Stop.** Records the tool surface a `role='public'` spawn REALISES, which nothing recorded before: `tool-allowlist` records the surface a spawn was given, and `[public-surface] op=audit` reconciles MCP server names from spawn-tee filenames, so it is blind to `Read`, `Bash`, `Skill` and `ToolSearch` (no MCP child, no tee file). On 2026-08-01 five such tools ran in a session spawned with `--allowed-tools mcp__whatsapp-channel__reply --strict-mcp-config --permission-mode dontAsk` (that reply tool is now retired; a public spawn's allowlist is the deny-basis anchor alone) and produced no line. **Input:** `MAXY_PUBLIC_ALLOWED_TOOLS`, stamped by both spawn paths (`http-server.ts` rc-spawn, `pty-spawner.ts`) for `role='public'` only, carrying that spawn's final `--allowed-tools` argv set comma-joined. Absent means not a public spawn: the hook exits 0 in silence. **Log lines** (server.log only — a visitor's PTY gains no output): `[public-surface] op=tool-realised sessionId=<id8> tool=<name> allowed=<y|n>` per call, `allowed` being exact membership of the stamped set; `[public-surface] op=realised-census sessionId=<id8> calls=<n> disallowed=<n>` at Stop, emitted including zero so a session with no disallowed call is distinguishable from one the hook never saw. Per-call tallies are appends to `<logdir>/public-surface/<sanitised-session-id>`, removed by the census, so no counter mutex is needed. The `UserPromptSubmit` registration is the turn-start reset and emits nothing: the stuck-turn recycler kills a wedged PTY and resumes the SAME session id, so that turn's `Stop` never fires and its tally would otherwise be counted into the next turn's census (measured: three calls in a killed turn plus one call in the next reported `calls=4 disallowed=3`). **Never blocks:** exit 0 on every path, including unparseable stdin, absent `session_id`, missing `python3` and an unwritable log. **Reach on upgrade:** `provision-account-dir.sh` writes both entries only at account-provision time, so `setup-account.sh` runs `reconcile_all_accounts_public_surface` (`platform/scripts/lib/account-settings-public-surface.sh`) on every install to retrofit them onto every existing account, one `[backfill-2339] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
279
+ - `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your answer and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_settings` (`platform/scripts/lib/account-settings-reconcile.sh`) on every install, converging every existing account's `settings.json` against `platform/templates/account-settings.json` which carries this matcher with one `[settings-reconcile] account=<id> status=<already-set|updated|absent-seeded|rewrite-failed|no-template>` line per account and one `[settings-reconcile] accounts=<n> divergent=<n>` summary, contents never echoed. (The per-hook backfill lib this converge replaced has been retired.)
280
+ - `hooks/public-tool-surface.sh` — **PreToolUse matcher-less (every tool) + UserPromptSubmit + Stop.** Records the tool surface a `role='public'` spawn REALISES, which nothing recorded before: `tool-allowlist` records the surface a spawn was given, and `[public-surface] op=audit` reconciles MCP server names from spawn-tee filenames, so it is blind to `Read`, `Bash`, `Skill` and `ToolSearch` (no MCP child, no tee file). On 2026-08-01 five such tools ran in a session spawned with `--allowed-tools mcp__whatsapp-channel__reply --strict-mcp-config --permission-mode dontAsk` (that reply tool is now retired; a public spawn's allowlist is the deny-basis anchor alone) and produced no line. **Input:** `MAXY_PUBLIC_ALLOWED_TOOLS`, stamped by both spawn paths (`http-server.ts` rc-spawn, `pty-spawner.ts`) for `role='public'` only, carrying that spawn's final `--allowed-tools` argv set comma-joined. Absent means not a public spawn: the hook exits 0 in silence. **Log lines** (server.log only — a visitor's PTY gains no output): `[public-surface] op=tool-realised sessionId=<id8> tool=<name> allowed=<y|n>` per call, `allowed` being exact membership of the stamped set; `[public-surface] op=realised-census sessionId=<id8> calls=<n> disallowed=<n>` at Stop, emitted including zero so a session with no disallowed call is distinguishable from one the hook never saw. Per-call tallies are appends to `<logdir>/public-surface/<sanitised-session-id>`, removed by the census, so no counter mutex is needed. The `UserPromptSubmit` registration is the turn-start reset and emits nothing: the stuck-turn recycler kills a wedged PTY and resumes the SAME session id, so that turn's `Stop` never fires and its tally would otherwise be counted into the next turn's census (measured: three calls in a killed turn plus one call in the next reported `calls=4 disallowed=3`). **Never blocks:** exit 0 on every path, including unparseable stdin, absent `session_id`, missing `python3` and an unwritable log. **Reach on upgrade:** `provision-account-dir.sh` writes both entries only at account-provision time, so `setup-account.sh` runs `reconcile_all_accounts_settings` on every install, converging every existing account's `settings.json` against `platform/templates/account-settings.json` which carries all three entries with one `[settings-reconcile] account=<id> status=…` line per account, contents never echoed. (The per-hook backfill lib this converge replaced has been retired.)
281
281
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
282
282
  - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent`.** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone. Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
283
283
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit.** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal this hook was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
284
284
  - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook.** Injects the standing prompt-optimiser restatement directive plus the per-turn four-rung routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool, where the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if the deliverable recurs and no specialist or skill owns it, author its owning skill via `skill-builder` (or `agent-builder` for a worker agent) and produce through it; **(4)** only if it is a genuine one-off, author inline as the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). The directive also carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. The directive is also **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
285
285
  - `hooks/preference-consult-directive.sh` — **UserPromptSubmit hook.** Injects a standing `<preference-adherence>` block naming the two-layer preference architecture (layer 1 the fixed block already in front of the agent every turn; layer 2 the account's own saved `Preference` records, read with `profile-read`, holding signature policy, header, naming and styling) and two directives: consult the relevant layer-2 preferences with `profile-read` before any customer-facing document deliverable, and store finished deliverables under `documents/` or the project folder because `output/` is scratch of last resort. Prompt-independent and always exit 0 (fail-open). Breadcrumb (stderr): `[pref-wrapper] op=inject`. This is the advisory companion to the `preference-consult-gate` hard gate.
286
286
  - `hooks/datetime-inject.sh` — **UserPromptSubmit hook.** Injects a fresh `<datetime>` block as `additionalContext` on every turn (`refresh=per-turn`), so the agent always has the current instant — date, time, timezone, weekday, and the raw UTC ISO — without hand-computing it and without depending on Claude Code's spawn-time "Today's date" line going stale. The instant is computed fresh each turn; the timezone is **not** computed here — it is the operator's own zone, resolved from their `UserProfile.timezone` on the graph once per admin spawn and stamped into `MAXY_ACCOUNT_TZ` (see `platform/services/claude-session-manager/src/account-timezone.ts`). When `MAXY_ACCOUNT_TZ` is empty (a non-admin turn, an unset profile timezone, or a neo4j hiccup at spawn) the block falls back to the server's own zone and labels the source, so a reader can always tell which clock the agent saw. This is the current-time surface the `datetime` skill points at. **Fail-open** on every path (missing `python3` or `node`, any formatting error → exit 0, empty stdout — the turn is never blocked). The hook appends `<ts> [datetime-inject] injected tz=<zone> source=<graph|server-fallback> refresh=per-turn session=<id>` to `$LOG_DIR/datetime-inject.log` so "did the agent have the right time this turn, and from which clock?" is answerable from logs without reproduction.
287
- - `hooks/mailbox-inject.sh` — **UserPromptSubmit hook.** Injects a `<mailboxes>` block as `additionalContext` on every turn (`refresh=per-turn`), naming every Outlook mailbox attached to this account and the plugin that holds it, so a session never learns attachment state by polling one mail plugin and reading that plugin's miss as an account-wide fact (the incident it exists for: a WhatsApp session called `email-status`, got the IMAP plugin's miss, and told a customer the account had no access to a mailbox the outlook plugin had read that morning). Reads `$ACCOUNT_DIR/secrets/outlook/` directly as a **read-only observer** — it never writes there, and never performs the rename `MailboxRegistry.migrateLegacy()` does. Signed-in mailboxes come from `mailboxes/<key>/tokens.enc` plus the pre-migration top-level `tokens.enc`, keeping only directories that actually contain a blob (the registry's own gate — the revocation path unlinks the blob and leaves the directory). Delegated mailboxes come from the plaintext `delegated.json`, keyed by normalised address so two keys differing only in case or space collapse to one line exactly as `DelegatedStore.read()` collapses them, with the delegator resolved against the **readable** set exactly as `MailboxRegistry.resolve()` does, so a delegated line never promises a mailbox every `outlook-*` call would refuse. A legacy blob whose `graphUserId` already has a `mailboxes/` directory is skipped rather than listed twice, mirroring the branch of `migrateLegacy()` that unlinks it. Every rendered subject is stripped of control characters and angle brackets and length-bounded, so the block's frame holds by construction rather than by the upstream invariants that currently keep them out. Three fields leave the decrypt — `mail`, `graphUserId`, `refreshTokenExpiry` — and **no token value is ever emitted, logged or written**; the `node` reader carries `2>/dev/null` and try/catches every read and parse, because node prints the entire parsed source text on an uncaught exception. **Fail-open** on every path. **Env asymmetry:** `ACCOUNT_DIR` is stamped unconditionally on the rc-spawn path (`buildRcChildEnv`) but only for admin and specialist spawns on the pty-spawner path, and `LOG_DIR` exists on the rc path only — so the hook resolves the account dir from `$PWD` when the stamp is absent (the spawn cwd is the account dir on both paths) and walks `$LOG_DIR` → `$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<accountDir>/logs` for its breadcrumb. **Reach on upgrade:** the provision-time writer re-runs for the house account only, so `platform/scripts/lib/account-settings-mailbox-inject.sh` backfills every account from `setup-account.sh` on every install (`[backfill-2157]` per account) same pattern as the `account-settings-askgate.sh` and `account-settings-pdf-conformance.sh` libs beside it. The breadcrumb is `<ts> [mailbox-inject] injected account=<id> outlook=<n> delegated=<n> usable=<n> expired=<n> unreadable=<n> degraded=<yes|no> imap=not-read source=disk refresh=per-turn session=<id>` in `<logDir>/mailbox-inject.log`, with `op=skip reason=<no-account-dir|unreadable-source|reader-failed>` and `op=degrade reason=delegated-unreadable` on the degraded paths. `imap=not-read`, never `imap=0`: IMAP mailboxes are not surveyed at all by this hook, and a literal zero in a field shaped like the two measured counts would be read in a later incident review as a census. No address is written to the log, because `/api/admin/logs` serves every account's log dir to any LAN caller without auth. A missing `node` or `python3` exits 0 before the breadcrumb block, so a PATH regression produces **no line at all** rather than a `reason=no-node` line.
287
+ - `hooks/mailbox-inject.sh` — **UserPromptSubmit hook.** Injects a `<mailboxes>` block as `additionalContext` on every turn (`refresh=per-turn`), naming every Outlook mailbox attached to this account and the plugin that holds it, so a session never learns attachment state by polling one mail plugin and reading that plugin's miss as an account-wide fact (the incident it exists for: a WhatsApp session called `email-status`, got the IMAP plugin's miss, and told a customer the account had no access to a mailbox the outlook plugin had read that morning). Reads `$ACCOUNT_DIR/secrets/outlook/` directly as a **read-only observer** — it never writes there, and never performs the rename `MailboxRegistry.migrateLegacy()` does. Signed-in mailboxes come from `mailboxes/<key>/tokens.enc` plus the pre-migration top-level `tokens.enc`, keeping only directories that actually contain a blob (the registry's own gate — the revocation path unlinks the blob and leaves the directory). Delegated mailboxes come from the plaintext `delegated.json`, keyed by normalised address so two keys differing only in case or space collapse to one line exactly as `DelegatedStore.read()` collapses them, with the delegator resolved against the **readable** set exactly as `MailboxRegistry.resolve()` does, so a delegated line never promises a mailbox every `outlook-*` call would refuse. A legacy blob whose `graphUserId` already has a `mailboxes/` directory is skipped rather than listed twice, mirroring the branch of `migrateLegacy()` that unlinks it. Every rendered subject is stripped of control characters and angle brackets and length-bounded, so the block's frame holds by construction rather than by the upstream invariants that currently keep them out. Three fields leave the decrypt — `mail`, `graphUserId`, `refreshTokenExpiry` — and **no token value is ever emitted, logged or written**; the `node` reader carries `2>/dev/null` and try/catches every read and parse, because node prints the entire parsed source text on an uncaught exception. **Fail-open** on every path. **Env asymmetry:** `ACCOUNT_DIR` is stamped unconditionally on the rc-spawn path (`buildRcChildEnv`) but only for admin and specialist spawns on the pty-spawner path, and `LOG_DIR` exists on the rc path only — so the hook resolves the account dir from `$PWD` when the stamp is absent (the spawn cwd is the account dir on both paths) and walks `$LOG_DIR` → `$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs` → `<accountDir>/logs` for its breadcrumb. **Reach on upgrade:** the provision-time writer re-runs for the house account only, so `reconcile_all_accounts_settings` converges every account against `platform/templates/account-settings.json` which carries this `UserPromptSubmit` entry — from `setup-account.sh` on every install, one `[settings-reconcile] account=<id> status=…` line per account. (The four per-hook backfill libs this converge replaced have been retired.) The breadcrumb is `<ts> [mailbox-inject] injected account=<id> outlook=<n> delegated=<n> usable=<n> expired=<n> unreadable=<n> degraded=<yes|no> imap=not-read source=disk refresh=per-turn session=<id>` in `<logDir>/mailbox-inject.log`, with `op=skip reason=<no-account-dir|unreadable-source|reader-failed>` and `op=degrade reason=delegated-unreadable` on the degraded paths. `imap=not-read`, never `imap=0`: IMAP mailboxes are not surveyed at all by this hook, and a literal zero in a field shaped like the two measured counts would be read in a later incident review as a census. No address is written to the log, because `/api/admin/logs` serves every account's log dir to any LAN caller without auth. A missing `node` or `python3` exits 0 before the breadcrumb block, so a PATH regression produces **no line at all** rather than a `reason=no-node` line.
288
288
  - `hooks/prompt-optimiser-compliance.sh` — **Stop hook.** After each admin turn, reads the just-finished turn from `transcript_path` and appends `<ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"` to `$LOG_DIR/prompt-optimiser-directive.log` (and stderr) when the routing directive fired, the prompt was non-trivial (not a slash-command, not a one-word confirmation), and the turn took **no route** — no `Agent` dispatch, no `Skill` load, no `ToolSearch`, no `mcp__*` tool call. This is the standing compliance signal that surfaces the session-`da0b12d4` failure class (agent answers a capability question from memory) as a visible event instead of a silent stale answer. Directive-fired is detected by the marker `PROMPT-OPTIMISER DIRECTIVE` in the turn slice, so it is robust to the CC-version difference in how `UserPromptSubmit` `additionalContext` is recorded (`attachment`/`hook_success` vs `hook_additional_context`). **Known limitation:** "direct continuation of the prior turn" is not detectable from the transcript, so a continuation turn that legitimately needs no route can be flagged; treat the log as a review signal, not a gate. **Fail-open:** no python3, no `transcript_path`, or an unreadable transcript → exit 0, no output. Lives in the same log as the directive breadcrumb, so a single `grep` interleaves "fired" and "no-route" into one per-session timeline; cross-check via `platform/scripts/logs-read.sh <sessionKey>`. This is a lightweight transcript read, not a per-turn spawn (contrast the turn recorder below).
289
289
  - **Turn recorder — removed entirely.** The `turn-completed-graph-write.sh` Stop hook, the `/api/admin/claude-sessions` loopback bypass it relied on, the `[turn-recorder]` emitters, the envelope walker, and the recorder-auto-archive subscriber are deleted. It had been dormant for a long time (never re-registered in settings.json); the admin now writes to the graph by delegating to `database-operator` via the Task tool inside the live session, and the on-demand `/insight` pass (`skills/insight/SKILL.md`, a registered admin skill) is the per-session review. There is no per-turn spawn.
290
290
 
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for pdf-text-layer-inject.sh.
3
+ #
4
+ # Read on a PDF returns page images and no text, so a value copied out of a PDF
5
+ # is read off a picture. This hook puts the PDF's own text layer on stderr so
6
+ # the characters are in context alongside the images.
7
+ #
8
+ # Covers:
9
+ # A. a text-layer PDF → exit 2, text on stderr, injected=yes
10
+ # B. the injected text is the real content → stderr carries the postcode
11
+ # C. a PDF with no text layer → exit 0, silent, scanned-no-text
12
+ # D. a non-PDF Read → exit 0, NO log line at all
13
+ # E. a path that does not exist → exit 0, unreadable
14
+ # F. an uppercase .PDF extension → exit 2 (match is case-insensitive)
15
+ # G. a PreToolUse envelope → exit 0, no log line
16
+ # H. a non-Read tool → exit 0, no log line
17
+ # I. empty stdin → exit 0 (fail-open)
18
+ # J. malformed JSON → exit 0 (fail-open)
19
+ # K. an extraction over the cap → exit 2, payload capped, omission line
20
+ # L. pdftotext absent from PATH → exit 0, no-poppler
21
+ # M. the log line names the account → per-account counting is possible
22
+
23
+ set -u
24
+
25
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/pdf-text-layer-inject.sh"
26
+ if [[ ! -f "$HOOK" ]]; then
27
+ echo "FAIL: $HOOK not found" >&2
28
+ exit 1
29
+ fi
30
+
31
+ PASS=0
32
+ FAIL=0
33
+ TMP="$(mktemp -d)"
34
+ trap 'rm -rf "$TMP"' EXIT
35
+ LOGD="$TMP/logs"
36
+ mkdir -p "$LOGD"
37
+ LOGFILE="$LOGD/pdf-text-layer.log"
38
+
39
+ # --- fixtures ---------------------------------------------------------------
40
+ # Hand-built PDFs: no external generator, so the test is deterministic on any
41
+ # box that has pdftotext. An empty content stream is the no-text-layer case.
42
+ mkpdf() { # <path> <text>
43
+ python3 - "$1" "$2" <<'PY'
44
+ import sys
45
+ path, text = sys.argv[1], sys.argv[2]
46
+ stream = (b"BT /F1 12 Tf 72 700 Td (" + text.encode() + b") Tj ET") if text else b""
47
+ objs = [
48
+ b"<< /Type /Catalog /Pages 2 0 R >>",
49
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
50
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R "
51
+ b"/Resources << /Font << /F1 5 0 R >> >> >>",
52
+ b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
53
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
54
+ ]
55
+ out = b"%PDF-1.4\n"
56
+ offs = []
57
+ for i, o in enumerate(objs, 1):
58
+ offs.append(len(out))
59
+ out += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
60
+ xref = len(out)
61
+ out += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
62
+ for off in offs:
63
+ out += ("%010d 00000 n \n" % off).encode()
64
+ out += (b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\n"
65
+ b"startxref\n" + str(xref).encode() + b"\n%%EOF\n")
66
+ open(path, "wb").write(out)
67
+ PY
68
+ }
69
+
70
+ mkpdf "$TMP/text.pdf" "Address: 20 Station Road, Dunmow, CM6 1EJ"
71
+ mkpdf "$TMP/scan.pdf" ""
72
+ mkpdf "$TMP/UPPER.PDF" "Address: 20 Station Road, Dunmow, CM6 1EJ"
73
+ mkpdf "$TMP/big.pdf" "$(python3 -c 'print("A"*60)')"
74
+ printf 'not a pdf\n' > "$TMP/notes.txt"
75
+
76
+ envelope() { # <event> <tool> <file_path>
77
+ python3 -c '
78
+ import json, sys
79
+ print(json.dumps({
80
+ "hook_event_name": sys.argv[1],
81
+ "tool_name": sys.argv[2],
82
+ "tool_input": {"file_path": sys.argv[3]},
83
+ "session_id": "test-session",
84
+ }))' "$1" "$2" "$3"
85
+ }
86
+
87
+ run() { # <name> <stdin> <expected_exit> → sets ERR (stderr) and RC
88
+ local name="$1" stdin="$2" want="$3"
89
+ ERR="$(printf '%s' "$stdin" | bash "$HOOK" 2>&1 1>/dev/null)"
90
+ RC=$?
91
+ if [[ "$RC" -eq "$want" ]]; then
92
+ echo "PASS: $name (exit=$RC)"
93
+ PASS=$((PASS + 1))
94
+ else
95
+ echo "FAIL: $name (expected exit=$want, got=$RC)" >&2
96
+ FAIL=$((FAIL + 1))
97
+ fi
98
+ }
99
+
100
+ check() { # <name> <condition-result 0|1>
101
+ if [[ "$2" -eq 0 ]]; then
102
+ echo "PASS: $1"
103
+ PASS=$((PASS + 1))
104
+ else
105
+ echo "FAIL: $1" >&2
106
+ FAIL=$((FAIL + 1))
107
+ fi
108
+ }
109
+
110
+ export LOG_DIR="$LOGD"
111
+
112
+ # A + B: a text-layer PDF injects, and the injected text is the real content.
113
+ run "A: a text-layer PDF → exit 2" "$(envelope PostToolUse Read "$TMP/text.pdf")" 2
114
+ grep -q "CM6 1EJ" <<<"$ERR"; check "B: stderr carries the postcode from the text layer" $?
115
+ grep -q "file=text.pdf .*injected=yes reason=text-layer" "$LOGFILE"; check "B2: log line records injected=yes" $?
116
+
117
+ # C: no text layer → silent, but still logged.
118
+ : > "$LOGFILE"
119
+ run "C: a PDF with no text layer → exit 0" "$(envelope PostToolUse Read "$TMP/scan.pdf")" 0
120
+ [[ -z "$ERR" ]]; check "C2: no text layer emits nothing on stderr" $?
121
+ grep -q "file=scan.pdf chars=0 injected=no reason=scanned-no-text" "$LOGFILE"; check "C3: log line records scanned-no-text" $?
122
+
123
+ # D: a non-PDF Read is not this hook's business and writes no line.
124
+ : > "$LOGFILE"
125
+ run "D: a non-PDF Read → exit 0" "$(envelope PostToolUse Read "$TMP/notes.txt")" 0
126
+ [[ ! -s "$LOGFILE" ]]; check "D2: a non-PDF Read writes no log line" $?
127
+
128
+ # E: an absent path.
129
+ : > "$LOGFILE"
130
+ run "E: a path that does not exist → exit 0" "$(envelope PostToolUse Read "$TMP/missing.pdf")" 0
131
+ grep -q "file=missing.pdf chars=0 injected=no reason=unreadable" "$LOGFILE"; check "E2: log line records unreadable" $?
132
+
133
+ # F: extension match is case-insensitive.
134
+ run "F: an uppercase .PDF → exit 2" "$(envelope PostToolUse Read "$TMP/UPPER.PDF")" 2
135
+
136
+ # G + H: wrong event, wrong tool.
137
+ : > "$LOGFILE"
138
+ run "G: a PreToolUse envelope → exit 0" "$(envelope PreToolUse Read "$TMP/text.pdf")" 0
139
+ run "H: a non-Read tool → exit 0" "$(envelope PostToolUse Write "$TMP/text.pdf")" 0
140
+ [[ ! -s "$LOGFILE" ]]; check "H2: neither writes a log line" $?
141
+
142
+ # I + J: fail-open on unusable input.
143
+ run "I: empty stdin → exit 0" "" 0
144
+ run "J: malformed JSON → exit 0" "{not json" 0
145
+
146
+ # K: the cap. CAP is lowered by env so the test does not build a 100KB PDF.
147
+ : > "$LOGFILE"
148
+ ERR="$(printf '%s' "$(envelope PostToolUse Read "$TMP/big.pdf")" \
149
+ | PDF_TEXT_LAYER_CAP=10 bash "$HOOK" 2>&1 1>/dev/null)"
150
+ RC=$?
151
+ [[ "$RC" -eq 2 ]]; check "K: an extraction over the cap → exit 2" $?
152
+ grep -q "characters omitted" <<<"$ERR"; check "K2: the omission line names the shortfall" $?
153
+ grep -q "pdftotext $TMP/big.pdf -" <<<"$ERR"; check "K3: the omission line gives the command for the rest" $?
154
+
155
+ # L: pdftotext missing. A PATH holding only what the hook itself needs — and no
156
+ # pdftotext — reproduces a box that never installed poppler-utils, without
157
+ # depending on which directory pdftotext lives in on this host.
158
+ : > "$LOGFILE"
159
+ mkdir -p "$TMP/bin"
160
+ for b in python3 date cat; do ln -sf "$(command -v "$b")" "$TMP/bin/$b"; done
161
+ ERR="$(printf '%s' "$(envelope PostToolUse Read "$TMP/text.pdf")" \
162
+ | PATH="$TMP/bin" "$BASH" "$HOOK" 2>&1 1>/dev/null)"
163
+ RC=$?
164
+ [[ "$RC" -eq 0 ]]; check "L: pdftotext absent → exit 0" $?
165
+ grep -q "file=text.pdf chars=0 injected=no reason=no-poppler" "$LOGFILE"; check "L2: log line records no-poppler" $?
166
+
167
+ # M: the log carries the account. The log directory is install-wide, shared by
168
+ # every account on the box, so a line without an account cannot be counted per
169
+ # account — which is the whole point of the count.
170
+ : > "$LOGFILE"
171
+ printf '%s' "$(envelope PostToolUse Read "$TMP/text.pdf")" \
172
+ | ACCOUNT_ID=acct-under-test bash "$HOOK" >/dev/null 2>/dev/null
173
+ grep -q "account=acct-under-test" "$LOGFILE"; check "M: the log line names the account" $?
174
+
175
+ echo "──────── pdf-text-layer-inject test summary ────────"
176
+ echo "PASS=$PASS FAIL=$FAIL"
177
+ [[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # pdf-text-layer-inject — PostToolUse companion to Read on a .pdf.
3
+ #
4
+ # Read on a PDF returns rasterised page images and no text, so an agent that
5
+ # needs a value out of a PDF reads it off a picture of the page. A job sheet
6
+ # reached a field engineer with a postcode one letter wrong for exactly that
7
+ # reason, while the correct characters sat in the PDF's own text layer. This
8
+ # hook puts that text layer into the agent's context alongside the images, so
9
+ # no value is transcribed by eye when the characters exist.
10
+ #
11
+ # Exit codes: 2 = the extraction is on stderr and the model reads it (the
12
+ # PostToolUse feedback mechanism mcp-tool-missing.sh already relies on);
13
+ # 0 = nothing to inject. Fail OPEN on every uninspectable path — a hook that
14
+ # cannot read a PDF must never disturb the Read that already succeeded.
15
+ #
16
+ # One line per .pdf Read to <logDir>/pdf-text-layer.log, including the reads
17
+ # that inject nothing: a scanned PDF and a hook that never fired are different
18
+ # states and only the log tells them apart.
19
+
20
+ set -uo pipefail
21
+
22
+ if [ -t 0 ]; then exit 0; fi
23
+ INPUT=$(cat)
24
+ [ -z "$INPUT" ] && exit 0
25
+
26
+ # --- log sink ---------------------------------------------------------------
27
+ # A hook's stderr on exit 0 reaches no file on this platform. The ladder mirrors
28
+ # quote-render-pdf-conformance.sh: LOG_DIR is stamped only on the rc-spawn path,
29
+ # CLAUDE_SESSION_MANAGER_PERSIST_DIR is on the systemd unit and resolves to the
30
+ # same directory, ACCOUNT_DIR/logs is the last resort.
31
+ LOGD=""
32
+ if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR}" ]; then
33
+ LOGD="$LOG_DIR"
34
+ elif [ -n "${CLAUDE_SESSION_MANAGER_PERSIST_DIR:-}" ] && [ -d "${CLAUDE_SESSION_MANAGER_PERSIST_DIR}/logs" ]; then
35
+ LOGD="$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs"
36
+ elif [ -n "${ACCOUNT_DIR:-}" ] && [ -d "${ACCOUNT_DIR}/logs" ]; then
37
+ LOGD="$ACCOUNT_DIR/logs"
38
+ fi
39
+
40
+ INPUT="$INPUT" LOGD="$LOGD" \
41
+ NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '?')" \
42
+ CAP="${PDF_TEXT_LAYER_CAP:-100000}" \
43
+ python3 - <<'PY'
44
+ import json, os, subprocess, sys
45
+
46
+ MIN_TEXT = 20
47
+
48
+ logd = os.environ.get("LOGD", "")
49
+ now = os.environ.get("NOW_ISO", "?")
50
+ try:
51
+ cap = int(os.environ.get("CAP", "100000"))
52
+ except Exception:
53
+ cap = 100000
54
+
55
+
56
+ # The log directory is install-wide: LOG_DIR resolves to <persistDir>/logs, one
57
+ # file shared by every account on the box. Without an account field a per-account
58
+ # count is not computable, and a per-account count is the signal that says the
59
+ # settings reconcile never reached an account. ACCOUNT_ID is stamped on both the
60
+ # pty-spawner and rc-daemon spawn paths; ACCOUNT_DIR's basename is the fallback.
61
+ account = os.environ.get("ACCOUNT_ID") or ""
62
+ if not account:
63
+ acct_dir = os.environ.get("ACCOUNT_DIR") or ""
64
+ account = os.path.basename(acct_dir.rstrip("/")) if acct_dir else ""
65
+ account = account or "?"
66
+
67
+
68
+ def emit_log(base, chars, injected, reason, sid):
69
+ if not logd:
70
+ return
71
+ line = ("%s [pdf-text-layer] op=pdf-text-layer file=%s chars=%d "
72
+ "injected=%s reason=%s account=%s session=%s"
73
+ % (now, base, chars, injected, reason, account, sid))
74
+ try:
75
+ with open(os.path.join(logd, "pdf-text-layer.log"), "a") as fh:
76
+ fh.write(line + "\n")
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ try:
82
+ d = json.loads(os.environ["INPUT"])
83
+ except Exception:
84
+ sys.exit(0)
85
+
86
+ if (d.get("hook_event_name") or "") != "PostToolUse":
87
+ sys.exit(0)
88
+ if (d.get("tool_name") or "") != "Read":
89
+ sys.exit(0)
90
+ path = (d.get("tool_input") or {}).get("file_path") or ""
91
+ if not path.lower().endswith(".pdf"):
92
+ sys.exit(0)
93
+
94
+ sid = d.get("session_id") or "?"
95
+ base = os.path.basename(path)
96
+
97
+ try:
98
+ proc = subprocess.run(["pdftotext", path, "-"], capture_output=True, timeout=60)
99
+ except FileNotFoundError:
100
+ emit_log(base, 0, "no", "no-poppler", sid)
101
+ sys.exit(0)
102
+ except Exception:
103
+ emit_log(base, 0, "no", "unreadable", sid)
104
+ sys.exit(0)
105
+
106
+ if proc.returncode != 0:
107
+ emit_log(base, 0, "no", "unreadable", sid)
108
+ sys.exit(0)
109
+
110
+ text = proc.stdout.decode("utf-8", "replace")
111
+ chars = len("".join(text.split()))
112
+
113
+ if chars < MIN_TEXT:
114
+ emit_log(base, chars, "no", "scanned-no-text", sid)
115
+ sys.exit(0)
116
+
117
+ # Log before writing the payload, so a failure mid-write still leaves the record.
118
+ emit_log(base, chars, "yes", "text-layer", sid)
119
+
120
+ payload = text
121
+ omitted = 0
122
+ if len(payload) > cap:
123
+ omitted = len(payload) - cap
124
+ payload = payload[:cap]
125
+
126
+ sys.stderr.write(
127
+ "Text layer of %s, extracted from the PDF itself. Copy values from here, "
128
+ "not from the page images.\n\n%s" % (base, payload))
129
+ if omitted:
130
+ sys.stderr.write(
131
+ "\n\n[%d characters omitted. Run: pdftotext %s -]\n" % (omitted, path))
132
+ sys.stderr.write("\n")
133
+ sys.exit(2)
134
+ PY
135
+ rc=$?
136
+ [ "$rc" -eq 2 ] && exit 2
137
+ exit 0
@@ -62,10 +62,21 @@ Each glassmorphism section needs its own screenshot — you cannot reuse a print
62
62
  4. **Capture the specific element** with `browser-screenshot` passing the element's CSS selector (e.g. `.cover`, `.backpage`) — the selector clips to that element, not the full page.
63
63
  5. **Save the PNG** alongside the HTML, named for the section it replaces (e.g. `cover-print.png`, `backpage-print.png`).
64
64
  6. **Restore** hidden elements and overflow with `browser-evaluate` after capture.
65
- 7. **Verify** the saved PNG visually — confirm no scrollbars, no UI chrome, correct dimensions.
65
+ 7. **Verify** the saved PNG — confirm no scrollbars, no UI chrome, correct dimensions. Do not compose your own rasterise or cleanup shell for this or for the PDF check below. A hand-written removal whose target is a shell variable joined to a wildcard is a target Claude Code's `dangerousRemoval` check cannot resolve, and that check ignores bypass-permissions mode, so the call stalls behind a card no channel operator can answer.
66
66
 
67
67
  After capturing all print images, save the final PDF with `browser-pdf-save` to the output path.
68
68
 
69
+ ### Verifying the saved PDF
70
+
71
+ Check the produced PDF with the shipped inspector rather than a hand-composed `pdfinfo`/`pdftoppm` shell:
72
+
73
+ PDF=/abs/path/to/output.pdf \
74
+ node "$PLATFORM_ROOT/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs"
75
+
76
+ It prints the page count, the page size in points, and one absolute PNG path per page, then exits 0. Read those PNGs to confirm the rendered result. Pass `OUT=/abs/dir` to render into a stable directory (it clears only its own prior `pg-*.png` output); pass `DPI=<n>` to change the default 150. A non-zero exit means nothing was rendered.
77
+
78
+ Never write your own rasterise shell for this. Such shells converge on a cleanup step whose removal target is a scratch-dir variable joined to a wildcard, and that target is refused by a check bypass-permissions mode cannot override, so the resulting card stalls the session invisibly.
79
+
69
80
  ## Page margins, page numbers, and running footers
70
81
 
71
82
  Use `@page` margin boxes for page numbers and running footers — not `position: fixed` elements.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pdf-inspect.mjs — the A4 print-document PDF check.
4
+ *
5
+ * Prints a PDF's page count and page size, rasterises every page to PNG, and
6
+ * prints the absolute PNG paths. No shell, ever.
7
+ *
8
+ * This ships with the platform rather than being written out by the agent at
9
+ * dispatch time, because the agent-authored version was the defect. Each
10
+ * specialist composed its own rasterise shell, and the shape it converged on
11
+ * was a `cd`, a scratch-dir variable, a forced removal of that variable joined
12
+ * to a PNG wildcard, then pdfinfo and pdftoppm.
13
+ * The removal target there is a shell variable plus a glob, which Claude Code's
14
+ * static analyser cannot resolve; its `dangerousRemoval` check is
15
+ * `bypassImmune: true`, so no permission rule and no permission mode
16
+ * pre-approves it. The card it raises inside a background subagent has no
17
+ * prompt surface a channel operator can reach, and the session stalls
18
+ * (Task 2476). Precedent: e-sign/stamp.mjs under Task 1921.
19
+ *
20
+ * Every external command runs through execFileSync with an argv array, so no
21
+ * shell parses any argument: no glob expansion, no variable expansion, no word
22
+ * splitting anywhere in this path.
23
+ *
24
+ * Invocation (the skill's PDF verify step):
25
+ *
26
+ * PDF=<abs path to pdf> [OUT=<abs dir>] [DPI=<n>] \
27
+ * node "$PLATFORM_ROOT/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs"
28
+ *
29
+ * With no OUT the output directory is created by mkdtempSync and is fresh per
30
+ * run. With OUT the directory is reused, and this script's own prior output is
31
+ * removed by unlinkSync on absolute paths built from readdirSync — never `rm`,
32
+ * never a glob.
33
+ *
34
+ * Exit 0 prints one record line then the absolute PNG paths, one per line:
35
+ *
36
+ * [pdf-inspect] pages=<n> pageSize=<w>x<h>pts dpi=<n> outDir=<abs> pngs=<n>
37
+ *
38
+ * Any non-zero exit means nothing was rendered.
39
+ */
40
+
41
+ import { execFileSync } from 'node:child_process'
42
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, statSync, unlinkSync } from 'node:fs'
43
+ import { tmpdir } from 'node:os'
44
+ import { join, isAbsolute } from 'node:path'
45
+
46
+ const TAG = '[pdf-inspect]'
47
+ /** Every PNG this script writes starts here. The cleanup below removes only
48
+ * files matching this prefix, so a caller-supplied OUT holding other work is
49
+ * never touched. */
50
+ const PREFIX = 'pg'
51
+
52
+ function die(message) {
53
+ process.stderr.write(`${TAG} error=${message}\n`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const pdf = process.env.PDF
58
+ if (!pdf) die('PDF is required (absolute path to the pdf)')
59
+ if (!isAbsolute(pdf)) die(`PDF must be an absolute path, got ${JSON.stringify(pdf)}`)
60
+ if (!existsSync(pdf)) die(`no such file: ${pdf}`)
61
+
62
+ const dpi = process.env.DPI ? Number(process.env.DPI) : 150
63
+ if (!Number.isFinite(dpi) || dpi <= 0) die(`DPI must be a positive number, got ${JSON.stringify(process.env.DPI)}`)
64
+
65
+ let outDir = process.env.OUT
66
+ if (outDir) {
67
+ if (!isAbsolute(outDir)) die(`OUT must be an absolute path, got ${JSON.stringify(outDir)}`)
68
+ mkdirSync(outDir, { recursive: true })
69
+ // Reused directory: remove THIS script's prior output by absolute path.
70
+ // readdirSync gives literal names, so nothing here is ever a pattern.
71
+ for (const name of readdirSync(outDir)) {
72
+ if (name.startsWith(`${PREFIX}-`) && name.endsWith('.png')) {
73
+ unlinkSync(join(outDir, name))
74
+ }
75
+ }
76
+ } else {
77
+ // Fresh per run, so there is no prior output to remove.
78
+ outDir = mkdtempSync(join(tmpdir(), 'pdf-inspect-'))
79
+ }
80
+
81
+ function run(bin, args) {
82
+ try {
83
+ return execFileSync(bin, args, { encoding: 'utf8' })
84
+ } catch (err) {
85
+ if (err && err.code === 'ENOENT') die(`${bin} not found — install poppler-utils`)
86
+ die(`${bin} failed: ${err instanceof Error ? err.message : String(err)}`)
87
+ }
88
+ }
89
+
90
+ const info = run('pdfinfo', [pdf])
91
+ const pagesMatch = info.match(/^Pages:\s+(\d+)$/m)
92
+ const sizeMatch = info.match(/^Page size:\s+([\d.]+) x ([\d.]+) pts/m)
93
+ if (!pagesMatch) die('pdfinfo reported no page count')
94
+ const pages = Number(pagesMatch[1])
95
+ if (pages < 1) die(`pdf has ${pages} pages`)
96
+ const pageSize = sizeMatch ? `${sizeMatch[1]}x${sizeMatch[2]}` : 'unknown'
97
+
98
+ run('pdftoppm', ['-r', String(dpi), '-png', pdf, join(outDir, PREFIX)])
99
+
100
+ const pngs = readdirSync(outDir)
101
+ .filter((n) => n.startsWith(`${PREFIX}-`) && n.endsWith('.png'))
102
+ .sort()
103
+ .map((n) => join(outDir, n))
104
+
105
+ if (pngs.length === 0) die('pdftoppm wrote no PNGs')
106
+ for (const p of pngs) {
107
+ if (statSync(p).size === 0) die(`empty PNG: ${p}`)
108
+ }
109
+
110
+ process.stdout.write(
111
+ `${TAG} pages=${pages} pageSize=${pageSize}pts dpi=${dpi} outDir=${outDir} pngs=${pngs.length}\n`,
112
+ )
113
+ for (const p of pngs) process.stdout.write(`${p}\n`)
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-06 (0.1.570)
13
+
14
+ - Reading a PDF now injects the text layer, so values copied from a PDF are not transcribed by eye any more.
15
+ - The four one-hook account-settings backfill libs have been retired; hook registration is now driven from the account-settings template.
16
+ - The operator is now notified when a tool call stays blocked, with a per-account census, and the notification is scoped to the blocked session's own account.
17
+
12
18
  ## 2026-08-06 (0.1.569)
13
19
 
14
20
  - Every account's safeguards are now brought up to date on each install. They used to be written once when the account was created and never revisited, so an account could be missing a protection that every other account had, with nothing reporting the gap.
@@ -182,6 +182,21 @@ grep -E "\[(pty-spawn-tool-inventory|recorder-session-init|recorder-turn|mcp:(me
182
182
 
183
183
  **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.
184
184
 
185
+ ## A session that stopped with the PTY alive
186
+
187
+ Claude Code raises a permission card for some checks even under `permissionMode=bypassPermissions`: `dangerousRemoval` is `bypassImmune: true` in its safety table, so no allow rule and no permission mode pre-approves it. When such a card is raised inside a background subagent, its only prompt surface is the parent session's UI, so an operator on WhatsApp or Telegram cannot see or answer it and the session simply stops.
188
+
189
+ The manager's blocked-tool census reconciles this against live state every 60s. It reads a bounded tail of each live session's own transcript and of every `<sessionId>/subagents/agent-*.jsonl`, and treats an assistant `tool_use` with no matching `tool_result` as blocked once that transcript has not advanced for 120s. Three line shapes, all under `[blocked-tool]`:
190
+
191
+ - `op=census sessions=<n> scanned=<n> blocked=<n>` — every tick, including zero. A gap in this line means the census stopped, not that nothing was blocked. `sessions` counts live PTYs and `scanned` counts transcripts actually read, so `sessions=3 scanned=0` means no transcript could be resolved for any live session, which is a fault rather than a quiet box.
192
+ - `op=blocked sessionId=<id8> agentId=<id> toolUseId=<id> tool=<name> blockedForMs=<n>` — once per blocked call. `agentId=—` means the card is in the session's own transcript, where the operator can see it in the session UI; `agent-<id>` means it is inside that background subagent, where they cannot. `blockedForMs` is the age of the transcript's last record, not a timer on the call.
193
+ - `op=cleared` with the same ids, once the result lands on a still-live session.
194
+ - `op=session-gone` with the same ids when the session stopped being live with the card still unanswered. This is deliberately not `op=cleared`: the call never resolved, and conflating the two would read as "the block lifted".
195
+
196
+ At 180s blocked the manager sends one notification through the loopback route `/api/admin/session-blocked`, which is gated the same way as `log-ingest`. `op=blocked-notify channel=<name> delivered=<bool>` records where it went and whether it landed. `channel=whatsapp` or `telegram` means the session's own admin door; `channel=house-admin` means the house admin phones, which is what a public session always gets — a public session's own channel is a customer and never receives internal machinery. `delivered=false` with `channel=none` means the manager could not reach the route at all.
197
+
198
+ The notification cannot answer the card. Answer it in the session's PTY or in Claude Desktop.
199
+
185
200
  ## Out of scope
186
201
 
187
202
  - Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.