@rubytech/create-maxy-code 0.1.573 → 0.1.574

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 (127) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/admins-write/__tests__/index.test.ts +97 -0
  3. package/payload/platform/lib/admins-write/dist/index.d.ts +12 -0
  4. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/admins-write/dist/index.js +27 -5
  6. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  7. package/payload/platform/lib/admins-write/src/index.ts +35 -5
  8. package/payload/platform/lib/admins-write/vitest.config.ts +14 -0
  9. package/payload/platform/lib/dispatch-read/dist/session.d.ts.map +1 -1
  10. package/payload/platform/lib/dispatch-read/dist/session.js +19 -4
  11. package/payload/platform/lib/dispatch-read/dist/session.js.map +1 -1
  12. package/payload/platform/lib/dispatch-read/src/__tests__/session.test.ts +91 -0
  13. package/payload/platform/lib/dispatch-read/src/session.ts +25 -6
  14. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  15. package/payload/platform/plugins/admin/PLUGIN.md +8 -3
  16. package/payload/platform/plugins/admin/hooks/__tests__/html-structure-gate.test.sh +264 -0
  17. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +122 -24
  18. package/payload/platform/plugins/admin/hooks/__tests__/specialist-dispatch-gate.test.sh +158 -0
  19. package/payload/platform/plugins/admin/hooks/html-structure-gate.sh +165 -0
  20. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +111 -19
  21. package/payload/platform/plugins/admin/hooks/specialist-dispatch-gate.sh +159 -0
  22. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts +27 -0
  23. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts.map +1 -0
  24. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js +29 -0
  25. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js.map +1 -0
  26. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts +11 -0
  27. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts.map +1 -0
  28. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js +20 -0
  29. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js.map +1 -0
  30. package/payload/platform/plugins/admin/mcp/dist/index.js +133 -1
  31. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  32. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +32 -2
  33. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +157 -46
  34. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  35. package/payload/platform/plugins/docs/references/admin-ui.md +71 -31
  36. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +38 -13
  37. package/payload/platform/plugins/docs/references/internals.md +6 -0
  38. package/payload/platform/plugins/docs/references/outlook-guide.md +31 -1
  39. package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
  40. package/payload/platform/plugins/outlook/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts +2 -0
  42. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts.map +1 -0
  43. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js +177 -0
  44. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js.map +1 -0
  45. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts +2 -0
  46. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts.map +1 -0
  47. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js +130 -0
  48. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js.map +1 -0
  49. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts +2 -0
  50. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts.map +1 -0
  51. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js +162 -0
  52. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js.map +1 -0
  53. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts +2 -0
  54. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts.map +1 -0
  55. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js +168 -0
  56. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js.map +1 -0
  57. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts +64 -0
  58. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts.map +1 -0
  59. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js +110 -0
  60. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js.map +1 -0
  61. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts +119 -0
  62. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts.map +1 -0
  63. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js +0 -0
  64. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js.map +1 -0
  65. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts +8 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js +247 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.d.ts.map +1 -1
  70. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js +10 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js.map +1 -1
  72. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  73. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +21 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  75. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts +16 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
  77. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +5 -1
  78. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
  79. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +4 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
  81. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +2 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
  83. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
  84. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +24 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
  86. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
  87. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +2 -1
  88. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +29 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
  92. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts +2 -0
  93. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts.map +1 -0
  94. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js +21 -0
  95. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js.map +1 -0
  96. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts +13 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js +10 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +54 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  102. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts +32 -0
  103. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts.map +1 -0
  104. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js +144 -0
  105. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js.map +1 -0
  106. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  107. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  108. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  109. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts +44 -0
  110. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts.map +1 -0
  111. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js +185 -0
  112. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js.map +1 -0
  113. package/payload/platform/services/claude-session-manager/dist/index.js +125 -0
  114. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  115. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -1
  116. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +7 -0
  117. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -1
  118. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts +42 -0
  119. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts.map +1 -0
  120. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js +254 -0
  121. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js.map +1 -0
  122. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts +92 -0
  123. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts.map +1 -0
  124. package/payload/platform/services/claude-session-manager/dist/specialist-census.js +227 -0
  125. package/payload/platform/services/claude-session-manager/dist/specialist-census.js.map +1 -0
  126. package/payload/platform/templates/account-settings.json +8 -4
  127. package/payload/server/server.js +465 -361
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for specialist-dispatch-gate.sh.
3
+ #
4
+ # An account's own specialists own their tools: the admin does not do their work
5
+ # by hand. The gate refuses a main-conversation call to a tool one of the
6
+ # account's authored cards declares, and names the specialist to dispatch.
7
+ #
8
+ # Two narrowings are load-bearing and both are asserted here. Only cards the
9
+ # ACCOUNT authored count — provisioning copies every shipped template in, and one
10
+ # of them declares roughly 130 tools including the whole email surface, so
11
+ # reading every card would refuse the admin nearly its entire seat. And native
12
+ # tools are never gated, because every card declares some of them.
13
+ #
14
+ # Caller identity is `agent_type`, which Claude Code sets when a hook fires
15
+ # inside a subagent and leaves empty in the main conversation.
16
+ #
17
+ # Covers:
18
+ # A. main conversation, authored tool → BLOCKED, names the specialist
19
+ # B. same call inside a subagent → ALLOWED (never filters a subagent)
20
+ # C. a native tool the card also declares → ALLOWED
21
+ # D. a tool only a shipped template declares → ALLOWED
22
+ # E. a tool only a premium bundle declares → ALLOWED
23
+ # F. an account with no authored cards → ALLOWED
24
+ # G. empty stdin → ALLOWED
25
+ # H. unparseable envelope → ALLOWED
26
+ # I. an unreadable specialists dir → ALLOWED, op=degraded on stderr
27
+ # J. a card under the ACCOUNT's own plugin root → BLOCKED (still the account's own)
28
+ # K. the refuse line carries the account and the tool
29
+ # L. the native set matches CC_NATIVE_TOOLS in the session manager
30
+
31
+ set -uo pipefail
32
+ HOOKS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
33
+ GATE="$HOOKS_DIR/specialist-dispatch-gate.sh"
34
+ DRIFT_SOURCE="$(cd "$HOOKS_DIR/../../../services/claude-session-manager/src" && pwd)/specialist-drift.ts"
35
+
36
+ PASS=0
37
+ FAIL=0
38
+ ok() { PASS=$((PASS + 1)); echo " ok $1"; }
39
+ bad() { FAIL=$((FAIL + 1)); echo " FAIL $1"; }
40
+
41
+ ROOT="$(mktemp -d)"
42
+ trap 'chmod -R u+rwX "$ROOT" 2>/dev/null; rm -rf "$ROOT"' EXIT
43
+
44
+ ACCT="$ROOT/2078cb54aaaa"
45
+ PLATFORM="$ROOT/platform"
46
+ PREMIUM="$ROOT/premium-plugins"
47
+ mkdir -p "$ACCT/specialists/agents" \
48
+ "$PLATFORM/templates/specialists/agents" \
49
+ "$PLATFORM/plugins" \
50
+ "$PREMIUM/sitedesk/agents"
51
+
52
+ card() { # dir file name tools
53
+ mkdir -p "$1"
54
+ printf -- '---\nname: %s\ndescription: "d"\ntools: %s\n---\nbody\n' "$3" "$4" > "$1/$2"
55
+ }
56
+
57
+ # The account's own clerk: no copy in any shipped root.
58
+ card "$ACCT/specialists/agents" "glsmith--email-clerk.md" "email-clerk" \
59
+ "Read, Bash, mcp__plugin_email_email__email-draft"
60
+ # A shipped template, copied into the account by provisioning.
61
+ card "$ACCT/specialists/agents" "personal-assistant.md" "personal-assistant" \
62
+ "mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin"
63
+ card "$PLATFORM/templates/specialists/agents" "personal-assistant.md" "personal-assistant" \
64
+ "mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin"
65
+ # A premium bundle card, also copied in.
66
+ card "$ACCT/specialists/agents" "sitedesk--quoter.md" "quoter" \
67
+ "mcp__plugin_sitedesk-job_sitedesk-job__quote-render"
68
+ card "$PREMIUM/sitedesk/agents" "sitedesk--quoter.md" "quoter" \
69
+ "mcp__plugin_sitedesk-job_sitedesk-job__quote-render"
70
+ # An authored card whose durable copy lives under the ACCOUNT's own plugin root.
71
+ card "$ACCT/specialists/agents" "glsmith--order-clerk.md" "order-clerk" \
72
+ "mcp__plugin_work_work__work-create"
73
+ card "$ACCT/plugins/glsmith/agents" "glsmith--order-clerk.md" "order-clerk" \
74
+ "mcp__plugin_work_work__work-create"
75
+
76
+ envelope() { printf '{"tool_name":"%s","agent_type":%s,"tool_input":{}}' "$1" "$2"; }
77
+
78
+ # fire <cwd> <json> — sets RC and ERR
79
+ fire() {
80
+ ERR="$(cd "$1" && printf '%s' "$2" | PLATFORM_ROOT="$PLATFORM" bash "$GATE" 2>&1 >/dev/null)"
81
+ RC=$?
82
+ }
83
+
84
+ # A
85
+ fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '""')"
86
+ if [ "$RC" = 2 ] && printf '%s' "$ERR" | grep -q 'email-clerk'; then
87
+ ok "A blocks the main conversation and names the specialist"
88
+ else bad "A rc=$RC err=$ERR"; fi
89
+
90
+ # B
91
+ fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '"specialists:email-clerk"')"
92
+ if [ "$RC" = 0 ]; then ok "B allows the same call inside a subagent"; else bad "B rc=$RC err=$ERR"; fi
93
+
94
+ # C
95
+ fire "$ACCT" "$(envelope Bash '""')"
96
+ if [ "$RC" = 0 ]; then ok "C allows a native tool the card declares"; else bad "C rc=$RC err=$ERR"; fi
97
+
98
+ # D
99
+ fire "$ACCT" "$(envelope mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin '""')"
100
+ if [ "$RC" = 0 ]; then ok "D allows a tool only a shipped template declares"; else bad "D rc=$RC err=$ERR"; fi
101
+
102
+ # E
103
+ fire "$ACCT" "$(envelope mcp__plugin_sitedesk-job_sitedesk-job__quote-render '""')"
104
+ if [ "$RC" = 0 ]; then ok "E allows a tool only a premium bundle declares"; else bad "E rc=$RC err=$ERR"; fi
105
+
106
+ # F
107
+ EMPTY="$ROOT/empty"
108
+ mkdir -p "$EMPTY/specialists/agents"
109
+ fire "$EMPTY" "$(envelope mcp__plugin_email_email__email-draft '""')"
110
+ if [ "$RC" = 0 ]; then ok "F allows on an account with no authored cards"; else bad "F rc=$RC err=$ERR"; fi
111
+
112
+ # G
113
+ fire "$ACCT" ""
114
+ if [ "$RC" = 0 ]; then ok "G allows on empty stdin"; else bad "G rc=$RC err=$ERR"; fi
115
+
116
+ # H
117
+ fire "$ACCT" '{not json'
118
+ if [ "$RC" = 0 ]; then ok "H allows on an unparseable envelope"; else bad "H rc=$RC err=$ERR"; fi
119
+
120
+ # I — failing open is fine; failing open SILENTLY is the fault.
121
+ BROKEN="$ROOT/brokenacct"
122
+ mkdir -p "$BROKEN/specialists/agents"
123
+ chmod 000 "$BROKEN/specialists/agents"
124
+ fire "$BROKEN" "$(envelope mcp__plugin_email_email__email-draft '""')"
125
+ chmod 755 "$BROKEN/specialists/agents"
126
+ if [ "$RC" = 0 ] && printf '%s' "$ERR" | grep -q 'op=degraded'; then
127
+ ok "I fails open and says so"
128
+ else bad "I rc=$RC err=$ERR"; fi
129
+
130
+ # J
131
+ fire "$ACCT" "$(envelope mcp__plugin_work_work__work-create '""')"
132
+ if [ "$RC" = 2 ] && printf '%s' "$ERR" | grep -q 'order-clerk'; then
133
+ ok "J blocks a card whose durable copy is under the account plugin root"
134
+ else bad "J rc=$RC err=$ERR"; fi
135
+
136
+ # K
137
+ fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '""')"
138
+ if printf '%s' "$ERR" | grep -q 'op=refuse account=2078cb54 tool=mcp__plugin_email_email__email-draft specialist=email-clerk'; then
139
+ ok "K the refuse line carries the account, the tool and the specialist"
140
+ else bad "K err=$ERR"; fi
141
+
142
+ # L — the hook cannot import the manager's TypeScript, so it mirrors
143
+ # CC_NATIVE_TOOLS as a python literal. A mirror that drifts silently un-gates or
144
+ # over-gates, so the two sets are compared rather than trusted.
145
+ TS_SET="$(sed -n '/export const CC_NATIVE_TOOLS/,/^\])/p' "$DRIFT_SOURCE" \
146
+ | grep -oE "'[A-Za-z]+'" | tr -d "'" | sort -u)"
147
+ SH_SET="$(sed -n '/^NATIVE = {/,/^}/p' "$GATE" \
148
+ | grep -oE '"[A-Za-z]+"' | tr -d '"' | sort -u)"
149
+ if [ "$TS_SET" = "$SH_SET" ]; then
150
+ ok "L the native set matches CC_NATIVE_TOOLS"
151
+ else
152
+ bad "L native sets differ:"
153
+ diff <(printf '%s\n' "$TS_SET") <(printf '%s\n' "$SH_SET") | sed 's/^/ /'
154
+ fi
155
+
156
+ echo "──────── specialist-dispatch-gate summary ────────"
157
+ echo "pass=$PASS fail=$FAIL"
158
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bash
2
+ # html-structure-gate — PreToolUse gate on Write / Edit / MultiEdit.
3
+ #
4
+ # Refuses a write that would produce an HTML document with a <style> element
5
+ # opened inside another one, or with more openers than closers.
6
+ #
7
+ # The fault is not cosmetic. HTML parses style content as raw text, so the FIRST
8
+ # </style> closes the outer element: everything after it becomes body content and
9
+ # renders as visible text, and the rule immediately after the stray opener is
10
+ # eaten as part of an invalid selector. One fault, two symptoms — a leaked
11
+ # stylesheet above the header and an unstyled box further down the same page.
12
+ #
13
+ # The detector has three rules, and the same three are implemented in the
14
+ # session-manager census that walks what is already on disk:
15
+ # 1. HTML comments are stripped first, each replaced by its own newlines, so a
16
+ # <style> inside <!-- … --> is not scanned and line numbers do not shift.
17
+ # That comment case is real: two account templates carry one and are clean,
18
+ # and a comment-blind detector reports both.
19
+ # 2. <style…> and </style…> are walked case-insensitively, tracking depth. An
20
+ # opener seen at depth > 0 is the fault.
21
+ # 3. At end of input, openers must equal closers; an unmatched opener is
22
+ # reported at its own line.
23
+ #
24
+ # It walks the document the call would PRODUCE, not the fragment it carries. For
25
+ # Write that is `content`; for Edit and MultiEdit the on-disk file is read and
26
+ # the replacements applied in memory first. Walking a fragment cannot work: rule
27
+ # 3 would refuse every partial edit, and a nested opener whose outer <style> is
28
+ # already on disk would pass.
29
+ #
30
+ # Scope is the whole account with an exclusion list, never a bucket list: every
31
+ # .html/.htm resolving inside the account dir except under node_modules/,
32
+ # .wrangler/, .tool/ and .trash/. A bucket list is what let the live document
33
+ # through — it sat under sites/<project>/public/, which no quote bucket covers.
34
+ # The account dir is this hook's cwd, as in fs-schema-guard.sh.
35
+ #
36
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
37
+ # uninspectable envelope — tty, empty stdin, unreadable target, unparseable edit.
38
+ # A gate that bricks writes on a parse failure is worse than the fault it catches.
39
+ #
40
+ # The observability line is a direct append to the resolved server.log, not an
41
+ # echo: a hook's stderr reaches no sink on an allow, so a stderr-only line would
42
+ # be an audit nothing reads. No task numbers or internal refs in operator text.
43
+
44
+ set -uo pipefail
45
+
46
+ # No envelope (tty or empty stdin) → allow: cannot inspect, must not brick writes.
47
+ if [ -t 0 ]; then exit 0; fi
48
+ INPUT=$(cat)
49
+ [ -z "$INPUT" ] && exit 0
50
+
51
+ VERDICT=$(INPUT="$INPUT" ACCT="$PWD" python3 - <<'PY' 2>/dev/null || true
52
+ import os, re, json, sys
53
+
54
+ COMMENT = re.compile(r"<!--.*?-->", re.S)
55
+ CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
56
+ TAG = re.compile(r"<\s*(/)?\s*style\b[^>]*>", re.I)
57
+ EXCLUDED = ("node_modules", ".wrangler", ".tool", ".trash")
58
+
59
+
60
+ def fault_line(s):
61
+ """Line of the first nested or unmatched <style> opener, else None."""
62
+ t = COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), s)
63
+ spans = [(m.start(), m.end()) for m in CSS_COMMENT.finditer(t)]
64
+ depth = 0
65
+ open_line = 0
66
+ for m in TAG.finditer(t):
67
+ line = t.count("\n", 0, m.start()) + 1
68
+ if m.group(1):
69
+ if depth > 0:
70
+ depth -= 1
71
+ elif depth > 0:
72
+ # A CSS comment protects an opener but never a closer. Style content
73
+ # is raw text to the HTML parser, so </style> terminates the element
74
+ # wherever it appears; an opener is never a parse event there, only
75
+ # text the CSS parser reads as an invalid selector — and inside a
76
+ # comment it is discarded, so nothing is eaten.
77
+ if any(a <= m.start() < b for a, b in spans):
78
+ continue
79
+ return line
80
+ else:
81
+ depth = 1
82
+ open_line = line
83
+ return open_line if depth > 0 else None
84
+
85
+
86
+ try:
87
+ d = json.loads(os.environ["INPUT"])
88
+ except Exception:
89
+ sys.exit(0)
90
+ if (d.get("tool_name", "") or "") not in ("Write", "Edit", "MultiEdit"):
91
+ sys.exit(0)
92
+ tool = d["tool_name"]
93
+ ti = d.get("tool_input", {}) or {}
94
+ target = ti.get("file_path") or ""
95
+ if not target:
96
+ sys.exit(0)
97
+
98
+ acct = os.path.realpath(os.environ["ACCT"])
99
+ ab = os.path.realpath(target if os.path.isabs(target) else os.path.join(acct, target))
100
+ if not ab.startswith(acct + os.sep):
101
+ sys.exit(0)
102
+ rel = os.path.relpath(ab, acct)
103
+ if os.path.splitext(rel)[1].lower() not in (".html", ".htm"):
104
+ sys.exit(0)
105
+ if any(p in EXCLUDED for p in rel.split(os.sep)):
106
+ sys.exit(0)
107
+
108
+ if tool == "Write":
109
+ produced = ti.get("content")
110
+ if not isinstance(produced, str):
111
+ sys.exit(0)
112
+ else:
113
+ try:
114
+ with open(ab, encoding="utf-8", errors="replace") as fh:
115
+ produced = fh.read()
116
+ except Exception:
117
+ sys.exit(0)
118
+ edits = ti.get("edits") if tool == "MultiEdit" else [ti]
119
+ if not isinstance(edits, list):
120
+ sys.exit(0)
121
+ for e in edits:
122
+ if not isinstance(e, dict):
123
+ sys.exit(0)
124
+ old, new = e.get("old_string"), e.get("new_string")
125
+ if not isinstance(old, str) or not isinstance(new, str):
126
+ sys.exit(0)
127
+ produced = produced.replace(old, new) if e.get("replace_all") else produced.replace(old, new, 1)
128
+
129
+ line = fault_line(produced)
130
+ if line is None:
131
+ sys.exit(0)
132
+ print(line)
133
+ print(json.dumps(rel))
134
+ PY
135
+ )
136
+
137
+ [ -z "$VERDICT" ] && exit 0
138
+ LINE=$(printf '%s' "$VERDICT" | sed -n 1p)
139
+ QREL=$(printf '%s' "$VERDICT" | sed -n 2p)
140
+ [ -z "$LINE" ] && exit 0
141
+
142
+ # Sink ladder, as quote-render-pdf-conformance.sh: LOG_DIR is stamped on the
143
+ # rc-spawn path, CLAUDE_SESSION_MANAGER_PERSIST_DIR is on the systemd unit and
144
+ # resolves to the same directory, the account's own logs dir is the last resort.
145
+ #
146
+ # There is deliberately no hard-coded brand fallback. Four brands are co-resident
147
+ # on one box, each with its own ~/.<brand>/logs, so a literal would file this
148
+ # account's path in a SIBLING brand's server.log. When no rung resolves, the line
149
+ # is dropped: the refusal still blocks and still reaches the agent on stderr.
150
+ LOGD=""
151
+ if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR}" ]; then
152
+ LOGD="$LOG_DIR"
153
+ elif [ -n "${CLAUDE_SESSION_MANAGER_PERSIST_DIR:-}" ] && [ -d "${CLAUDE_SESSION_MANAGER_PERSIST_DIR}/logs" ]; then
154
+ LOGD="$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs"
155
+ elif [ -d "$PWD/logs" ]; then
156
+ LOGD="$PWD/logs"
157
+ fi
158
+ if [ -n "$LOGD" ]; then
159
+ ID8=$(basename "$PWD" | cut -c1-8)
160
+ printf '[html-structure-gate] op=refuse account=%s file=%s line=%s\n' \
161
+ "$ID8" "$QREL" "$LINE" >>"$LOGD/server.log" 2>/dev/null || true
162
+ fi
163
+
164
+ echo "Blocked: this document opens a <style> element inside another one (line $LINE). The browser closes the outer element at the first </style>, so the rest of the stylesheet renders as visible text on the page and the rule after the stray opener is discarded. Put the block in the body as its own <style> element, or merge its rules into the one already open." >&2
165
+ exit 2
@@ -4,20 +4,37 @@
4
4
  # The companion to the PreToolUse quote-render-gate: that gate checks the
