@rubytech/create-maxy-code 0.1.493 → 0.1.495

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.493",
3
+ "version": "0.1.495",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -0,0 +1,422 @@
1
+ # Task 1930 — Schema-and-preference adherence enforcement — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Hard-block customer-facing document deliverables that go out without the account's layer-2 preferences being consulted that turn, and make placement drift (`output/` scratch domination) a visible signal.
6
+
7
+ **Architecture:** Two new admin hooks (a PreToolUse gate on document-deliverable sends, a UserPromptSubmit reminder), plus spec edits to `SCHEMA.md` and the `data-manager` reconcile brief, plus registration in the account-provisioning settings writer. The periodic scheduling of the reconcile audit is deferred to a filed Task 1931.
8
+
9
+ **Tech Stack:** Bash + python3 hooks (Claude Code hook protocol), markdown specs. No TypeScript, no npm build.
10
+
11
+ ## Global Constraints
12
+
13
+ - All work under `maxy-code/` (this is a `getmaxy` worktree; the subtree root is `maxy-code/`). Run commands from `maxy-code/`.
14
+ - Hook contract: exit 0 allow, exit 2 block (PreToolUse) / exit 0 always (UserPromptSubmit). Fail OPEN on tty, empty stdin, missing `python3`, or missing/unreadable transcript. A broken hook that blocks everything is worse than the miss.
15
+ - No task numbers or internal refs in any operator-visible (stderr block / injected) string.
16
+ - British English, plain hyphens, no em-dashes in any shipped markdown or block message.
17
+ - New hook tests join the existing tree suite at `platform/plugins/admin/hooks/__tests__/` (they are committed, not ephemeral — the project's hook tests live there).
18
+ - Every commit carries `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` and a `Session:` trailer.
19
+
20
+ ---
21
+
22
+ ### Task 1: Preference-consult gate hook
23
+
24
+ **Files:**
25
+ - Create: `platform/plugins/admin/hooks/preference-consult-gate.sh`
26
+ - Test: `platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh`
27
+
28
+ **Interfaces:**
29
+ - Consumes: the Claude Code PreToolUse JSON envelope on stdin (`{tool_name, tool_input, transcript_path, ...}`).
30
+ - Produces: exit 0 (allow) / exit 2 (block). Stderr `[preference-gate] op=allow|bypass tool=<name>`.
31
+
32
+ - [ ] **Step 1: Write the failing test**
33
+
34
+ ```bash
35
+ # platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh
36
+ #!/usr/bin/env bash
37
+ set -uo pipefail
38
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
39
+ HOOK="$DIR/../preference-consult-gate.sh"
40
+ PASS=0; FAIL=0; FAILED=()
41
+ run() { printf '%s' "$1" | bash "$HOOK" >/dev/null 2>&1; echo $?; }
42
+ assert_exit() { local got; got=$(run "$2"); if [ "$got" = "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: want exit $1 got $got"); fi; }
43
+
44
+ TMP="$(mktemp -d)"
45
+ # Transcript WITH a profile-read after the last user message.
46
+ CONSULTED="$TMP/consulted.jsonl"
47
+ {
48
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":"make the quote"}}'
49
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
50
+ } > "$CONSULTED"
51
+ # Transcript with NO profile-read this turn (one appears BEFORE the last user msg only).
52
+ UNCONSULTED="$TMP/unconsulted.jsonl"
53
+ {
54
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
55
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":"send it"}}'
56
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}'
57
+ } > "$UNCONSULTED"
58
+
59
+ pdf_env() { printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"memory/users/+447700900000/documents/quote.pdf"},"transcript_path":"%s"}' "$1"; }
60
+ email_att_env() { printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{"attachments":["memory/users/+44/documents/x.pdf"]},"transcript_path":"%s"}' "$1"; }
61
+
62
+ # Gated tool + consulted → allow.
63
+ assert_exit 0 "$(pdf_env "$CONSULTED")" "pdf consulted allows"
64
+ # Gated tool + not consulted → block.
65
+ assert_exit 2 "$(pdf_env "$UNCONSULTED")" "pdf unconsulted blocks"
66
+ # Email with attachment + not consulted → block.
67
+ assert_exit 2 "$(email_att_env "$UNCONSULTED")" "email+attach unconsulted blocks"
68
+ # Email with NO attachment → not a document deliverable → allow.
69
+ assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{},"transcript_path":"%s"}' "$UNCONSULTED")" "email no-attach allows"
70
+ # browser-pdf-save OUTSIDE customer documents scope → allow.
71
+ assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"output/report.pdf"},"transcript_path":"%s"}' "$UNCONSULTED")" "pdf outside scope allows"
72
+ # Non-send tool → allow.
73
+ assert_exit 0 "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"},"transcript_path":"%s"}' "$UNCONSULTED")" "read allows"
74
+ # Empty stdin → fail open (allow).
75
+ assert_exit 0 "" "empty stdin fails open"
76
+
77
+ echo "----- $PASS passed, $FAIL failed -----"
78
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
79
+ [ "$FAIL" -eq 0 ]
80
+ ```
81
+
82
+ - [ ] **Step 2: Run it, verify it fails** — `bash platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh` — Expected: FAIL (hook does not exist).
83
+
84
+ - [ ] **Step 3: Write the hook**
85
+
86
+ ```bash
87
+ #!/usr/bin/env bash
88
+ # preference-consult-gate — PreToolUse gate on customer-facing document deliverables.
89
+ #
90
+ # A customer document (a PDF/file to the customer documents scope, or an email/
91
+ # Outlook send carrying an attachment) must not go out until the account's saved
92
+ # layer-2 preferences were consulted this turn, proven by a profile-read tool
93
+ # call after the last user message in the session transcript. Layer-2 records
94
+ # hold the signature policy, header, naming and styling rules the fixed layer-1
95
+ # block does not; a send with no profile-read this turn is the miss that dropped
96
+ # a customer signature.
97
+ #
98
+ # Scope (document-deliverable shape only; every other tool/shape exits 0):
99
+ # *browser-pdf-save path under memory/users/<phone>/documents/
100
+ # SendUserFile path under memory/users/<phone>/documents/ or .pdf/.html/.docx
101
+ # *email-send|reply|draft-send / *outlook-mail-send|reply|draft-send non-empty attachments
102
+ #
103
+ # Exit: 0 allow, 2 block. Fail OPEN on tty/empty stdin/no python3/transcript
104
+ # unreadable. Block log: [preference-gate] op=bypass tool=<name> detail=no profile-read this turn
105
+ set -uo pipefail
106
+
107
+ if [ -t 0 ]; then exit 0; fi
108
+ INPUT=$(cat)
109
+ [ -z "$INPUT" ] && exit 0
110
+ command -v python3 >/dev/null 2>&1 || exit 0
111
+
112
+ DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
113
+ import os, json, re, sys
114
+ try:
115
+ d = json.loads(os.environ["INPUT"])
116
+ except Exception:
117
+ sys.exit(0)
118
+ tool = d.get("tool_name", "") or ""
119
+ ti = d.get("tool_input", {}) or {}
120
+
121
+ def under_customer_docs(p):
122
+ return bool(re.search(r'(^|/)memory/users/[^/]+/documents/', os.path.normpath(p or "")))
123
+
124
+ gated = False
125
+ if tool.endswith("browser-pdf-save"):
126
+ gated = under_customer_docs(ti.get("path", ""))
127
+ elif tool == "SendUserFile" or tool.endswith("__SendUserFile"):
128
+ p = ti.get("path", "") or ti.get("file_path", "")
129
+ gated = under_customer_docs(p) or p.lower().endswith((".pdf", ".html", ".docx"))
130
+ elif re.search(r'(email-send|email-reply|email-draft-send|outlook-mail-send|outlook-mail-reply|outlook-draft-send)$', tool):
131
+ atts = ti.get("attachments")
132
+ gated = isinstance(atts, list) and len(atts) > 0
133
+
134
+ if not gated:
135
+ print("") # not a document deliverable
136
+ sys.exit(0)
137
+
138
+ transcript = d.get("transcript_path", "") or ""
139
+ if not transcript or not os.access(transcript, os.R_OK):
140
+ print("") # cannot inspect consultation → fail open
141
+ sys.exit(0)
142
+
143
+ try:
144
+ with open(transcript) as f:
145
+ lines = f.readlines()
146
+ except Exception:
147
+ print("")
148
+ sys.exit(0)
149
+
150
+ last_user = -1
151
+ for i, ln in enumerate(lines):
152
+ try:
153
+ e = json.loads(ln)
154
+ except Exception:
155
+ continue
156
+ msg = e.get("message", e)
157
+ role = e.get("role") or (msg.get("role") if isinstance(msg, dict) else None)
158
+ if role == "user" or e.get("type") == "user":
159
+ last_user = i
160
+
161
+ consulted = False
162
+ for ln in (lines[last_user + 1:] if last_user >= 0 else lines):
163
+ try:
164
+ e = json.loads(ln)
165
+ except Exception:
166
+ continue
167
+ msg = e.get("message", e)
168
+ content = msg.get("content") if isinstance(msg, dict) else None
169
+ if isinstance(content, list):
170
+ for block in content:
171
+ if isinstance(block, dict) and block.get("type") == "tool_use" and (block.get("name", "") or "").endswith("profile-read"):
172
+ consulted = True
173
+
174
+ print("%s|%s" % (tool, "yes" if consulted else "no"))
175
+ PY
176
+ )
177
+
178
+ [ -z "$DECISION" ] && exit 0
179
+ TOOL="${DECISION%%|*}"
180
+ CONSULTED="${DECISION##*|}"
181
+
182
+ if [ "$CONSULTED" = "yes" ]; then
183
+ echo "[preference-gate] op=allow tool=$TOOL consulted=true" >&2
184
+ exit 0
185
+ fi
186
+
187
+ echo "[preference-gate] op=bypass tool=$TOOL detail=no profile-read this turn" >&2
188
+ echo "Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again." >&2
189
+ exit 2
190
+ ```
191
+
192
+ - [ ] **Step 4: Make it executable and run the test** — `chmod +x platform/plugins/admin/hooks/preference-consult-gate.sh && bash platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh` — Expected: `7 passed, 0 failed`.
193
+
194
+ - [ ] **Step 5: Commit**
195
+
196
+ ```bash
197
+ git add platform/plugins/admin/hooks/preference-consult-gate.sh platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh
198
+ git commit -m "feat: preference-consult gate blocks customer documents sent without a profile-read this turn"
199
+ ```
200
+
201
+ ---
202
+
203
+ ### Task 2: Preference-consult wrapper injector
204
+
205
+ **Files:**
206
+ - Create: `platform/plugins/admin/hooks/preference-consult-directive.sh`
207
+ - Test: `platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh`
208
+
209
+ **Interfaces:**
210
+ - Consumes: UserPromptSubmit envelope on stdin (ignored; block is prompt-independent).
211
+ - Produces: stdout JSON `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"<preference-adherence>...</preference-adherence>"}}`; always exit 0.
212
+
213
+ - [ ] **Step 1: Write the failing test**
214
+
215
+ ```bash
216
+ #!/usr/bin/env bash
217
+ set -uo pipefail
218
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
219
+ HOOK="$DIR/../preference-consult-directive.sh"
220
+ PASS=0; FAIL=0; FAILED=()
221
+ OUT=$(printf '{"session_id":"x","prompt":"hi"}' | bash "$HOOK" 2>/dev/null); RC=$?
222
+ check() { if eval "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
223
+ check '[ "$RC" = "0" ]' "exit 0"
224
+ check 'printf "%s" "$OUT" | grep -q "hookSpecificOutput"' "emits envelope"
225
+ check 'printf "%s" "$OUT" | grep -q "profile-read"' "names profile-read"
226
+ check 'printf "%s" "$OUT" | grep -q "output/"' "names output scratch rule"
227
+ check 'printf "%s" "$OUT" | grep -qv "—"' "no em-dash"
228
+ echo "----- $PASS passed, $FAIL failed -----"
229
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
230
+ [ "$FAIL" -eq 0 ]
231
+ ```
232
+
233
+ - [ ] **Step 2: Run it, verify it fails** — Expected: FAIL (hook absent).
234
+
235
+ - [ ] **Step 3: Write the hook**
236
+
237
+ ```bash
238
+ #!/usr/bin/env bash
239
+ # preference-consult-directive — UserPromptSubmit hook. Injects a standing line
240
+ # naming the two-layer preference architecture and two adherence directives:
241
+ # consult layer-2 preferences (profile-read) before any customer document, and
242
+ # promote finished deliverables out of output/. Prompt-independent; fail-open.
243
+ set -uo pipefail
244
+ HOOK_INPUT=$(cat 2>/dev/null || true)
245
+ command -v python3 >/dev/null 2>&1 || exit 0
246
+ python3 - <<'PY' 2>/dev/null || exit 0
247
+ import json
248
+ ctx = (
249
+ "<preference-adherence>\n"
250
+ "Your preferences live in two layers. Layer 1 is the fixed block already in front of you every turn. "
251
+ "Layer 2 is the account's own saved records, read with profile-read; it holds the signature policy, header, "
252
+ "naming conventions and styling that layer 1 does not, and it grows over time. Before any customer-facing "
253
+ "document deliverable (a PDF or file sent to the customer, or an email or Outlook message carrying an "
254
+ "attachment), read the relevant layer-2 preferences with profile-read and apply them. "
255
+ "Store finished deliverables under documents/ or the project folder. output/ is scratch of last resort, "
256
+ "rebuilt on the next tool write, and never the home a graph reference points at.\n"
257
+ "</preference-adherence>"
258
+ )
259
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ctx}}))
260
+ PY
261
+ echo "[pref-wrapper] op=inject" >&2
262
+ exit 0
263
+ ```
264
+
265
+ - [ ] **Step 4: Run the test** — `bash platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh` — Expected: `5 passed, 0 failed`.
266
+
267
+ - [ ] **Step 5: Commit**
268
+
269
+ ```bash
270
+ git add platform/plugins/admin/hooks/preference-consult-directive.sh platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh
271
+ git commit -m "feat: preference-consult UserPromptSubmit directive names the two-layer architecture and output-scratch rule"
272
+ ```
273
+
274
+ ---
275
+
276
+ ### Task 3: Register both hooks in account provisioning
277
+
278
+ **Files:**
279
+ - Modify: `platform/scripts/lib/provision-account-dir.sh` (the `SETTINGS_EOF` hooks block, around lines 63-178)
280
+
281
+ **Interfaces:**
282
+ - Consumes: the hook scripts from Tasks 1-2 at `$HOOKS_PATH`.
283
+ - Produces: a `.claude/settings.json` with PreToolUse matchers for the document-send tools routed to `preference-consult-gate.sh`, and `preference-consult-directive.sh` in the UserPromptSubmit list.
284
+
285
+ - [ ] **Step 1: Write the failing test** (asserts the rendered settings block references both hooks)
286
+
287
+ ```bash
288
+ #!/usr/bin/env bash
289
+ set -uo pipefail
290
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
291
+ LIB="$DIR/../lib/provision-account-dir.sh"
292
+ PASS=0; FAIL=0; FAILED=()
293
+ c(){ if grep -qF "$1" "$LIB"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
294
+ c 'preference-consult-gate.sh' "gate registered"
295
+ c 'preference-consult-directive.sh' "directive registered"
296
+ c 'mcp__plugin_email_email__email-send' "email matcher present"
297
+ c 'mcp__plugin_outlook_outlook__outlook-mail-send' "outlook matcher present"
298
+ echo "----- $PASS passed, $FAIL failed -----"
299
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
300
+ [ "$FAIL" -eq 0 ]
301
+ ```
302
+
303
+ Save as `platform/scripts/__tests__/preference-hooks-registered.test.sh`.
304
+
305
+ - [ ] **Step 2: Run it, verify it fails** — Expected: FAIL (strings absent).
306
+
307
+ - [ ] **Step 3: Edit the settings block.** In the `"PreToolUse"` array, after the `browser-pdf-save` → `quote-render-gate.sh` entry, add matcher entries (one per document-send tool) routed to `preference-consult-gate.sh`, plus a second `browser-pdf-save` hook entry so both gates run on it. Exact JSON to insert inside `"PreToolUse": [ ... ]`:
308
+
309
+ ```json
310
+ {
311
+ "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
312
+ "hooks": [
313
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
314
+ ]
315
+ },
316
+ {
317
+ "matcher": "mcp__plugin_email_email__email-send|mcp__plugin_email_email__email-reply|mcp__plugin_email_email__email-draft-send|mcp__plugin_outlook_outlook__outlook-mail-send|mcp__plugin_outlook_outlook__outlook-mail-reply|mcp__plugin_outlook_outlook__outlook-draft-send|SendUserFile",
318
+ "hooks": [
319
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
320
+ ]
321
+ }
322
+ ```
323
+
324
+ In the `"UserPromptSubmit"` array's single `hooks` list, append after `datetime-inject.sh`:
325
+
326
+ ```json
327
+ ,{ "type": "command", "command": "bash $HOOKS_PATH/preference-consult-directive.sh" }
328
+ ```
329
+
330
+ - [ ] **Step 4: Run the test + JSON validity check.**
331
+
332
+ Run: `bash platform/scripts/__tests__/preference-hooks-registered.test.sh` — Expected: `4 passed, 0 failed`.
333
+ Then confirm the emitted settings JSON parses. Extract the heredoc body with the placeholders stubbed and pipe through `python3 -m json.tool`:
334
+
335
+ ```bash
336
+ HOOKS_PATH=/x PLATFORM_ROOT=/x ACCOUNT_DIR=/x \
337
+ python3 - <<'PY'
338
+ import re, os, json
339
+ s = open("platform/scripts/lib/provision-account-dir.sh").read()
340
+ body = s.split("SETTINGS_EOF", 2)[1] if "SETTINGS_EOF" in s else ""
341
+ body = re.sub(r'\$\{?[A-Z_]+\}?', '/x', body)
342
+ json.loads(body); print("settings JSON valid")
343
+ PY
344
+ ```
345
+ Expected: `settings JSON valid`.
346
+
347
+ - [ ] **Step 5: Commit**
348
+
349
+ ```bash
350
+ git add platform/scripts/lib/provision-account-dir.sh platform/scripts/__tests__/preference-hooks-registered.test.sh
351
+ git commit -m "feat: register preference-consult gate and directive in account provisioning settings"
352
+ ```
353
+
354
+ ---
355
+
356
+ ### Task 4: Spec edits — SCHEMA.md and data-manager reconcile brief
357
+
358
+ **Files:**
359
+ - Modify: `platform/templates/account-schema/SCHEMA.md` (tool-owned section, lines 29-42)
360
+ - Modify: `platform/templates/specialists/agents/data-manager.md` (reconcile audit brief + output contract)
361
+
362
+ **Interfaces:** documentation only; consumed by the agent and the `data-manager` specialist at runtime.
363
+
364
+ - [ ] **Step 1: Edit `SCHEMA.md`.** In the tool-owned paragraph, after the sentence listing `output/` as recreated on the next write, add:
365
+
366
+ > Finished deliverables must be promoted out of `output/` into `documents/` or `projects/<name>/` through the `data-manager` specialist; `output/` is scratch of last resort, and a graph reference must never point into it (the next tool write rebuilds the tree and the reference dangles).
367
+
368
+ - [ ] **Step 2: Edit `data-manager.md`.** In "The reconcile audit brief" paragraph, extend the counted set: after "(b) graph file-references that do not resolve to a real path", add "(c) graph file-references that resolve into a tool-owned scratch dir (`output/`, `generated/`, `extracted/`, `url-get/`), which the next tool write rebuilds so the reference is fragile by construction, and (d) deliverables a node references that resolve only under a scratch dir and were never promoted to `documents/` or `projects/`." In the "Output contract" paragraph, change the reconcile line to: `unreachable=N broken-refs=M scratch-refs=P stranded=Q`.
369
+
370
+ - [ ] **Step 3: Verify the edits landed**
371
+
372
+ ```bash
373
+ grep -q "scratch of last resort" platform/templates/account-schema/SCHEMA.md && \
374
+ grep -q "scratch-refs=P stranded=Q" platform/templates/specialists/agents/data-manager.md && echo "spec edits present"
375
+ ```
376
+ Expected: `spec edits present`.
377
+
378
+ - [ ] **Step 4: Commit**
379
+
380
+ ```bash
381
+ git add platform/templates/account-schema/SCHEMA.md platform/templates/specialists/agents/data-manager.md
382
+ git commit -m "docs: SCHEMA promote-out-of-output rule and data-manager reconcile counts scratch-refs and stranded deliverables"
383
+ ```
384
+
385
+ ---
386
+
387
+ ### Task 5: Plugin and reference docs + file deferred Task 1931
388
+
389
+ **Files:**
390
+ - Modify: `platform/plugins/admin/PLUGIN.md` (hooks section — add both hooks)
391
+ - Modify: `.docs/` (the relevant enforcement/hooks doc) and `platform/plugins/docs/references/` (the shipped guide) as the sprint doc-gate requires
392
+ - Create: `.tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md`
393
+ - Modify: `.tasks/LANES.md` (Ready row for 1931)
394
+
395
+ - [ ] **Step 1: PLUGIN.md.** In the hooks list (near the `quote-render-gate` entry ~line 190), add an entry for `preference-consult-gate.sh` (PreToolUse matcher set = the document-send tools; block condition = document deliverable with no `profile-read` after the last user message; block message and `[preference-gate] op=bypass` log line; fail-open cases) and for `preference-consult-directive.sh` (UserPromptSubmit; injects the two-layer directive; `[pref-wrapper] op=inject`).
396
+
397
+ - [ ] **Step 2: `.docs/` + references.** Add the two hooks to whichever `.docs/` file documents the admin hook surface and to the shipped `platform/plugins/docs/references/` guide that lists enforcement behaviour. Locate them by grep: `grep -rl "fs-schema-guard\|quote-render-gate" .docs platform/plugins/docs/references`.
398
+
399
+ - [ ] **Step 3: File Task 1931.** Create `.tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md`: problem (the extended reconcile counts scratch-refs and stranded deliverables but only runs on ad-hoc `data-manager` dispatch, so the no-event placement drift is not caught on a standing basis); success (the audit runs on a periodic cadence per account and emits `[reconcile] op=run unreachable=… broken-refs=… scratch-refs=… stranded=…`, with absence-of-run itself a detectable failure); scope in (a scheduler mechanism for the read-only audit, deterministic where possible); scope out (the counts themselves — landed in 1930; auto-promotion). Add its Ready row to `.tasks/LANES.md`.
400
+
401
+ - [ ] **Step 4: Verify** — `test -f .tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md && grep -q "1931" .tasks/LANES.md && grep -q "preference-consult-gate" platform/plugins/admin/PLUGIN.md && echo "docs + task filed"` — Expected: `docs + task filed`.
402
+
403
+ - [ ] **Step 5: Commit**
404
+
405
+ ```bash
406
+ git add platform/plugins/admin/PLUGIN.md .docs platform/plugins/docs/references .tasks/backlog/1931-make-reconcile-audit-run-on-a-standing-periodic-cadence.md .tasks/LANES.md
407
+ git commit -m "docs: document preference-consult hooks; file Task 1931 for standing reconcile cadence"
408
+ ```
409
+
410
+ ---
411
+
412
+ ## Verification (Phase 4)
413
+
414
+ - All hook tests pass: `for t in platform/plugins/admin/hooks/__tests__/*.test.sh platform/scripts/__tests__/preference-hooks-registered.test.sh; do bash "$t" | tail -1; done`. The 22 `fs-schema-guard` tests and `quote-render-gate` tests stay green.
415
+ - Settings JSON parses (Task 3 Step 4).
416
+ - **Pi signal verification (mandatory — this sprint touches hook input handling).** After deploy, trigger a customer-document send in admin chat without a `profile-read` that turn and confirm the block; read the transcript JSONL structure on the Pi and confirm the `role:user` boundary and `tool_use` `name` fields match what the test pipes. If the live envelope's `tool_input` field names diverge (e.g. `attachments` vs another key, or the transcript entry shape), fix the hook and the test before proceeding.
417
+
418
+ ## Self-Review
419
+
420
+ - **Spec coverage:** gate (Task 1), wrapper (Task 2), registration (Task 3), SCHEMA + data-manager (Task 4), PLUGIN/docs + deferred 1931 (Task 5). All five spec units mapped.
421
+ - **Placeholder scan:** none; every hook and test carries full code.
422
+ - **Type consistency:** the gate emits `op=allow`/`op=bypass` and the wrapper `op=inject` consistently across plan, spec, and PLUGIN entry. The reconcile contract `unreachable=N broken-refs=M scratch-refs=P stranded=Q` is identical in Task 4 and the spec.
@@ -0,0 +1,81 @@
1
+ # Task 1930 — Schema-and-preference adherence enforcement (design)
2
+
3
+ Date: 2026-07-23
4
+ Task: `.tasks/backlog/1930-enforce-schema-and-preference-adherence-scratch-deliverables-and-unconsulted-sends.md`
5
+
6
+ ## Problem
7
+
8
+ Two adherence failures share one root: a rule the agent follows by judgement, with no gate forcing it.
9
+
10
+ - **Preferences.** Layer-2 `Preference` nodes (`platform/plugins/memory/mcp/src/tools/profile-read.ts`) are consulted by per-turn judgement. A customer signature preference was missed on a delivered document because nothing forced a `profile-read` before the send.
11
+ - **Placement.** `output/` is tool-owned scratch rebuilt each write (`platform/templates/account-schema/SCHEMA.md:29-42`); finished deliverables strand there and graph refs point at those scratch paths. The `data-manager` reconcile audit skips tool-owned dirs, so both are invisible to it.
12
+
13
+ ## Decisions (operator-confirmed)
14
+
15
+ 1. The preference gate fires only on **customer-facing document deliverables**, not every send. Casual channel replies and plain-text emails pass.
16
+ 2. This sprint ships the gate, the wrapper reminder, and the spec changes. The **periodic scheduling** of the reconcile audit is deferred to Task 1931.
17
+ 3. The gate hard-blocks (exit 2), matching the user's instruction.
18
+
19
+ ## Architecture
20
+
21
+ Five units, each single-purpose and independently testable.
22
+
23
+ ### 1. `platform/plugins/admin/hooks/preference-consult-gate.sh` (new, PreToolUse)
24
+
25
+ Contract mirrors `quote-render-gate.sh`: read the JSON envelope on stdin, exit 0 allow / exit 2 block, fail-open on any uninspectable call (tty, empty stdin, missing `python3`, missing/unreadable transcript).
26
+
27
+ **Fires only on a customer-facing document deliverable**, decided from `tool_name` + `tool_input`:
28
+ - `mcp__plugin_browser_browser__browser-pdf-save` whose output path is under `memory/users/<phone>/documents/`.
29
+ - `SendUserFile` whose file path is under `memory/users/<phone>/documents/` or carries a document extension (`.pdf`, `.html`, `.docx`).
30
+ - `mcp__plugin_email_email__email-send` / `email-reply` / `email-draft-send` with a non-empty `attachments`.
31
+ - `mcp__plugin_outlook_outlook__outlook-mail-send` / `outlook-mail-reply` / `outlook-draft-send` with a non-empty `attachments`.
32
+
33
+ Any tool or shape not matching the above exits 0 (not a document deliverable).
34
+
35
+ **Consultation check.** Read the transcript at `transcript_path`. Scan the messages from the last `role:user` entry to the end for an assistant `tool_use` whose name ends in `profile-read`. Found → exit 0. Absent → exit 2.
36
+
37
+ **Block message** (operator-visible, no task numbers): names that a customer document is about to go out without the account's saved preferences being checked this turn, and directs the agent to run `profile-read` for this account and retry the send. A false block (preferences read in an earlier turn) is a one-step recovery, not a dead end.
38
+
39
+ **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block; `op=allow tool=<name> consulted=true` on the allow path for a document deliverable. `browser-pdf-save` already carries `quote-render-gate` on the same matcher; both hooks run and are independent (one on receipt, one on consultation).
40
+
41
+ ### 2. `platform/plugins/admin/hooks/preference-consult-directive.sh` (new, UserPromptSubmit)
42
+
43
+ Mirrors `datetime-inject.sh`: drain stdin first, fail-open exit 0 on every path, emit `hookSpecificOutput.additionalContext` via python3. The block is standing and prompt-independent. Content: the two-layer preference architecture (layer 1 fixed/injected, layer 2 the account's `Preference` records read via `profile-read`), and two directives: consult the relevant layer-2 preferences before any customer-facing document deliverable, and promote finished deliverables out of `output/` into `documents/` or `projects/`. Breadcrumb line: `[pref-wrapper] op=inject`.
44
+
45
+ ### 3. `platform/templates/account-schema/SCHEMA.md`
46
+
47
+ In the tool-owned section (`:29-42`), add one sentence: finished deliverables must be promoted out of `output/` into `documents/` or `projects/`; `output/` is scratch of last resort, rebuilt on the next write, and a graph reference must never point into it.
48
+
49
+ ### 4. `platform/templates/specialists/agents/data-manager.md`
50
+
51
+ Extend "The reconcile audit brief": in addition to (a) files unreachable from any node and (b) refs that resolve to no real path, count (c) graph refs resolving into a scratch dir (`output/`, `generated/`, `extracted/`, `url-get/`) and (d) deliverables referenced by a node but resolving only under scratch. Output contract becomes `unreachable=N broken-refs=M scratch-refs=P stranded=Q`. The audit stays read-only; it moves nothing.
52
+
53
+ ### 5. Registration + docs
54
+
55
+ - `platform/scripts/lib/provision-account-dir.sh`: add PreToolUse matcher entries routing the document-send tools to `preference-consult-gate.sh`, and add `preference-consult-directive.sh` to the UserPromptSubmit hooks list.
56
+ - `platform/plugins/admin/PLUGIN.md`: hook entries for both new hooks (matcher, block condition, block message, log line, fail-open).
57
+ - `.docs/` and `platform/plugins/docs/references/`: the sprint-gate documentation targets.
58
+
59
+ ## Registration matcher note
60
+
61
+ Settings matchers are per-tool. The gate registers against the browser, email, outlook, and SendUserFile matchers; the script itself does the document-deliverable discrimination, so over-matching a non-document send is safe (it exits 0). WhatsApp document delivery routes through the file path the same discrimination covers when a document goes out as a `SendUserFile`; a plain `whatsapp-reply` text is not a document deliverable and is out of scope by decision 1.
62
+
63
+ ## Testing (ephemeral)
64
+
65
+ - **Gate.** For each document-send tool: envelope whose transcript has a `profile-read` since the last user turn → exit 0; envelope with none → exit 2 with the bypass line. A non-document send (email with no attachments, plain browser-pdf-save outside the customer documents scope) → exit 0. Empty/tty stdin → exit 0.
66
+ - **Wrapper.** Emits `hookSpecificOutput.additionalContext` containing both directives; fails open (exit 0) when python3 is absent.
67
+ - **Regression.** The 22 `fs-schema-guard` tests and the `quote-render-gate` tests stay green.
68
+
69
+ ## Observability
70
+
71
+ - **Gate (per send):** `[preference-gate] op=allow|bypass tool=<name>`. Success = `op=allow consulted=true`. Failure = any `op=bypass`.
72
+ - **Wrapper:** `[pref-wrapper] op=inject` per turn; absence across turns means the directive is not reaching the agent.
73
+ - **Reconcile:** the extended brief emits `unreachable=N broken-refs=M scratch-refs=P stranded=Q`; a rising `scratch-refs`/`stranded` is the placement-drift signal. The standing periodic run that would make this a no-event backstop is Task 1931.
74
+
75
+ ## Out of scope
76
+
77
+ - Hard write-block on `output/` writes (render tools legitimately use it as scratch).
78
+ - Auto-promotion or auto-move of stranded files (stays the `data-manager` paired-move job).
79
+ - The periodic scheduler for the reconcile audit (Task 1931).
80
+ - Layer-1 fixed-block content, the voice file, and which preferences are stored.
81
+ - Quote-specific conformance (1922/1928/1929).
@@ -188,6 +188,7 @@ Tools are available via the `admin` MCP server.
188
188
  ## Hooks
189
189
 
190
190
  - `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` — the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML → `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
191
+ - `hooks/preference-consult-gate.sh` — **PreToolUse gate on customer-facing document deliverables.** Registered against `mcp__plugin_browser_browser__browser-pdf-save` (a second command on the same matcher as `quote-render-gate`, either exit 2 blocks) and against the email/Outlook send tools plus `SendUserFile`. It fires only in the document-deliverable shape: a `browser-pdf-save` or `SendUserFile` whose path is under `memory/users/<phone>/documents/` (or a `SendUserFile` of a `.pdf`/`.html`/`.docx`), and `email-send`/`email-reply`/`email-draft-send`/`outlook-mail-send`/`outlook-mail-reply`/`outlook-draft-send` carrying a non-empty `attachments`. For a gated call it reads the session transcript (`transcript_path`) and blocks (exit 2) when no `profile-read` tool call ran after the last user message, because the account's layer-2 `Preference` records (signature policy, header, naming, styling) were not consulted this turn, the exact miss that dropped a customer signature on a delivered document. **Block message:** `Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again.` **Log line** (stderr): `[preference-gate] op=bypass tool=<name> detail=no profile-read this turn` on a block, `op=allow tool=<name> consulted=true` on an allowed document deliverable. Every non-document tool/shape, and every casual channel reply or plain-text email, passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / transcript missing or unreadable). Scope decision: the gate fires on document deliverables only, not on every send, so it never blocks routine chat. The standing reconcile counterpart for stranded `output/` deliverables is filed as a follow-up task.
191
192
  - `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). **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.
192
193
  - `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.
193
194
  - `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.
@@ -196,6 +197,7 @@ Tools are available via the `admin` MCP server.
196
197
  - `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).
197
198
  - `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.
198
199
  - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook.** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool — 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 neither fits, author inline — freestyle is 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.
200
+ - `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.
199
201
  - `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.
200
202
  - `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).
201
203
  - **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.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ HOOK="$DIR/../preference-consult-directive.sh"
5
+ PASS=0; FAIL=0; FAILED=()
6
+ OUT=$(printf '{"session_id":"x","prompt":"hi"}' | bash "$HOOK" 2>/dev/null); RC=$?
7
+ check() { if eval "$1"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
8
+ check '[ "$RC" = "0" ]' "exit 0"
9
+ check 'printf "%s" "$OUT" | grep -q "hookSpecificOutput"' "emits envelope"
10
+ check 'printf "%s" "$OUT" | grep -q "profile-read"' "names profile-read"
11
+ check 'printf "%s" "$OUT" | grep -q "output/"' "names output scratch rule"
12
+ check '! printf "%s" "$OUT" | grep -q "—"' "no em-dash"
13
+ echo "----- $PASS passed, $FAIL failed -----"
14
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
15
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ HOOK="$DIR/../preference-consult-gate.sh"
5
+ PASS=0; FAIL=0; FAILED=()
6
+ run() { printf '%s' "$1" | bash "$HOOK" >/dev/null 2>&1; echo $?; }
7
+ assert_exit() { local got; got=$(run "$2"); if [ "$got" = "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: want exit $1 got $got"); fi; }
8
+
9
+ TMP="$(mktemp -d)"
10
+ # Transcript WITH a profile-read this turn. The profile-read's RESULT is recorded
11
+ # as a user entry whose content is only tool_result blocks (the real Claude Code
12
+ # shape); that entry must NOT be treated as the human turn boundary, or the gate
13
+ # false-blocks every compliant send.
14
+ CONSULTED="$TMP/consulted.jsonl"
15
+ {
16
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":"make the quote"}}'
17
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
18
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"prefs..."}]}}'
19
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"saving the pdf now"}]}}'
20
+ } > "$CONSULTED"
21
+ # Genuine human turn recorded as a content LIST (image + text), not a string, must
22
+ # still count as the boundary.
23
+ CONSULTED_LIST="$TMP/consulted_list.jsonl"
24
+ {
25
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"make the quote"}]}}'
26
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
27
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"prefs..."}]}}'
28
+ } > "$CONSULTED_LIST"
29
+ # Transcript with NO profile-read this turn (one appears BEFORE the last user msg only).
30
+ UNCONSULTED="$TMP/unconsulted.jsonl"
31
+ {
32
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_memory_memory__profile-read","input":{}}]}}'
33
+ printf '%s\n' '{"type":"user","message":{"role":"user","content":"send it"}}'
34
+ printf '%s\n' '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}'
35
+ } > "$UNCONSULTED"
36
+
37
+ pdf_env() { printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"memory/users/+447700900000/documents/quote.pdf"},"transcript_path":"%s"}' "$1"; }
38
+ email_att_env() { printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{"attachments":["memory/users/+44/documents/x.pdf"]},"transcript_path":"%s"}' "$1"; }
39
+
40
+ # Gated tool + consulted (with a tool_result user entry after profile-read) -> allow.
41
+ assert_exit 0 "$(pdf_env "$CONSULTED")" "pdf consulted allows despite tool_result user entry"
42
+ # Genuine user turn as a content list -> boundary still detected, consulted -> allow.
43
+ assert_exit 0 "$(pdf_env "$CONSULTED_LIST")" "pdf consulted (list user turn) allows"
44
+ # Gated tool + not consulted -> block.
45
+ assert_exit 2 "$(pdf_env "$UNCONSULTED")" "pdf unconsulted blocks"
46
+ # Email with attachment + not consulted -> block.
47
+ assert_exit 2 "$(email_att_env "$UNCONSULTED")" "email+attach unconsulted blocks"
48
+ # Email with NO attachment -> not a document deliverable -> allow.
49
+ assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_email_email__email-send","tool_input":{},"transcript_path":"%s"}' "$UNCONSULTED")" "email no-attach allows"
50
+ # browser-pdf-save OUTSIDE customer documents scope -> allow.
51
+ assert_exit 0 "$(printf '{"tool_name":"mcp__plugin_browser_browser__browser-pdf-save","tool_input":{"path":"output/report.pdf"},"transcript_path":"%s"}' "$UNCONSULTED")" "pdf outside scope allows"
52
+ # Non-send tool -> allow.
53
+ assert_exit 0 "$(printf '{"tool_name":"Read","tool_input":{"file_path":"x"},"transcript_path":"%s"}' "$UNCONSULTED")" "read allows"
54
+ # Empty stdin -> fail open (allow).
55
+ assert_exit 0 "" "empty stdin fails open"
56
+
57
+ echo "----- $PASS passed, $FAIL failed -----"
58
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
59
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # preference-consult-directive — UserPromptSubmit hook. Injects a standing line
3
+ # naming the two-layer preference architecture and two adherence directives:
4
+ # consult layer-2 preferences (profile-read) before any customer document, and
5
+ # promote finished deliverables out of output/. Prompt-independent; fail-open.
6
+ set -uo pipefail
7
+ HOOK_INPUT=$(cat 2>/dev/null || true)
8
+ command -v python3 >/dev/null 2>&1 || exit 0
9
+ python3 - <<'PY' 2>/dev/null || exit 0
10
+ import json
11
+ ctx = (
12
+ "<preference-adherence>\n"
13
+ "Your preferences live in two layers. Layer 1 is the fixed block already in front of you every turn. "
14
+ "Layer 2 is the account's own saved records, read with profile-read; it holds the signature policy, header, "
15
+ "naming conventions and styling that layer 1 does not, and it grows over time. Before any customer-facing "
16
+ "document deliverable (a PDF or file sent to the customer, or an email or Outlook message carrying an "
17
+ "attachment), read the relevant layer-2 preferences with profile-read and apply them. "
18
+ "Store finished deliverables under documents/ or the project folder. output/ is scratch of last resort, "
19
+ "rebuilt on the next tool write, and never the home a graph reference points at.\n"
20
+ "</preference-adherence>"
21
+ )
22
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": ctx}}))
23
+ PY
24
+ echo "[pref-wrapper] op=inject" >&2
25
+ exit 0
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # preference-consult-gate — PreToolUse gate on customer-facing document deliverables.
3
+ #
4
+ # A customer document (a PDF/file to the customer documents scope, or an email/
5
+ # Outlook send carrying an attachment) must not go out until the account's saved
6
+ # layer-2 preferences were consulted this turn, proven by a profile-read tool
7
+ # call after the last user message in the session transcript. Layer-2 records
8
+ # hold the signature policy, header, naming and styling rules the fixed layer-1
9
+ # block does not; a send with no profile-read this turn is the miss that dropped
10
+ # a customer signature.
11
+ #
12
+ # Scope (document-deliverable shape only; every other tool/shape exits 0):
13
+ # *browser-pdf-save path under memory/users/<phone>/documents/
14
+ # SendUserFile path under memory/users/<phone>/documents/ or .pdf/.html/.docx
15
+ # *email-send|reply|draft-send / *outlook-mail-send|reply|draft-send non-empty attachments
16
+ #
17
+ # Exit: 0 allow, 2 block. Fail OPEN on tty/empty stdin/no python3/transcript
18
+ # unreadable. Block log: [preference-gate] op=bypass tool=<name> detail=no profile-read this turn
19
+ set -uo pipefail
20
+
21
+ if [ -t 0 ]; then exit 0; fi
22
+ INPUT=$(cat)
23
+ [ -z "$INPUT" ] && exit 0
24
+ command -v python3 >/dev/null 2>&1 || exit 0
25
+
26
+ DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
27
+ import os, json, re, sys
28
+ try:
29
+ d = json.loads(os.environ["INPUT"])
30
+ except Exception:
31
+ sys.exit(0)
32
+ tool = d.get("tool_name", "") or ""
33
+ ti = d.get("tool_input", {}) or {}
34
+
35
+ def under_customer_docs(p):
36
+ return bool(re.search(r'(^|/)memory/users/[^/]+/documents/', os.path.normpath(p or "")))
37
+
38
+ gated = False
39
+ if tool.endswith("browser-pdf-save"):
40
+ gated = under_customer_docs(ti.get("path", ""))
41
+ elif tool == "SendUserFile" or tool.endswith("__SendUserFile"):
42
+ p = ti.get("path", "") or ti.get("file_path", "")
43
+ gated = under_customer_docs(p) or p.lower().endswith((".pdf", ".html", ".docx"))
44
+ elif re.search(r'(email-send|email-reply|email-draft-send|outlook-mail-send|outlook-mail-reply|outlook-draft-send)$', tool):
45
+ atts = ti.get("attachments")
46
+ gated = isinstance(atts, list) and len(atts) > 0
47
+
48
+ if not gated:
49
+ print("") # not a document deliverable
50
+ sys.exit(0)
51
+
52
+ transcript = d.get("transcript_path", "") or ""
53
+ if not transcript or not os.access(transcript, os.R_OK):
54
+ print("") # cannot inspect consultation -> fail open
55
+ sys.exit(0)
56
+
57
+ try:
58
+ with open(transcript) as f:
59
+ lines = f.readlines()
60
+ except Exception:
61
+ print("")
62
+ sys.exit(0)
63
+
64
+ def is_genuine_user_turn(e):
65
+ is_user = (e.get("type") == "user") or (e.get("role") == "user") or (isinstance(e.get("message"), dict) and e["message"].get("role") == "user")
66
+ if not is_user:
67
+ return False
68
+ msg = e.get("message", e)
69
+ content = msg.get("content") if isinstance(msg, dict) else None
70
+ if isinstance(content, str):
71
+ return True
72
+ if isinstance(content, list):
73
+ # A tool_result delivery is recorded as a user entry whose content is
74
+ # composed only of tool_result blocks. It is not a human turn; counting
75
+ # it would push the boundary past the profile-read that ran this turn and
76
+ # false-block a compliant send. A genuine human turn carries at least one
77
+ # non-tool_result block (text/image).
78
+ return any(not (isinstance(b, dict) and b.get("type") == "tool_result") for b in content)
79
+ # Unknown shape: do not advance the boundary, biasing toward a wider consulted
80
+ # window (toward allow) — fail-open is the correct direction here.
81
+ return False
82
+
83
+ last_user = -1
84
+ for i, ln in enumerate(lines):
85
+ try:
86
+ e = json.loads(ln)
87
+ except Exception:
88
+ continue
89
+ if is_genuine_user_turn(e):
90
+ last_user = i
91
+
92
+ consulted = False
93
+ for ln in (lines[last_user + 1:] if last_user >= 0 else lines):
94
+ try:
95
+ e = json.loads(ln)
96
+ except Exception:
97
+ continue
98
+ msg = e.get("message", e)
99
+ content = msg.get("content") if isinstance(msg, dict) else None
100
+ if isinstance(content, list):
101
+ for block in content:
102
+ if isinstance(block, dict) and block.get("type") == "tool_use" and (block.get("name", "") or "").endswith("profile-read"):
103
+ consulted = True
104
+
105
+ print("%s|%s" % (tool, "yes" if consulted else "no"))
106
+ PY
107
+ )
108
+
109
+ [ -z "$DECISION" ] && exit 0
110
+ TOOL="${DECISION%%|*}"
111
+ CONSULTED="${DECISION##*|}"
112
+
113
+ if [ "$CONSULTED" = "yes" ]; then
114
+ echo "[preference-gate] op=allow tool=$TOOL consulted=true" >&2
115
+ exit 0
116
+ fi
117
+
118
+ echo "[preference-gate] op=bypass tool=$TOOL detail=no profile-read this turn" >&2
119
+ echo "Blocked: this customer document is about to go out without the account's saved preferences being checked this turn. Those records hold the signature, header, naming and styling rules. Run profile-read for this account, apply anything relevant, then send again." >&2
120
+ exit 2
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:643a947eb8968517ad598f7f8b4e433d8545877d7c21f857d4867ac914e34cc9
4
+ content-hash: sha256:59873e611d7a40e6aa9c3f41bc766d8a4e0632934f7542470aa309fb59dc5db7
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -4227,6 +4227,8 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
4227
4227
 
4228
4228
  **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
4229
4229
 
4230
+ **Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up.
4231
+
4230
4232
  ---
4231
4233
 
4232
4234
  ## Output Formatting and Budget
@@ -408,6 +408,8 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
408
408
 
409
409
  **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
410
410
 
411
+ **Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up.
412
+
411
413
  ---
412
414
 
413
415
  ## Output Formatting and Budget
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ LIB="$DIR/../lib/provision-account-dir.sh"
5
+ PASS=0; FAIL=0; FAILED=()
6
+ c(){ if grep -qF "$1" "$LIB"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
7
+ c 'preference-consult-gate.sh' "gate registered"
8
+ c 'preference-consult-directive.sh' "directive registered"
9
+ c 'mcp__plugin_email_email__email-send' "email matcher present"
10
+ c 'mcp__plugin_outlook_outlook__outlook-mail-send' "outlook matcher present"
11
+ echo "----- $PASS passed, $FAIL failed -----"
12
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
13
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for account-settings-pdf-conformance.sh (Task 1929).
3
+ #
4
+ # The lib retrofits the quote-render PDF-conformance PostToolUse matcher onto
5
+ # existing accounts' .claude/settings.json on every install/upgrade. Task 1922
6
+ # added the matcher to provision-account-dir.sh, but that writer only runs at
7
+ # account-provision time and the upgrade path re-provisions only the house
8
+ # account -- so an account provisioned before 1922 keeps a settings.json without
9
+ # the browser-pdf-save PostToolUse matcher and never runs the PDF gate (live
10
+ # glsmith is such an account).
11
+ #
12
+ # Covers:
13
+ # 1. Stale matcher (browser-pdf-save present, conformance cmd absent) -> updated, appended, idempotent
14
+ # 2. Correct matcher (conformance cmd present) -> already-set, byte-identical
15
+ # 3. browser-pdf-save matcher absent (broad mcp__.* present) -> updated, matcher added, wildcard untouched
16
+ # 4. No .hooks.PostToolUse -> no-hooks, untouched
17
+ # 5. File absent -> absent
18
+ # 6. Malformed JSON -> rewrite-failed, untouched, no temp
19
+ # 7. reconcile_all_accounts_pdf_conformance -> patches every account.json dir, skips others
20
+ #
21
+ # All cases assert the [backfill-1929] status token and the post-state of the file.
22
+
23
+ set -u
24
+
25
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/account-settings-pdf-conformance.sh"
26
+ if [ ! -f "$LIB" ]; then
27
+ echo "FAIL: $LIB not found" >&2
28
+ exit 1
29
+ fi
30
+ # shellcheck source=/dev/null
31
+ . "$LIB"
32
+
33
+ MATCHER='mcp__plugin_browser_browser__browser-pdf-save'
34
+ CONFORMANCE='bash $PLATFORM_ROOT/plugins/admin/hooks/quote-render-pdf-conformance.sh'
35
+
36
+ PASS=0
37
+ FAIL=0
38
+ TMP="$(mktemp -d)"
39
+ trap 'rm -rf "$TMP"' EXIT
40
+
41
+ ok() { PASS=$((PASS+1)); }
42
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
43
+
44
+ # settings.json with a PostToolUse browser-pdf-save matcher carrying the given
45
+ # command list. Args after $1 are command strings for that matcher.
46
+ write_settings() {
47
+ local file="$1"; shift
48
+ local cmds="" c
49
+ for c in "$@"; do
50
+ [ -n "$cmds" ] && cmds="$cmds,"
51
+ cmds="$cmds{\"type\":\"command\",\"command\":\"$c\"}"
52
+ done
53
+ mkdir -p "$(dirname "$file")"
54
+ cat > "$file" <<EOF
55
+ {
56
+ "permissions": { "defaultMode": "bypassPermissions", "allow": [], "deny": [] },
57
+ "hooks": {
58
+ "PostToolUse": [
59
+ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "bash \$P/b.sh" } ] },
60
+ { "matcher": "$MATCHER", "hooks": [ $cmds ] },
61
+ { "matcher": "mcp__.*", "hooks": [ { "type": "command", "command": "bash \$P/missing.sh" } ] }
62
+ ]
63
+ }
64
+ }
65
+ EOF
66
+ }
67
+
68
+ conformance_count() { grep -cF 'quote-render-pdf-conformance.sh' "$1" 2>/dev/null || true; }
69
+ pdfmatcher_present() {
70
+ jq -e --arg m "$MATCHER" '.hooks.PostToolUse | any(.[]; .matcher == $m)' "$1" >/dev/null 2>&1
71
+ }
72
+ wildcard_present() {
73
+ jq -e '.hooks.PostToolUse | any(.[]; .matcher == "mcp__.*")' "$1" >/dev/null 2>&1
74
+ }
75
+
76
+ # --- 1. stale matcher -> updated, then idempotent ---------------------------
77
+ F="$TMP/stale/.claude/settings.json"
78
+ write_settings "$F"
79
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
80
+ case "$OUT" in *status=updated*) ok;; *) bad "1a stale should be updated, got: $OUT";; esac
81
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "1b conformance not appended (count=$(conformance_count "$F"))"
82
+ OUT2="$(ensure_pdf_conformance_matcher "$F")"
83
+ case "$OUT2" in *status=already-set*) ok;; *) bad "1c re-run should be already-set, got: $OUT2";; esac
84
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "1d conformance duplicated on re-run (count=$(conformance_count "$F"))"
85
+
86
+ # --- 2. correct matcher -> already-set, byte-identical ----------------------
87
+ F="$TMP/good/.claude/settings.json"
88
+ write_settings "$F" "$CONFORMANCE"
89
+ BEFORE="$(cat "$F")"
90
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
91
+ case "$OUT" in *status=already-set*) ok;; *) bad "2a good should be already-set, got: $OUT";; esac
92
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "2b already-set file was rewritten"
93
+
94
+ # --- 3. browser-pdf-save matcher absent (wildcard present) -> updated, added --
95
+ F="$TMP/nomatcher/.claude/settings.json"
96
+ mkdir -p "$(dirname "$F")"
97
+ cat > "$F" <<'EOF'
98
+ { "permissions": {}, "hooks": { "PostToolUse": [
99
+ { "matcher": "Bash", "hooks": [] },
100
+ { "matcher": "mcp__.*", "hooks": [ { "type": "command", "command": "bash $P/missing.sh" } ] }
101
+ ] } }
102
+ EOF
103
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
104
+ case "$OUT" in *status=updated*) ok;; *) bad "3a nomatcher should be updated, got: $OUT";; esac
105
+ pdfmatcher_present "$F" && ok || bad "3b browser-pdf-save matcher not added"
106
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "3c conformance missing after add"
107
+ wildcard_present "$F" && ok || bad "3d wildcard matcher was disturbed"
108
+ # added matcher carries exactly the one conformance command
109
+ if jq -e --arg m "$MATCHER" '.hooks.PostToolUse[] | select(.matcher==$m) | .hooks | length == 1' "$F" >/dev/null 2>&1; then ok; else bad "3e added matcher should carry exactly one command"; fi
110
+
111
+ # --- 4. no PostToolUse -> no-hooks, untouched -------------------------------
112
+ F="$TMP/nohooks/.claude/settings.json"
113
+ mkdir -p "$(dirname "$F")"
114
+ echo '{ "permissions": {}, "hooks": {} }' > "$F"
115
+ BEFORE="$(cat "$F")"
116
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
117
+ case "$OUT" in *status=no-hooks*) ok;; *) bad "4a should be no-hooks, got: $OUT";; esac
118
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "4b no-hooks file was rewritten"
119
+
120
+ # --- 5. file absent -> absent ------------------------------------------------
121
+ OUT="$(ensure_pdf_conformance_matcher "$TMP/does-not-exist/.claude/settings.json")"
122
+ case "$OUT" in *status=absent*) ok;; *) bad "5 should be absent, got: $OUT";; esac
123
+
124
+ # --- 6. malformed JSON -> rewrite-failed, untouched -------------------------
125
+ F="$TMP/bad/.claude/settings.json"
126
+ mkdir -p "$(dirname "$F")"
127
+ printf '{ this is not json ' > "$F"
128
+ BEFORE="$(cat "$F")"
129
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
130
+ case "$OUT" in *status=rewrite-failed*) ok;; *) bad "6a malformed should be rewrite-failed, got: $OUT";; esac
131
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "6b malformed file was mutated"
132
+ [ ! -e "$F.1929.tmp" ] && ok || bad "6c temp file left behind"
133
+
134
+ # --- 7. reconcile_all_accounts_pdf_conformance ------------------------------
135
+ ROOT="$TMP/accts"
136
+ mkdir -p "$ROOT/acctA/.claude" "$ROOT/acctB/.claude" "$ROOT/notanaccount/.claude"
137
+ echo '{"role":"house"}' > "$ROOT/acctA/account.json"
138
+ echo '{"role":"client"}' > "$ROOT/acctB/account.json"
139
+ # notanaccount has no account.json -> must be skipped
140
+ write_settings "$ROOT/acctA/.claude/settings.json"
141
+ write_settings "$ROOT/acctB/.claude/settings.json" "$CONFORMANCE"
142
+ write_settings "$ROOT/notanaccount/.claude/settings.json"
143
+ RECOUT="$(reconcile_all_accounts_pdf_conformance "$ROOT")"
144
+ [ "$(conformance_count "$ROOT/acctA/.claude/settings.json")" -eq 1 ] && ok || bad "7a acctA not patched"
145
+ [ "$(conformance_count "$ROOT/acctB/.claude/settings.json")" -eq 1 ] && ok || bad "7b acctB disturbed"
146
+ [ "$(conformance_count "$ROOT/notanaccount/.claude/settings.json")" -eq 0 ] && ok || bad "7c non-account dir was patched"
147
+ case "$RECOUT" in *acctA*status=updated*) ok;; *) bad "7d reconcile did not log acctA updated";; esac
148
+
149
+ echo "----------------------------------------"
150
+ echo "PASS=$PASS FAIL=$FAIL"
151
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,98 @@
1
+ # account-settings-pdf-conformance.sh — retrofit the quote-render PDF-conformance
2
+ # PostToolUse matcher onto existing accounts' .claude/settings.json. Task 1929.
3
+ #
4
+ # Task 1922 added a PostToolUse matcher on the browser-pdf-save tool running
5
+ # quote-render-pdf-conformance.sh to provision-account-dir.sh. That writer only
6
+ # runs at account-provision time; the upgrade path re-provisions the house
7
+ # account but NOT existing client sub-accounts, so any account provisioned before
8
+ # 1922 keeps a settings.json without the matcher and never runs the PDF gate
9
+ # (live glsmith is such an account). This lib is the standing backfill:
10
+ # setup-account.sh reconciles every account on every install so the gate reaches
11
+ # pre-1922 accounts. Mirrors account-settings-askgate.sh (Task 1683).
12
+ #
13
+ # Requires jq. Idempotent. Never echoes file contents. Fail-open (nudge, not
14
+ # brick): any unusable file logs a status and is left untouched.
15
+ #
16
+ # Sourced by setup-account.sh; also runnable standalone for a manual repair.
17
+
18
+ # The exact command string provision-account-dir.sh writes. $PLATFORM_ROOT is a
19
+ # literal in the settings file, resolved by Claude Code at hook-run time.
20
+ _PDF_CONFORMANCE_MATCHER='mcp__plugin_browser_browser__browser-pdf-save'
21
+ _PDF_CONFORMANCE_CMD='bash $PLATFORM_ROOT/plugins/admin/hooks/quote-render-pdf-conformance.sh'
22
+
23
+ # ensure_pdf_conformance_matcher <settings_file>
24
+ # Guarantees the PostToolUse browser-pdf-save matcher carries the conformance
25
+ # command: appends it if the matcher exists without it, or adds the whole matcher
26
+ # (single conformance command) if the matcher is absent. Logs exactly one
27
+ # `[backfill-1929] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>`
28
+ # to stdout. Always returns 0.
29
+ ensure_pdf_conformance_matcher() {
30
+ local f="$1"
31
+ if [ ! -f "$f" ]; then
32
+ echo "[backfill-1929] file=$f status=absent"
33
+ return 0
34
+ fi
35
+ # Patchable only when the file is valid JSON with a PostToolUse array.
36
+ if ! jq -e '.hooks.PostToolUse | type == "array"' "$f" >/dev/null 2>&1; then
37
+ if jq -e . "$f" >/dev/null 2>&1; then
38
+ echo "[backfill-1929] file=$f status=no-hooks"
39
+ else
40
+ echo "[backfill-1929] file=$f status=rewrite-failed"
41
+ fi
42
+ return 0
43
+ fi
44
+ local tmp="$f.1929.tmp"
45
+ if ! jq \
46
+ --arg matcher "$_PDF_CONFORMANCE_MATCHER" \
47
+ --arg cmd "$_PDF_CONFORMANCE_CMD" '
48
+ .hooks.PostToolUse |= (
49
+ if any(.[]; .matcher == $matcher)
50
+ then map(
51
+ if .matcher == $matcher
52
+ then .hooks = (
53
+ if any(.hooks[]; .command == $cmd)
54
+ then .hooks
55
+ else .hooks + [{type: "command", command: $cmd}]
56
+ end)
57
+ else . end)
58
+ else . + [{matcher: $matcher, hooks: [{type: "command", command: $cmd}]}]
59
+ end)
60
+ ' "$f" > "$tmp" 2>/dev/null; then
61
+ rm -f "$tmp"
62
+ echo "[backfill-1929] file=$f status=rewrite-failed"
63
+ return 0
64
+ fi
65
+ # Semantic no-op? Compare canonical forms; leave the file byte-identical if so.
66
+ # `|| true` keeps a jq failure here (e.g. $f truncated by a concurrent writer
67
+ # between the type-check above and this read) from tripping a caller's `set -e`
68
+ # and aborting the whole install. This lib is fail-open by contract.
69
+ local before after
70
+ before="$(jq -S . "$f" 2>/dev/null || true)"
71
+ after="$(jq -S . "$tmp" 2>/dev/null || true)"
72
+ if [ "$before" = "$after" ]; then
73
+ rm -f "$tmp"
74
+ echo "[backfill-1929] file=$f status=already-set"
75
+ return 0
76
+ fi
77
+ if mv "$tmp" "$f"; then
78
+ echo "[backfill-1929] file=$f status=updated"
79
+ else
80
+ rm -f "$tmp"
81
+ echo "[backfill-1929] file=$f status=rewrite-failed"
82
+ fi
83
+ return 0
84
+ }
85
+
86
+ # reconcile_all_accounts_pdf_conformance <accounts_dir>
87
+ # Standing check + backfill: every account dir with an account.json gets its
88
+ # PostToolUse browser-pdf-save matcher's conformance command ensured. Mirrors
89
+ # reconcile_all_accounts_askgate.
90
+ reconcile_all_accounts_pdf_conformance() {
91
+ local accts="$1"
92
+ [ -d "$accts" ] || return 0
93
+ local acct
94
+ for acct in "$accts"/*/; do
95
+ [ -f "$acct/account.json" ] || continue
96
+ ensure_pdf_conformance_matcher "$acct/.claude/settings.json"
97
+ done
98
+ }
@@ -113,6 +113,18 @@ provision_account_dir() {
113
113
  "hooks": [
114
114
  { "type": "command", "command": "bash $HOOKS_PATH/quote-render-gate.sh" }
115
115
  ]
116
+ },
117
+ {
118
+ "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
119
+ "hooks": [
120
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
121
+ ]
122
+ },
123
+ {
124
+ "matcher": "mcp__plugin_email_email__email-send|mcp__plugin_email_email__email-reply|mcp__plugin_email_email__email-draft-send|mcp__plugin_outlook_outlook__outlook-mail-send|mcp__plugin_outlook_outlook__outlook-mail-reply|mcp__plugin_outlook_outlook__outlook-draft-send|SendUserFile",
125
+ "hooks": [
126
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
127
+ ]
116
128
  }
117
129
  ],
118
130
  "PostToolUse": [
@@ -164,7 +176,8 @@ provision_account_dir() {
164
176
  "hooks": [
165
177
  { "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
166
178
  { "type": "command", "command": "bash $HOOKS_PATH/prompt-optimiser-directive.sh" },
167
- { "type": "command", "command": "bash $HOOKS_PATH/datetime-inject.sh" }
179
+ { "type": "command", "command": "bash $HOOKS_PATH/datetime-inject.sh" },
180
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-directive.sh" }
168
181
  ]
169
182
  }
170
183
  ],
@@ -36,6 +36,8 @@ ACCOUNTS_DIR="$INSTALL_DIR/data/accounts"
36
36
  . "$SCRIPT_DIR/lib/provision-account-dir.sh"
37
37
  # shellcheck source=lib/account-settings-askgate.sh
38
38
  . "$SCRIPT_DIR/lib/account-settings-askgate.sh"
39
+ # shellcheck source=lib/account-settings-pdf-conformance.sh
40
+ . "$SCRIPT_DIR/lib/account-settings-pdf-conformance.sh"
39
41
  # shellcheck source=lib/read-brand-json.sh
40
42
  . "$SCRIPT_DIR/lib/read-brand-json.sh"
41
43
  # Resolve brand-aware persistent users.json path before the resolver runs.
@@ -77,3 +79,14 @@ reconcile_all_accounts_owned_dirs "$ACCOUNTS_DIR"
77
79
  # so the gate reaches pre-1552 accounts. Idempotent; logs one [backfill-1683] line
78
80
  # per account.
79
81
  reconcile_all_accounts_askgate "$ACCOUNTS_DIR"
82
+
83
+ # ------------------------------------------------------------------
84
+ # 5. Retrofit the quote-render PDF-conformance matcher onto every account
85
+ # ------------------------------------------------------------------
86
+ # Standing check + durable backfill (Task 1929): provision_account_dir writes the
87
+ # PostToolUse browser-pdf-save matcher (quote-render-pdf-conformance.sh) only at
88
+ # provision time, and this script re-provisions only the house account. An
89
+ # account created before Task 1922 keeps a settings.json without the matcher and
90
+ # never runs the PDF gate. Reconcile every account on every install so the gate
91
+ # reaches pre-1922 accounts. Idempotent; logs one [backfill-1929] line per account.
92
+ reconcile_all_accounts_pdf_conformance "$ACCOUNTS_DIR"
@@ -28,6 +28,11 @@ rule. Renaming an existing bad name goes through the `data-manager` specialist.
28
28
 
29
29
  ## Tool-owned (off-limits — never reorganise)
30
30
 
31
+ Finished deliverables must be promoted out of `output/` into `documents/` or
32
+ `projects/<name>/` through the `data-manager` specialist; `output/` is scratch of
33
+ last resort, and a graph reference must never point into it (the next tool write
34
+ rebuilds the tree and the reference dangles).
35
+
31
36
  The internal structure of these is owned by the writing tool and is recreated on
32
37
  the next write: `url-get/`, `output/`, `generated/`, `extracted/`, `uploads/`,
33
38
  any published/served tree (`sites/`, `public/`), `agents/`, `specialists/`, and
@@ -21,13 +21,13 @@ Directories whose layout is owned by tools are not yours to reorganise — a too
21
21
 
22
22
  ## The reconcile audit brief
23
23
 
24
- When admin's brief names a reconcile audit, the pass is read-only: walk the operator-file areas of the account directory, resolve each file against the graph via `memory-search`, and count (a) files unreachable from any graph node and (b) graph file-references that do not resolve to a real path. Move nothing, dispatch nothing. The audit exists so a rising count is visible before anyone asks for a cleanup.
24
+ When admin's brief names a reconcile audit, the pass is read-only: walk the operator-file areas of the account directory, resolve each file against the graph via `memory-search`, and count (a) files unreachable from any graph node, (b) graph file-references that do not resolve to a real path, (c) graph file-references that resolve into a tool-owned scratch dir (`output/`, `generated/`, `extracted/`, `url-get/`), which the next tool write rebuilds so the reference is fragile by construction, and (d) deliverables a node references that resolve only under a scratch dir and were never promoted to `documents/` or `projects/`. Move nothing, dispatch nothing. The audit exists so a rising count is visible before anyone asks for a cleanup.
25
25
 
26
26
  When the vertical's `schema-<vertical>.md` declares a **published / served tree** with a Filesystem ↔ graph section (e.g. the per-listing site target with a URL ↔ path rule and an artefact → graph-reference table), the audit also walks that tree. The graph references it by hosted URL, not local path, so resolution runs through the section's URL ↔ path rule: for each served file, derive its hosted URL and check that some node references it; for each node URL **that matches the section's hosted-URL prefix**, derive its local path and check the file exists. Count served files matching no node reference in `unreachable` and prefix-matching node URLs resolving to no served file in `broken-refs`. Node URLs the rule cannot resolve — externally-hosted URLs (a CDN image, a third-party portal link) carry no served-tree prefix — are outside this audit and are never counted as broken. Honour the section's "served but intentionally unreferenced (tool / collateral)" list — those classes carry no reference by design and are never orphans, the same exclusion you already apply to the `url-get/` cache. This walk is still read-only: the served tree is a publish write target (off-limits to moves, above); the audit only counts drift, it never reorganises.
27
27
 
28
28
  ## Output contract
29
29
 
30
- End every dispatch with a one-line machine-greppable summary as the last line of your reply. For stewardship work: `moves=N ref-updates=M skipped=<reason|none>`. For the reconcile audit: `unreachable=N broken-refs=M`. If a brief is ambiguous about whether a directory is operator data or a system convention, name the ambiguity in your reply instead of guessing.
30
+ End every dispatch with a one-line machine-greppable summary as the last line of your reply. For stewardship work: `moves=N ref-updates=M skipped=<reason|none>`. For the reconcile audit: `unreachable=N broken-refs=M scratch-refs=P stranded=Q`. If a brief is ambiguous about whether a directory is operator data or a system convention, name the ambiguity in your reply instead of guessing.
31
31
 
32
32
  ## Review gates
33
33