@rubytech/create-maxy-code 0.1.567 → 0.1.569
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/dist/__tests__/brand-agent-root.test.js +91 -0
- package/dist/brand-agent-root.js +58 -0
- package/dist/index.js +25 -28
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +4 -2
- package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
- package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
- package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
- package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +7 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts +10 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +52 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +9 -0
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +1 -1
- package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -1
- package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +44 -1
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +15 -0
- package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +43 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
- package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
- package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +11 -146
- package/payload/platform/scripts/setup-account.sh +13 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +39 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +223 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +5 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +5 -5
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +11 -1
- 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 +58 -12
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +112 -13
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +6 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.js +6 -3
- package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -8
- 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 +38 -15
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +4 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +3 -0
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +2 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js +5 -2
- package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +5 -3
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +26 -16
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +84 -31
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +6 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -2
- package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/notification.js +15 -3
- package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/server.js +35 -3
- package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts +10 -0
- package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/turn-follow.js +38 -1
- package/payload/platform/services/telegram-channel/dist/turn-follow.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/server.js +31 -2
- package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts +10 -0
- package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/turn-follow.js +38 -1
- package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts +9 -0
- package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts.map +1 -0
- package/payload/platform/services/whatsapp-channel/dist/instructions.js +22 -0
- package/payload/platform/services/whatsapp-channel/dist/instructions.js.map +1 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +3 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +10 -2
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +26 -2
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/platform/templates/account-settings.json +153 -0
- package/payload/server/server.js +254 -96
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# quote-render-gate — PreToolUse gate on browser-pdf-save.
|
|
3
3
|
#
|
|
4
|
-
# A
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# quote was hand-authored off the deterministic path, so the fixed-layout
|
|
10
|
-
# guarantee and the reconcile/margin/link gates never ran; the PDF is refused and
|
|
11
|
-
# the bypass is surfaced as [quote-render] op=bypass, not delivered.
|
|
4
|
+
# A client-facing quote reaches a customer only when the deterministic
|
|
5
|
+
# quote-render tool produced that exact document. The tool writes a render
|
|
6
|
+
# receipt on success (quoting/jobs/<jobId>/render-receipt.json) naming the
|
|
7
|
+
# documents it filed; this gate resolves the PDF's source HTML and requires its
|
|
8
|
+
# content to match one of the client-facing documents that receipt names.
|
|
12
9
|
#
|
|
13
|
-
# Scope
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# Scope — three shapes, all resolved against the account dir:
|
|
11
|
+
# memory/users/<phone>/documents/quote-<jobId>[-breakdown].pdf
|
|
12
|
+
# jobs/<jobId>/quote/<anything>.pdf
|
|
13
|
+
# jobs/<jobId>/Quotations/<anything>.pdf
|
|
14
|
+
# jobs/<jobId>/pricing/ is deliberately absent: it holds the internal priced
|
|
15
|
+
# breakdown, always excluded with the admin-scope internal view. Every other PDF
|
|
16
|
+
# — brochures, works orders, invoices — passes untouched.
|
|
17
|
+
#
|
|
18
|
+
# Why identity rather than existence. A receipt proves a render happened for the
|
|
19
|
+
# job, not that this file is its output. On the 2026-08-05 bypass the job's
|
|
20
|
+
# figures were rewritten after the render and the delivered document was written
|
|
21
|
+
# later still, so an existence check would have authorised the print. Comparing
|
|
22
|
+
# content, not paths, keeps an operator copy of a rendered document passing while
|
|
23
|
+
# hand-authored content fails. filed.internal is not client-facing and is never a
|
|
24
|
+
# permitted match.
|
|
25
|
+
#
|
|
26
|
+
# A missing source HTML blocks: the pipeline files the PDF beside the HTML it
|
|
27
|
+
# printed from, so no local source means no proof of renderer origin.
|
|
16
28
|
#
|
|
17
29
|
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
|
|
18
30
|
# uninspectable call (browser-pdf-save is a general-purpose tool; the bypass we
|
|
@@ -27,48 +39,104 @@ if [ -t 0 ]; then exit 0; fi
|
|
|
27
39
|
INPUT=$(cat)
|
|
28
40
|
[ -z "$INPUT" ] && exit 0
|
|
29
41
|
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
# Prints nothing for an out-of-scope call, "allow" for a reconciling document,
|
|
43
|
+
# or "block <reason> <jobId>".
|
|
44
|
+
DECISION=$(INPUT="$INPUT" ACCT="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
45
|
+
import os, json, re, sys, hashlib
|
|
46
|
+
|
|
35
47
|
try:
|
|
36
48
|
d = json.loads(os.environ["INPUT"])
|
|
37
49
|
except Exception:
|
|
38
50
|
sys.exit(0)
|
|
39
|
-
|
|
40
|
-
if not tool.endswith("browser-pdf-save"):
|
|
51
|
+
if not (d.get("tool_name", "") or "").endswith("browser-pdf-save"):
|
|
41
52
|
sys.exit(0)
|
|
42
53
|
path = (d.get("tool_input", {}) or {}).get("path", "") or ""
|
|
43
54
|
if not path:
|
|
44
55
|
sys.exit(0)
|
|
45
56
|
norm = os.path.normpath(path)
|
|
57
|
+
|
|
58
|
+
# The customer documents scope and the jobs bucket are path conventions, so
|
|
59
|
+
# match on them structurally rather than resolving symlinks.
|
|
60
|
+
jobid = None
|
|
46
61
|
m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(?:-breakdown)?\.pdf$', norm)
|
|
47
|
-
if
|
|
62
|
+
if m:
|
|
63
|
+
jobid = m.group(1)
|
|
64
|
+
else:
|
|
65
|
+
# `.+` rather than `[^/]+`: the bucket is matched recursively, so one mkdir
|
|
66
|
+
# cannot walk a hand-authored quote out of scope. The write gate and the
|
|
67
|
+
# Bash gate match the same buckets recursively; all three must agree.
|
|
68
|
+
m = re.search(r'(?:^|/)jobs/([^/]+)/(?:quote|Quotations)/.+\.pdf$', norm)
|
|
69
|
+
if m:
|
|
70
|
+
jobid = m.group(1)
|
|
71
|
+
if jobid is None:
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
# A jobId is a single path segment. A separator or traversal in it never keys a
|
|
75
|
+
# legitimate receipt, so treat it as a bypass rather than resolving a stray file.
|
|
76
|
+
if "/" in jobid or ".." in jobid:
|
|
77
|
+
print("block bad-jobid " + jobid)
|
|
48
78
|
sys.exit(0)
|
|
49
|
-
|
|
79
|
+
|
|
80
|
+
base = os.environ.get("ACCT") or os.getcwd()
|
|
81
|
+
abs_pdf = norm if os.path.isabs(norm) else os.path.join(base, norm)
|
|
82
|
+
sibling = abs_pdf[:-4] + ".html"
|
|
83
|
+
if not os.path.isfile(sibling):
|
|
84
|
+
print("block no-source-html " + jobid)
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sha256(p):
|
|
89
|
+
h = hashlib.sha256()
|
|
90
|
+
with open(p, "rb") as fh:
|
|
91
|
+
for chunk in iter(lambda: fh.read(1 << 16), b""):
|
|
92
|
+
h.update(chunk)
|
|
93
|
+
return h.hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
with open(os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")) as fh:
|
|
98
|
+
filed = (json.load(fh) or {}).get("filed") or {}
|
|
99
|
+
except Exception:
|
|
100
|
+
print("block not-receipt-filed " + jobid)
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
want = sha256(sibling)
|
|
105
|
+
except Exception:
|
|
106
|
+
print("block no-source-html " + jobid)
|
|
107
|
+
sys.exit(0)
|
|
108
|
+
|
|
109
|
+
for key in ("quote", "breakdown"):
|
|
110
|
+
cand = filed.get(key)
|
|
111
|
+
if not isinstance(cand, str) or not cand:
|
|
112
|
+
continue
|
|
113
|
+
cpath = cand if os.path.isabs(cand) else os.path.join(base, cand)
|
|
114
|
+
try:
|
|
115
|
+
if sha256(cpath) == want:
|
|
116
|
+
print("allow")
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
except Exception:
|
|
119
|
+
continue
|
|
120
|
+
print("block not-receipt-filed " + jobid)
|
|
50
121
|
PY
|
|
51
122
|
)
|
|
52
123
|
|
|
53
|
-
|
|
54
|
-
|
|
124
|
+
case "$DECISION" in
|
|
125
|
+
""|allow) exit 0 ;;
|
|
126
|
+
esac
|
|
55
127
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
128
|
+
REASON=$(printf '%s' "$DECISION" | cut -d' ' -f2)
|
|
129
|
+
JOBID=$(printf '%s' "$DECISION" | cut -d' ' -f3-)
|
|
130
|
+
echo "[quote-render] op=bypass jobId=$JOBID reason=$REASON" >&2
|
|
131
|
+
case "$REASON" in
|
|
132
|
+
no-source-html)
|
|
133
|
+
echo "Blocked: this client quote PDF has no source document beside it, so there is nothing to check it against. Produce the quote with the quote-render tool, which files the document and its render receipt, then print the path it returns." >&2
|
|
134
|
+
;;
|
|
135
|
+
bad-jobid)
|
|
61
136
|
echo "Blocked: this looks like a client quote PDF but its name does not resolve to a job. Produce the quote with the quote-render tool, which files the document and its render receipt, then print the path it returns." >&2
|
|
62
|
-
|
|
137
|
+
;;
|
|
138
|
+
*)
|
|
139
|
+
echo "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." >&2
|
|
63
140
|
;;
|
|
64
141
|
esac
|
|
65
|
-
|
|
66
|
-
# The receipt lives beside the job's figures, written by quote-render only after
|
|
67
|
-
# every gate passed. Its presence is the proof the deterministic path ran.
|
|
68
|
-
if [ -f "$PWD/quoting/jobs/$JOBID/render-receipt.json" ]; then
|
|
69
|
-
exit 0
|
|
70
|
-
fi
|
|
71
|
-
|
|
72
|
-
echo "[quote-render] op=bypass jobId=$JOBID detail=client quote PDF has no render receipt" >&2
|
|
73
|
-
echo "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." >&2
|
|
74
142
|
exit 2
|
|
@@ -30,6 +30,184 @@ if [ -t 0 ]; then exit 0; fi
|
|
|
30
30
|
INPUT=$(cat)
|
|
31
31
|
[ -z "$INPUT" ] && exit 0
|
|
32
32
|
|
|
33
|
+
# --- log sink ---------------------------------------------------------------
|
|
34
|
+
# A hook's stderr on exit 0 is not an observable sink: measured 2026-08-06 on the
|
|
35
|
+
# laptop's sitedesk-code install, no bracketed hook line has ever reached
|
|
36
|
+
# server.log (live or rotated) or the JSONL transcripts. The sibling inject hooks
|
|
37
|
+
# each write their own file under the resolved log dir; the header conformance
|
|
38
|
+
# line below does the same, so a clean run is distinguishable from a check that
|
|
39
|
+
# never ran. The ladder mirrors mailbox-inject.sh: LOG_DIR is stamped only on the
|
|
40
|
+
# rc-spawn path, CLAUDE_SESSION_MANAGER_PERSIST_DIR is on the systemd unit and
|
|
41
|
+
# resolves to the same directory, ACCOUNT_DIR/logs is the last resort.
|
|
42
|
+
ACCT_DIR=""
|
|
43
|
+
if [ -n "${ACCOUNT_DIR:-}" ] && [ -d "${ACCOUNT_DIR}" ]; then ACCT_DIR="$ACCOUNT_DIR"; fi
|
|
44
|
+
LOGD=""
|
|
45
|
+
if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR}" ]; then
|
|
46
|
+
LOGD="$LOG_DIR"
|
|
47
|
+
elif [ -n "${CLAUDE_SESSION_MANAGER_PERSIST_DIR:-}" ] && [ -d "${CLAUDE_SESSION_MANAGER_PERSIST_DIR}/logs" ]; then
|
|
48
|
+
LOGD="$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs"
|
|
49
|
+
elif [ -n "$ACCT_DIR" ] && [ -d "$ACCT_DIR/logs" ]; then
|
|
50
|
+
LOGD="$ACCT_DIR/logs"
|
|
51
|
+
fi
|
|
52
|
+
SID=$(printf '%s' "$INPUT" | python3 -c 'import json,sys
|
|
53
|
+
try:
|
|
54
|
+
print(json.load(sys.stdin).get("session_id","") or "?")
|
|
55
|
+
except Exception:
|
|
56
|
+
print("?")' 2>/dev/null)
|
|
57
|
+
SID=${SID:-?}
|
|
58
|
+
NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "?")
|
|
59
|
+
|
|
60
|
+
emit_log() {
|
|
61
|
+
local line="$NOW_ISO [quote-render] $1 session=$SID"
|
|
62
|
+
if [ -n "$LOGD" ]; then
|
|
63
|
+
# stderr redirect FIRST: with the append first, bash emits its own
|
|
64
|
+
# "Permission denied" on an unwritable log before 2>/dev/null takes effect,
|
|
65
|
+
# and that escapes to the hook's stderr on every print.
|
|
66
|
+
printf '%s\n' "$line" 2>/dev/null >> "$LOGD/quote-render-conformance.log" || true
|
|
67
|
+
fi
|
|
68
|
+
echo "$line" >&2 || true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# --- per-physical-page running-header conformance ---------------------------
|
|
72
|
+
# Chrome decides physical pagination at print time, so a document whose header is
|
|
73
|
+
# a hand-placed div next to a hand-placed page break loses that header on any page
|
|
74
|
+
# the content bled onto. This stage is deliberately receipt-free and path-
|
|
75
|
+
# agnostic: template-seeded documents (budget estimates, valuations, works
|
|
76
|
+
# orders, survey sheets) never pass through the quote renderer and so have no
|
|
77
|
+
# receipt, and they are exactly the documents that reproduce the fault.
|
|
78
|
+
#
|
|
79
|
+
# It only ever gates a document that DECLARES a running header in a @page top
|
|
80
|
+
# margin box, so a brochure or any document without one is never blocked.
|
|
81
|
+
HRESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
82
|
+
import os, json, re, sys, subprocess, html as htmlmod
|
|
83
|
+
|
|
84
|
+
def out(s):
|
|
85
|
+
print(s)
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
d = json.loads(os.environ["INPUT"])
|
|
90
|
+
except Exception:
|
|
91
|
+
sys.exit(0) # uninspectable envelope -> fail open
|
|
92
|
+
if not (d.get("tool_name", "") or "").endswith("browser-pdf-save"):
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
pdf = (d.get("tool_input", {}) or {}).get("path", "") or ""
|
|
95
|
+
if not pdf or not os.path.isfile(pdf):
|
|
96
|
+
sys.exit(0) # PDF not written -> nothing to inspect
|
|
97
|
+
base = os.path.basename(pdf)
|
|
98
|
+
|
|
99
|
+
# Source HTML: the render receipt for a gated client quote, else the sibling
|
|
100
|
+
# .html beside the PDF. The sibling is the documented browser-pdf-save
|
|
101
|
+
# convention (invoicing.md step 4, document-management.md step 4: print the HTML
|
|
102
|
+
# to an absolute .pdf path alongside it, same name).
|
|
103
|
+
src = ""
|
|
104
|
+
m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$',
|
|
105
|
+
os.path.normpath(pdf))
|
|
106
|
+
if m and "/" not in m.group(1) and ".." not in m.group(1):
|
|
107
|
+
try:
|
|
108
|
+
r = json.load(open(os.path.join(os.environ["BASE"], "quoting", "jobs",
|
|
109
|
+
m.group(1), "render-receipt.json")))
|
|
110
|
+
src = (r.get("filed", {}) or {}).get("breakdown" if m.group(2) else "quote", "")
|
|
111
|
+
except Exception:
|
|
112
|
+
src = ""
|
|
113
|
+
if not src or not os.path.isfile(src):
|
|
114
|
+
sib = os.path.splitext(pdf)[0] + ".html"
|
|
115
|
+
src = sib if os.path.isfile(sib) else ""
|
|
116
|
+
if not src:
|
|
117
|
+
out("LOG|op=pdf-header-conformance file=%s pagesChecked=0 reason=no-source-html" % base)
|
|
118
|
+
try:
|
|
119
|
+
doc = open(src, encoding="utf-8", errors="replace").read()
|
|
120
|
+
except Exception:
|
|
121
|
+
out("LOG|op=pdf-header-conformance file=%s pagesChecked=0 reason=no-source-html" % base)
|
|
122
|
+
|
|
123
|
+
# The marker is the literal string content of a @top-* box. A content:url() logo
|
|
124
|
+
# box carries no text and is skipped: only extractable text can be asserted per
|
|
125
|
+
# page. EVERY declaration of each box is considered, not just the first, because
|
|
126
|
+
# @page :first declares the same boxes with empty content to suppress them on
|
|
127
|
+
# page 1 and CSS does not fix the order of the two rules — taking the first match
|
|
128
|
+
# alone would read the suppression rule as "no running header" and silently skip
|
|
129
|
+
# the check on any document that declared :first before @page.
|
|
130
|
+
def unesc(s):
|
|
131
|
+
s = htmlmod.unescape(s)
|
|
132
|
+
s = re.sub(r'\\([0-9a-fA-F]{1,6})\s?', lambda g: chr(int(g.group(1), 16)), s)
|
|
133
|
+
return s.replace('\\"', '"').replace("\\\\", "\\").strip()
|
|
134
|
+
|
|
135
|
+
marker = ""
|
|
136
|
+
for box in ("top-center", "top-right", "top-left"):
|
|
137
|
+
for mm in re.finditer(r'@' + box + r'\s*\{[^}]*?content\s*:\s*"((?:[^"\\]|\\.)*)"', doc, re.S):
|
|
138
|
+
cand = unesc(mm.group(1))
|
|
139
|
+
if len(cand) >= 4:
|
|
140
|
+
marker = cand
|
|
141
|
+
break
|
|
142
|
+
if marker:
|
|
143
|
+
break
|
|
144
|
+
if not marker:
|
|
145
|
+
out("LOG|op=pdf-header-conformance file=%s pagesChecked=0 reason=no-running-header" % base)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
info = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, timeout=20)
|
|
149
|
+
pages = int(re.search(r'^Pages:\s+(\d+)', info.stdout, re.M).group(1))
|
|
150
|
+
except Exception:
|
|
151
|
+
out("LOG|op=pdf-header-conformance file=%s pagesChecked=0 reason=no-poppler" % base)
|
|
152
|
+
|
|
153
|
+
# The marker must be found in the TOP BAND of the page, not anywhere on it. A
|
|
154
|
+
# running footer routinely repeats the same reference as the header (the GLS
|
|
155
|
+
# footer is "… Builders Est. 1987 · Quote <ref>"), so a whole-page text match
|
|
156
|
+
# reports a header on a page that has only a footer — measured on a real
|
|
157
|
+
# 10-page print, where page 1 matched on its footer alone while its header was
|
|
158
|
+
# correctly suppressed by @page :first. Cropping to the top 12% of the page
|
|
159
|
+
# covers a top margin as deep as 30mm on A4 and excludes the footer entirely.
|
|
160
|
+
sz = re.search(r'^Page size:\s+([\d.]+) x ([\d.]+)', info.stdout, re.M)
|
|
161
|
+
pw, ph = (float(sz.group(1)), float(sz.group(2))) if sz else (595.0, 842.0)
|
|
162
|
+
band = max(1, int(ph * 0.12))
|
|
163
|
+
crop = ["-x", "0", "-y", "0", "-W", str(int(pw) + 1), "-H", str(band)]
|
|
164
|
+
|
|
165
|
+
# @page :first suppresses the margin boxes on page 1, which carries its own
|
|
166
|
+
# masthead instead; without it every physical page is expected to carry one.
|
|
167
|
+
first = 2 if re.search(r'@page\s*:first', doc) else 1
|
|
168
|
+
if pages < first:
|
|
169
|
+
out("LOG|op=pdf-header-conformance file=%s pages=%d pagesChecked=0 reason=single-page"
|
|
170
|
+
% (base, pages))
|
|
171
|
+
|
|
172
|
+
def norm(s):
|
|
173
|
+
return re.sub(r'\s+', ' ', s).strip()
|
|
174
|
+
|
|
175
|
+
want = norm(marker)
|
|
176
|
+
missing = []
|
|
177
|
+
for p in range(first, pages + 1):
|
|
178
|
+
try:
|
|
179
|
+
t = subprocess.run(["pdftotext", "-f", str(p), "-l", str(p)] + crop + [pdf, "-"],
|
|
180
|
+
capture_output=True, text=True, timeout=20).stdout
|
|
181
|
+
except Exception:
|
|
182
|
+
out("LOG|op=pdf-header-conformance file=%s pagesChecked=0 reason=no-poppler" % base)
|
|
183
|
+
if want not in norm(t):
|
|
184
|
+
missing.append(p)
|
|
185
|
+
|
|
186
|
+
checked = pages - first + 1
|
|
187
|
+
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))
|
|
194
|
+
PY
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
while IFS= read -r hline; do
|
|
198
|
+
case "$hline" in
|
|
199
|
+
LOG\|*) emit_log "${hline#LOG|}" ;;
|
|
200
|
+
HBLOCK\|*)
|
|
201
|
+
hrest=${hline#HBLOCK|}
|
|
202
|
+
hfile=${hrest%%|*}
|
|
203
|
+
hdetail=${hrest#*|}
|
|
204
|
+
emit_log "op=bypass file=$hfile reason=pdf-header-conformance detail=$hdetail"
|
|
205
|
+
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
|
|
207
|
+
;;
|
|
208
|
+
esac
|
|
209
|
+
done <<< "$HRESULT"
|
|
210
|
+
|
|
33
211
|
RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
34
212
|
import os, json, re, sys, urllib.request
|
|
35
213
|
|
|
@@ -203,6 +203,9 @@ if (ACCOUNT_ID) {
|
|
|
203
203
|
const roster = runRosterCensus({
|
|
204
204
|
agentsMdPath: resolve(accountDir, "agents", "admin", "AGENTS.md"),
|
|
205
205
|
specialistsAgentsDir: resolve(accountDir, "specialists", "agents"),
|
|
206
|
+
// Task 2460 — the one directory the CLI resolves a subagent name from,
|
|
207
|
+
// written at every spawn by syncAccountAgentRegistry.
|
|
208
|
+
registryDir: resolve(accountDir, ".claude", "agents"),
|
|
206
209
|
durableDirs: [resolve(PLATFORM_ROOT, "templates", "specialists", "agents")],
|
|
207
210
|
durablePluginRoots: [
|
|
208
211
|
resolve(accountDir, "plugins"),
|
|
@@ -727,7 +730,7 @@ eagerTool(server, "account-manage", "Read the account configuration (tier, domai
|
|
|
727
730
|
};
|
|
728
731
|
}
|
|
729
732
|
});
|
|
730
|
-
eagerTool(server, "capabilities-here", "Returns the live install set on the current account as a single JSON object: enabledPlugins (account config), tier, adminModel, publicModel, installedPlatformSpecialists (disk), installedPremiumSpecialists (disk, gated by brand.shipsPremiumBundles), registeredAgentTypes (Claude Code agent registry
|
|
733
|
+
eagerTool(server, "capabilities-here", "Returns the live install set on the current account as a single JSON object: enabledPlugins (account config), tier, adminModel, publicModel, installedPlatformSpecialists (disk), installedPremiumSpecialists (disk, gated by brand.shipsPremiumBundles), registeredAgentTypes (this account's Claude Code agent registry, <accountDir>/.claude/agents/), claudeAiConnectors (the claude.ai account connectors loaded for this client, each with an auth state of connected, needs-auth, or unreachable), and the brand block. Call this whenever you are about to make a claim about what this account can do, what is enabled, or what specialists are dispatchable. A connector listed here exists and is usable: do not deny a connector-backed request (Zoom, Dropbox, Microsoft 365, Cloudflare, Google) just because there is no skill or plugin for it — reach the connector tool, and authenticate it if its state is needs-auth. Do not infer install state from the platform-architecture skill — that skill is the catalogue of what the platform can ship, not what is enabled here.", {}, async () => {
|
|
731
734
|
if (!ACCOUNT_ID)
|
|
732
735
|
return refuseNoAccount("capabilities-here");
|
|
733
736
|
const requestId = randomUUID();
|
|
@@ -745,6 +748,9 @@ eagerTool(server, "capabilities-here", "Returns the live install set on the curr
|
|
|
745
748
|
platformRoot: PLATFORM_ROOT,
|
|
746
749
|
premiumPluginsRoot,
|
|
747
750
|
claudeConfigDir,
|
|
751
|
+
// Task 2460 — the account's own agent registry. The brand-wide root
|
|
752
|
+
// this used to read is retired and cleared on upgrade.
|
|
753
|
+
agentRegistryDir: resolve(getAccountDir(), ".claude", "agents"),
|
|
748
754
|
// Task 1160 — enumerate the brand client's claude.ai connectors via
|
|
749
755
|
// `claude mcp list`. Never rejects: resolves with whatever output the CLI
|
|
750
756
|
// produced (empty on a missing binary, non-zero exit, or the 12s timeout
|