5
5
  # rendered HTML on the deterministic path (via the render receipt) before the
6
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.
7
+ # the PDF-only conformance checks run here, once the PDF exists.
8
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
9
+ # PAGE CHROME receipt-free and path-agnostic, so it also covers template-seeded
10
+ # documents (budget estimates, valuations, works orders) that never pass through
11
+ # the quote renderer. Each check gates only a document that DECLARES the thing it
12
+ # checks, so a brochure is never touched:
13
+ # 1. a running header on every physical page, when the source declares a text
14
+ # margin box. Chrome decides pagination at print time, so a header placed
15
+ # next to a hand-declared page break misses every bleed page.
16
+ # 2. that header's image no wider than a third of the page, when the source
17
+ # declares an image margin box. Chromium paints a margin box's content:url()
18
+ # at intrinsic pixel size and does NOT scale it to the declared box.
19
+ # Both are offline, and both report before either exits, so one print surfaces
20
+ # both faults rather than the second appearing only after the first is reprinted.
21
+ #
22
+ # LINKS — for a gated client-quote PDF (memory/users/<phone>/documents/
23
+ # quote-<jobId>.pdf or ...-breakdown.pdf) whose render receipt names the source:
24
+ # 3. the PDF's /URI annotation count >= the source HTML's absolute-link count
12
25
  # (a print that dropped links — a rasterised or flattened copy — carries
