@rubytech/create-maxy-code 0.1.492 → 0.1.494
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-23-task-1930-adherence-enforcement.md +422 -0
- package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md +81 -0
- package/payload/platform/plugins/admin/PLUGIN.md +3 -0
- package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh +15 -0
- package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh +59 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
- package/payload/platform/plugins/admin/hooks/preference-consult-directive.sh +25 -0
- package/payload/platform/plugins/admin/hooks/preference-consult-gate.sh +120 -0
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
- package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +11 -1
- package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +13 -0
- package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +151 -0
- package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +98 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +20 -1
- package/payload/platform/scripts/setup-account.sh +13 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
- package/payload/platform/templates/account-schema/SCHEMA.md +5 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
- package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
- package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
- package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
- package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
- package/payload/platform/templates/specialists/agents/data-manager.md +3 -2
- package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
- package/payload/platform/templates/specialists/agents/librarian.md +1 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
- package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
- package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
- package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
- package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
- package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
- package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
- package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
- package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
- package/payload/server/public/chat.html +2 -2
- package/payload/server/public/operator.html +2 -2
- package/payload/server/public/public.html +2 -2
- package/payload/server/server.js +35 -26
package/package.json
CHANGED
|
@@ -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.
|
package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md
ADDED
|
@@ -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,8 @@ 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.
|
|
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.
|
|
191
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.
|
|
192
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.
|
|
193
195
|
- `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 channel reply tool 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.
|
|
@@ -195,6 +197,7 @@ Tools are available via the `admin` MCP server.
|
|
|
195
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).
|
|
196
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.
|
|
197
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.
|
|
198
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.
|
|
199
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).
|
|
200
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 ]
|