@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.
Files changed (63) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-23-task-1930-adherence-enforcement.md +422 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md +81 -0
  4. package/payload/platform/plugins/admin/PLUGIN.md +3 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh +15 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh +59 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
  8. package/payload/platform/plugins/admin/hooks/preference-consult-directive.sh +25 -0
  9. package/payload/platform/plugins/admin/hooks/preference-consult-gate.sh +120 -0
  10. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
  11. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
  12. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
  13. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +11 -1
  14. package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
  15. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
  18. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
  19. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
  20. package/payload/platform/plugins/docs/references/internals.md +2 -0
  21. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +13 -0
  22. package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +151 -0
  23. package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +98 -0
  24. package/payload/platform/scripts/lib/provision-account-dir.sh +20 -1
  25. package/payload/platform/scripts/setup-account.sh +13 -0
  26. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
  28. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
  30. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
  36. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
  38. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
  39. package/payload/platform/templates/account-schema/SCHEMA.md +5 -0
  40. package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
  41. package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
  42. package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
  43. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
  44. package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
  45. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
  46. package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
  47. package/payload/platform/templates/specialists/agents/data-manager.md +3 -2
  48. package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
  49. package/payload/platform/templates/specialists/agents/librarian.md +1 -0
  50. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
  51. package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
  52. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
  53. package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
  54. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
  55. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
  56. package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
  57. package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
  58. package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
  59. package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
  60. package/payload/server/public/chat.html +2 -2
  61. package/payload/server/public/operator.html +2 -2
  62. package/payload/server/public/public.html +2 -2
  63. package/payload/server/server.js +35 -26