13
26
  # fewer /URI than the HTML had anchors);
14
- # 2. every embedded https:// anchor in the source HTML answers 200 (a pending
27
+ # 4. every embedded https:// anchor in the source HTML answers 200 (a pending
15
28
  # redeploy that would 404 the Sign-Online link is caught before the PDF is
16
29
  # sent).
17
30
  # The /URI-count check is offline and runs first; the network fetch runs only
18
31
  # when the count check passes, so a link-loss regression never depends on the
19
32
  # network being up.
20
33
  #
34
+ # Every path that inspects nothing emits `op=skip reason=…`: a silent return is
35
+ # indistinguishable from a gate that never ran, which is the state an account
36
+ # with a stale PostToolUse matcher is actually in.
37
+ #
21
38
  # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
22
39
  # uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
23
40
  # written): the no-receipt case is already the PreToolUse gate's to block, and
@@ -184,16 +201,73 @@ for p in range(first, pages + 1):
184
201
  missing.append(p)
185
202
 
186
203
  checked = pages - first + 1
204
+ print("LOG|op=pdf-header-conformance file=%s pages=%d pagesChecked=%d pagesHeaderless=%d"
205
+ % (base, pages, checked, len(missing)))
187
206
  if missing:
188
- print("LOG|op=pdf-header-conformance file=%s pages=%d pagesChecked=%d pagesHeaderless=%d"
189
- % (base, pages, checked, len(missing)))
190
- out("HBLOCK|%s|pagesHeaderless=%d pages=%s"
191
- % (base, len(missing), ",".join(str(x) for x in missing)))
192
- out("LOG|op=pdf-header-conformance file=%s pages=%d pagesChecked=%d pagesHeaderless=0"
193
- % (base, pages, checked))
207
+ print("HBLOCK|%s|pagesHeaderless=%d pages=%s"
208
+ % (base, len(missing), ",".join(str(x) for x in missing)))
209
+
210
+ # --- running-image geometry -------------------------------------------------
211
+ # Chromium paints a page-margin box's content:url() at INTRINSIC pixel size (one
212
+ # image px to one CSS px at 96 px/in) and does NOT scale it to the box's declared
213
+ # width/height, so a full-resolution logo covers most of the page on every page
214
+ # the box paints. Measured 2026-08-05 on a real print: a 700x252 asset in a
215
+ # declared 34mm box rendered 525.0 pt wide on a 594.96 pt A4 page, 88%.
216
+ #
217
+ # The assertion is scoped to the RUNNING image — the object present on every
218
+ # checked page, which is what a running header is by construction — never to
219
+ # every image. A content photo legitimately runs wider: measured across three
220
+ # real quotes, running headers occupy 9.0% to 18.9% of page width while
221
+ # single-page content photos occupy 38.7% to 77.2%, so a whole-page threshold
222
+ # blocks legitimate documents while the recurrence-scoped one separates the
223
+ # correct case from the defect by more than four to one.
224
+ #
225
+ # This runs after the header verdict rather than instead of it, so one print
226
+ # reports a headerless page AND an oversized logo together. Both are offline, so
227
+ # the file's offline-before-network ordering still holds.
228
+ if re.search(r'@top-(?:left|center|right)\s*\{[^}]*?content\s*:\s*url\(', doc, re.S) and checked >= 2:
229
+ try:
230
+ listing = subprocess.run(["pdfimages", "-list", pdf],
231
+ capture_output=True, text=True, timeout=30).stdout
232
+ except Exception:
233
+ listing = ""
234
+ if not listing:
235
+ out("LOG|op=pdf-image-geometry file=%s pagesChecked=0 reason=no-poppler" % base)
236
+ # Columns, zero-based: 0 page, 3 width, 10 object, 12 x-ppi. A row whose
237
+ # object or ppi is not numeric (an inline image) is skipped rather than
238
+ # guessed at.
239
+ per = {}
240
+ for row in listing.splitlines()[2:]:
241
+ f = row.split()
242
+ if len(f) < 13:
243
+ continue
244
+ try:
245
+ pg, wpx, obj, ppi = int(f[0]), int(f[3]), f[10], float(f[12])
246
+ except ValueError:
247
+ continue
248
+ if pg < first or pg > pages or ppi <= 0:
249
+ continue
250
+ seen = per.setdefault(obj, {})
251
+ seen[pg] = max(seen.get(pg, 0.0), wpx / ppi * 72.0)
252
+ limit = pw / 3.0
253
+ running = {o: max(v.values()) for o, v in per.items() if len(v) == checked}
254
+ widest = max(running.values()) if running else 0.0
255
+ over = sorted(w for w in running.values() if w > limit)
256
+ # Logged whether or not it fails: this is the line that decides the verdict.
257
+ print("LOG|op=pdf-image-geometry file=%s pagesChecked=%d runningImages=%d "
258
+ "widestPt=%.1f fracPage=%.1f%% limitPt=%.1f"
259
+ % (base, checked, len(running), widest, widest / pw * 100.0, limit))
260
+ if over:
261
+ print("GBLOCK|%s|widestPt=%.1f fracPage=%.1f%% limitPt=%.1f"
262
+ % (base, over[-1], over[-1] / pw * 100.0, limit))
263
+ sys.exit(0)
194
264
  PY
195
265
  )
196
266
 
267
+ # Both page-chrome faults are collected before exiting, so a document with a
268
+ # headerless page AND an oversized running logo reports both on one print rather
269
+ # than surfacing the second only after the first is fixed and reprinted.
270
+ BLOCKED=0
197
271
  while IFS= read -r hline; do
198
272
  case "$hline" in
199
273
  LOG\|*) emit_log "${hline#LOG|}" ;;
@@ -203,32 +277,49 @@ while IFS= read -r hline; do
203
277
  hdetail=${hrest#*|}
204
278
  emit_log "op=bypass file=$hfile reason=pdf-header-conformance detail=$hdetail"
205
279
  echo "Blocked: this printed document has physical pages carrying no running header, so it would go out with unbranded pages ($hdetail). The header must be a property of the page itself, a page margin box, not a header block placed next to a declared page break, because the browser decides the real page boundaries at print time. Fix the document's print CSS, print it again, and check every page." >&2
206
- exit 2
280
+ BLOCKED=1
281
+ ;;
282
+ GBLOCK\|*)
283
+ grest=${hline#GBLOCK|}
284
+ gfile=${grest%%|*}
285
+ gdetail=${grest#*|}
286
+ emit_log "op=bypass file=$gfile reason=pdf-header-image-geometry detail=$gdetail"
287
+ echo "Blocked: the running header image on this printed document covers most of the page width ($gdetail). An image used as a page margin box's content is painted at its own pixel size and is not scaled down to the box you declared, so the image file itself has to be made the size the box expects. Shrink the asset, rebuild the document, print it again, and check a page after the first." >&2
288
+ BLOCKED=1
207
289
  ;;
208
290
  esac
209
291
  done <<< "$HRESULT"
292
+ [ "$BLOCKED" -eq 1 ] && exit 2
210
293
 
211
294
  RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
212
295
  import os, json, re, sys, urllib.request
213
296
 
297
+ # Every return that inspects nothing names its reason. A silent return is
298
+ # indistinguishable from a gate that never ran at all, which is the state a
299
+ # stale PostToolUse matcher leaves an account in; fail-open behaviour itself is
300
+ # unchanged, only its visibility.
301
+ def skip(reason):
302
+ print("SKIP|%s" % reason)
303
+ sys.exit(0)
304
+
214
305
  try:
215
306
  d = json.loads(os.environ["INPUT"])
216
307
  except Exception:
217
- sys.exit(0) # uninspectable envelope -> fail open
308
+ skip("bad-envelope")
218
309
 
219
310
  tool = d.get("tool_name", "") or ""
220
311
  if not tool.endswith("browser-pdf-save"):
221
- sys.exit(0)
312
+ skip("not-browser-tool")
222
313
  path = (d.get("tool_input", {}) or {}).get("path", "") or ""
223
314
  if not path:
224
- sys.exit(0)
315
+ skip("no-path")
225
316
  norm = os.path.normpath(path)
226
317
  m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
227
318
  if not m:
228
- sys.exit(0) # not a gated client-quote artefact
319
+ skip("not-gated") # not a gated client-quote artefact
229
320
  jobid = m.group(1)
230
321
  if "/" in jobid or ".." in jobid:
231
- sys.exit(0) # the PreToolUse gate owns bad-jobid; here fail open
322
+ skip("bad-jobid") # the PreToolUse gate owns bad-jobid; here fail open
232
323
  variant = m.group(2) # "-breakdown" or None
233
324
 
234
325
  base = os.environ["BASE"]
@@ -236,12 +327,12 @@ receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json
236
327
  try:
237
328
  receipt = json.load(open(receipt_path))
238
329
  except Exception:
239
- sys.exit(0) # no receipt -> PreToolUse gate already blocks; fail open
330
+ skip("no-receipt") # PreToolUse gate already blocks that; fail open here
240
331
 
241
332
  filed = receipt.get("filed", {}) or {}
242
333
  html_path = filed.get("breakdown" if variant else "quote", "")
243
334
  if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
244
- sys.exit(0) # nothing to compare against -> fail open
335
+ skip("no-source") # nothing to compare against -> fail open
245
336
 
246
337
  html = open(html_path, encoding="utf-8", errors="replace").read()
247
338
  # Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
@@ -274,6 +365,7 @@ PY
274
365
  )
275
366
 
276
367
  case "$RESULT" in
368
+ SKIP\|*) emit_log "op=skip reason=${RESULT#SKIP|}" ;;
277
369
  BLOCK\|*)
278
370
  rest=${RESULT#BLOCK|}
279
371
  jobid=${rest%%|*}