@@ -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,99 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for quote-render-pdf-conformance.sh.
3
+ #
4
+ # PostToolUse hook on browser-pdf-save. For a gated client-quote PDF whose render
5
+ # receipt names the source HTML, it blocks when the PDF dropped links (its /URI
6
+ # count is below the HTML's absolute-link count) or when an embedded https link
7
+ # is not live. Everything else passes; it fails open on anything uninspectable.
8
+ #
9
+ # Covers (all offline-deterministic — the one network case points at a closed
10
+ # local port so the connection refusal is immediate and reproducible):
11
+ # A. non-quote PDF → ALLOWED (not gated)
12
+ # B. quote PDF, no receipt → ALLOWED (PreToolUse owns that block)
13
+ # C. quote PDF, HTML has no absolute links → ALLOWED (0 >= 0, no fetch)
14
+ # D. quote PDF, PDF /URI < HTML links → BLOCKED (link-loss, no fetch)
15
+ # E. quote PDF, /URI ok but an https link is dead → BLOCKED (link not live)
16
+ # F. quote PDF, /URI ok and the only link is 200 → not asserted (needs a live server)
17
+ # G. empty stdin → ALLOWED (fail-open)
18
+ # H. non-browser tool → ALLOWED (matcher defence-in-depth)
19
+ # I. a block emits the op=bypass fault line
20
+
21
+ set -u
22
+
23
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-render-pdf-conformance.sh"
24
+ if [[ ! -x "$HOOK" ]]; then
25
+ echo "FAIL: $HOOK not executable" >&2
26
+ exit 1
27
+ fi
28
+
29
+ ACCT=$(mktemp -d)
30
+ cleanup() { rm -rf "$ACCT"; }
31
+ trap cleanup EXIT
32
+
33
+ DOCS="$ACCT/memory/users/44700900000/documents"
34
+ mkdir -p "$DOCS"
35
+
36
+ # Receipt + filed HTML for each scenario job. The receipt names the filed quote
37
+ # HTML; the hook reads its absolute links and compares to the PDF's /URI count.
38
+ mk_job() { # jobId, html_body, pdf_uri_repeat
39
+ local job="$1" body="$2" uris="$3"
40
+ mkdir -p "$ACCT/quoting/jobs/$job"
41
+ local html="$DOCS/quote-$job.html"
42
+ printf '%s' "$body" > "$html"
43
+ printf '{"jobId":"%s","filed":{"internal":"x","breakdown":"x","quote":"%s"}}' "$job" "$html" \
44
+ > "$ACCT/quoting/jobs/$job/render-receipt.json"
45
+ # A minimal PDF-ish file carrying `uris` copies of the /URI token.
46
+ { printf '%%PDF-1.4\n'; local i=0; while [[ $i -lt $uris ]]; do printf '/URI (x)\n'; i=$((i+1)); done; } \
47
+ > "$DOCS/quote-$job.pdf"
48
+ }
49
+
50
+ # C: no absolute links, PDF has none → allow.
51
+ mk_job "clean" '<html><body><a href="#top">top</a><p>no links</p></body></html>' 0
52
+ # D: two https links, PDF dropped them (0 /URI) → block on count.
53
+ mk_job "dropped" '<html><body><a href="https://a.example/1">a</a><a href="https://b.example/2">b</a></body></html>' 0
54
+ # E: one https link, PDF kept a /URI, but the link points at a closed local port → block on liveness.
55
+ mk_job "dead" '<html><body><a href="https://127.0.0.1:1/never">sign</a></body></html>' 1
56
+
57
+ PASS=0
58
+ FAIL=0
59
+
60
+ run_case() {
61
+ local name="$1" stdin="$2" expected_exit="$3" actual_exit
62
+ ( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
63
+ actual_exit=$?
64
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
65
+ echo "PASS: $name (exit=$actual_exit)"; PASS=$((PASS + 1))
66
+ else
67
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2; FAIL=$((FAIL + 1))
68
+ fi
69
+ }
70
+
71
+ envelope() { printf '{"hook_event_name":"PostToolUse","tool_name":"%s","tool_input":{"path":"%s"}}' "$1" "$2"; }
72
+ BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
73
+
74
+ run_case "A: non-quote PDF → ALLOWED" \
75
+ "$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
76
+ run_case "B: quote PDF, no receipt → ALLOWED (PreToolUse owns it)" \
77
+ "$(envelope "$BROWSER" "$DOCS/quote-nojob.pdf")" 0
78
+ run_case "C: HTML has no absolute links → ALLOWED" \
79
+ "$(envelope "$BROWSER" "$DOCS/quote-clean.pdf")" 0
80
+ run_case "D: PDF /URI below HTML link count → BLOCKED" \
81
+ "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" 2
82
+ run_case "E: an embedded https link is not live → BLOCKED" \
83
+ "$(envelope "$BROWSER" "$DOCS/quote-dead.pdf")" 2
84
+ run_case "G: empty stdin → ALLOWED (fail-open)" \
85
+ "" 0
86
+ run_case "H: non-browser tool → ALLOWED" \
87
+ '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"path":"'"$DOCS"'/quote-dropped.pdf"}}' 0
88
+
89
+ # I: a block emits the op=bypass fault line on stderr.
90
+ STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
91
+ if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=dropped reason=pdf-link-conformance'; then
92
+ echo "PASS: I: block emits op=bypass jobId=dropped"; PASS=$((PASS + 1))
93
+ else
94
+ echo "FAIL: I: op=bypass line absent (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
95
+ fi
96
+
97
+ echo "──────── quote-render-pdf-conformance test summary ────────"
98
+ echo "PASS=$PASS FAIL=$FAIL"
99
+ [[ "$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
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # quote-render-pdf-conformance — PostToolUse gate on browser-pdf-save.
3
+ #
4
+ # The companion to the PreToolUse quote-render-gate: that gate checks the
5
+ # rendered HTML on the deterministic path (via the render receipt) before the
6
+ # PDF is printed; the HTML the tool gated cannot yet be a PDF at PreToolUse, so
7
+ # the two PDF-only conformance checks run here, once the PDF exists.
8
+ #
9
+ # For a gated client-quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf
10
+ # or ...-breakdown.pdf) whose render receipt names the source HTML, it asserts:
11
+ # 1. the PDF's /URI annotation count >= the source HTML's absolute-link count
12
+ # (a print that dropped links — a rasterised or flattened copy — carries
13
+ # fewer /URI than the HTML had anchors);
14
+ # 2. every embedded https:// anchor in the source HTML answers 200 (a pending
15
+ # redeploy that would 404 the Sign-Online link is caught before the PDF is
16
+ # sent).
17
+ # The /URI-count check is offline and runs first; the network fetch runs only
18
+ # when the count check passes, so a link-loss regression never depends on the
19
+ # network being up.
20
+ #
21
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
22
+ # uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
23
+ # written): the no-receipt case is already the PreToolUse gate's to block, and
24
+ # browser-pdf-save is general-purpose, so failing open here bricks no unrelated
25
+ # PDF. No task numbers or internal refs in any operator-visible string.
26
+
27
+ set -uo pipefail
28
+
29
+ if [ -t 0 ]; then exit 0; fi
30
+ INPUT=$(cat)
31
+ [ -z "$INPUT" ] && exit 0
32
+
33
+ RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
34
+ import os, json, re, sys, urllib.request
35
+
36
+ try:
37
+ d = json.loads(os.environ["INPUT"])
38
+ except Exception:
39
+ sys.exit(0) # uninspectable envelope -> fail open
40
+
41
+ tool = d.get("tool_name", "") or ""
42
+ if not tool.endswith("browser-pdf-save"):
43
+ sys.exit(0)
44
+ path = (d.get("tool_input", {}) or {}).get("path", "") or ""
45
+ if not path:
46
+ sys.exit(0)
47
+ norm = os.path.normpath(path)
48
+ m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
49
+ if not m:
50
+ sys.exit(0) # not a gated client-quote artefact
51
+ jobid = m.group(1)
52
+ if "/" in jobid or ".." in jobid:
53
+ sys.exit(0) # the PreToolUse gate owns bad-jobid; here fail open
54
+ variant = m.group(2) # "-breakdown" or None
55
+
56
+ base = os.environ["BASE"]
57
+ receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")
58
+ try:
59
+ receipt = json.load(open(receipt_path))
60
+ except Exception:
61
+ sys.exit(0) # no receipt -> PreToolUse gate already blocks; fail open
62
+
63
+ filed = receipt.get("filed", {}) or {}
64
+ html_path = filed.get("breakdown" if variant else "quote", "")
65
+ if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
66
+ sys.exit(0) # nothing to compare against -> fail open
67
+
68
+ html = open(html_path, encoding="utf-8", errors="replace").read()
69
+ # Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
70
+ # mailto hrefs. Fragment and relative anchors do not, so they are not counted.
71
+ uri_anchors = re.findall(r'href="((?:https?|mailto):[^"]*)"', html, re.I)
72
+ https_anchors = [u for u in uri_anchors if u.lower().startswith("https:")]
73
+
74
+ # /URI occurrences in the printed PDF. This is the same primitive the e-sign
75
+ # stamp gate uses (grep -a -c '/URI'); on real browser-pdf-save output it was
76
+ # measured to carry one greppable /URI per surviving link (base PDFs counted 4
77
+ # to 6, one per anchor), so the count is a faithful per-link tally, not stream-
78
+ # compressed away. A rasterised or flattened reprint drops to a lower count.
79
+ uri_count = open(path, "rb").read().count(b"/URI")
80
+ if uri_count < len(uri_anchors):
81
+ print("BLOCK|%s|uri-count pdf=%d html_links=%d" % (jobid, uri_count, len(uri_anchors)))
82
+ sys.exit(0)
83
+
84
+ for u in https_anchors:
85
+ try:
86
+ with urllib.request.urlopen(u, timeout=6) as r:
87
+ code = r.status
88
+ except Exception as e:
89
+ code = getattr(e, "code", 0) or 0
90
+ if code != 200:
91
+ print("BLOCK|%s|link %s -> %s" % (jobid, u, code))
92
+ sys.exit(0)
93
+
94
+ sys.exit(0)
95
+ PY
96
+ )
97
+
98
+ case "$RESULT" in
99
+ BLOCK\|*)
100
+ rest=${RESULT#BLOCK|}
101
+ jobid=${rest%%|*}
102
+ detail=${rest#*|}
103
+ echo "[quote-render] op=bypass jobId=$jobid reason=pdf-link-conformance detail=$detail" >&2
104
+ echo "Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live ($detail). 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." >&2
105
+ exit 2
106
+ ;;
107
+ esac
108
+ exit 0
@@ -110,12 +110,24 @@ Two further clauses bind the Output contract itself. **Recency**, universal: a t
110
110
  claim carries the date it was observed, and a date the agent cannot see is one it flags, never
111
111
  one it infers. **Corroboration**, for agents whose deliverable is a pattern, a theme, a trend, a
112
112
  signal: the agent names the independent sources behind it and does not report one as established
113
- on a single occurrence. Corroboration is authoring guidance enforced by review, not by the gate,
114
- because the subset it applies to cannot be detected statically.
115
-
116
- `agent-grounding-drift.test.ts` fails if any shipped prompt carries either sentence anything
117
- other than exactly once, and fails if the number of prompts it inspected is not the number it
118
- expects. A new agent that skips them does not ship.
113
+ on a single occurrence.
114
+
115
+ Corroboration binds only that subset, and the subset cannot be read out of a prompt's text, so
116
+ every prompt declares it. The flat specialists carry `pattern_deliverable: true` or
117
+ `pattern_deliverable: false` as a frontmatter key; the role `IDENTITY.md` files, which have no
118
+ frontmatter and are injected raw, carry the same marker as a trailing HTML comment
119
+ `<!-- pattern_deliverable: true -->`. Absent is a gate failure, not a default. A prompt declaring
120
+ true carries this sentence, character for character, as a third paragraph of its `## Grounding`
121
+ section; a prompt declaring false does not carry it at all:
122
+
123
+ > Where what you report is a pattern, a theme, a trend, or a signal, name the independent occurrences behind it; one occurrence is a single observation you report as such, never a pattern you assert.
124
+
125
+ `agent-grounding-drift.test.ts` fails if any shipped prompt carries the provenance or
126
+ person-attribute sentence anything other than exactly once, if a prompt does not declare
127
+ `pattern_deliverable` exactly once, if a true-declaring prompt carries the corroboration sentence
128
+ anything other than exactly once, if a false-declaring prompt carries it at all, and if the
129
+ number of prompts it inspected is not the number it expects. The marker and the sentence cannot
130
+ drift apart, and a new agent that skips any of the three does not ship.
119
131
 
120
132
  ## Skills owned
121
133
 
@@ -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
@@ -477,7 +477,15 @@ Dispatch runs **only** inside a live operator session on the account's own devic
477
477
 
478
478
  2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
479
479
 
480
- - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it all with a pure PDF library (**`pdf-lib`**, run as a small Node script; pick another pure library if the account standardises on one, but it must add no browser and no network call):
480
+ - **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it, all with a PDF library that loads the base and copies its pages through intact (**`pdf-lib`**, run as a small Node script). The stamp copies existing pages; it never renders them to an image. Rasterising the base (ghostscript to PNG, or any image-then-redraw rebuild) discards every link annotation and all selectable text, so the signer receives a flattened copy with dead links; it is prohibited whatever is or is not installed. A substitute for pdf-lib is allowed only if it imports existing PDF pages: pypdf, qpdf, or pdftk. A canvas-only generator such as reportlab cannot import a base and is not a substitute. Whatever the library, it adds no browser and no network call.
481
+
482
+ **Pre-flight: the page-copying library must resolve before any stamp runs.** A stamp attempted without it is the exact history this guards against, where a prior session met the missing library by rasterising instead of installing one:
483
+
484
+ ```bash
485
+ node -e 'import("pdf-lib").then(()=>console.log("ok"),e=>{console.error("pdf-lib missing: npm i pdf-lib at the account directory root");process.exit(1)})'
486
+ ```
487
+
488
+ On failure the one remediation is that install; a fallback that renders pages to images is never one. The stamp script itself:
481
489
 
482
490
  ```js
483
491
  // stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
@@ -509,6 +517,8 @@ Dispatch runs **only** inside a live operator session on the account's own devic
509
517
 
510
518
  The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
511
519
 
520
+ **Post-stamp gate: prove fidelity before any send.** For both `base.pdf` and the stamped copy, count link annotations with `grep -a -c '/URI'` and measure page-1 text extent with ghostscript text extraction (`gs -q -dNOPAUSE -dBATCH -sDEVICE=txtwrite -dFirstPage=1 -dLastPage=1 -o - <file> | wc -c`). The stamped copy passes only when its `/URI` count and its page-1 text extent are each at least the base's. On either regression the stamp rasterised or otherwise flattened the base, so delete the stamped copy, skip both sends, and leave the row `swept = 0` so the next sweep re-surfaces it. This gate converts a link-stripped copy from a no-event failure (nothing logs until a signer clicks a dead link) into a rejection at stamp time. Record the base and stamped `/URI` counts alongside every dispatch, pass or fail, so a fidelity regression is a number rather than a complaint.
521
+
512
522
  - **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
513
523
  - once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
514
524
  - once to the **signer's captured `email`** — this copy is the **statutory record provided to the signer**, the evidentiary copy the legal basis (intro) relies on.
@@ -18,7 +18,7 @@ set -u
18
18
 
19
19
  BIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
20
20
  SCRIPT="$BIN_DIR/portal-brand-css.mjs"
21
- TEMPLATE="$BIN_DIR/../skills/data-portal/template/public/portal.css"
21
+ TEMPLATE="$BIN_DIR/../skills/data-portal/template/portal.css"
22
22
  [ -f "$SCRIPT" ] || { echo "FAIL: $SCRIPT not found" >&2; exit 1; }
23
23
  [ -f "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE not found" >&2; exit 1; }
24
24
  # Case 2 strips the :root block with perl and diffs what is left. Without perl
@@ -198,6 +198,28 @@ export async function pushAccount(opts) {
198
198
  const current = Number(state[0]?.currentGeneration ?? 0)
199
199
  const next = current + 1
200
200
 
201
+ // Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
202
+ // INSERT — leaves rows at `next` while the pointer stays at `current`. Every
203
+ // later run recomputes the same `next`, and its first colliding INSERT then
204
+ // hits UNIQUE(accountId, relPath, generation), so the index freezes at the
205
+ // stale generation until an operator hand-clears the orphans. Clearing `next`
206
+ // before staging lets a partial stage self-heal on the next cycle. Guarded on
207
+ // a non-zero count so the DELETE never precedes the flip on a clean cycle and
208
+ // the log line always means a recovered wedge. The count comes from a SELECT
209
+ // because the D1 client returns rows, not the affected-row meta.
210
+ const orphan = await client.query(
211
+ 'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
212
+ [accountId, next],
213
+ )
214
+ const orphanCount = Number(orphan[0]?.n ?? 0)
215
+ if (orphanCount > 0) {
216
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
217
+ accountId,
218
+ next,
219
+ ])
220
+ log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
221
+ }
222
+
201
223
  for (const row of rows) {
202
224
  await client.query(
203
225
  'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +