@rubytech/create-realagent-code 0.1.568 → 0.1.570
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +7 -5
- package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
- 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/pdf-text-layer-inject.sh +137 -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/lib/roster-census.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
- package/payload/platform/plugins/admin/skills/a4-print-documents/SKILL.md +12 -1
- package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
- package/payload/platform/plugins/docs/references/admin-session.md +15 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +19 -43
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +12 -35
- 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 -144
- package/payload/platform/scripts/setup-account.sh +11 -53
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts +129 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
- package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +107 -0
- package/payload/platform/services/claude-session-manager/dist/index.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/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/templates/account-settings.json +159 -0
- package/payload/server/server.js +750 -656
- package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
- package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
- package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
- package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
- package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
- package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
- package/payload/platform/scripts/lib/account-settings-public-surface.sh +0 -109
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# quote-pdf-bash-gate — PreToolUse gate on Bash.
|
|
3
|
+
#
|
|
4
|
+
# quote-render-gate refuses a hand-authored client quote at browser-pdf-save.
|
|
5
|
+
# An agent holding Bash reaches the same outcome with any HTML-to-PDF converter
|
|
6
|
+
# and meets no control at all — the specialist that produced the 2026-08-05
|
|
7
|
+
# quote holds Bash, Write, Edit and browser-pdf-save, and quote-engine-gate
|
|
8
|
+
# refuses only execution of the two persisted quoting scripts.
|
|
9
|
+
#
|
|
10
|
+
# This gate refuses a Bash command that produces a PDF from an HTML under a
|
|
11
|
+
# gated quote path. It matches structurally on the paths in the command, never
|
|
12
|
+
# on the converter's name, so the rule is binary-independent: wkhtmltopdf,
|
|
13
|
+
# chromium --print-to-pdf, weasyprint and anything else are all covered by the
|
|
14
|
+
# same two conditions.
|
|
15
|
+
#
|
|
16
|
+
# Scope mirrors quote-render-gate: the customer documents scope and a job's
|
|
17
|
+
# quote / Quotations buckets. jobs/<jobId>/pricing/ is excluded — it holds the
|
|
18
|
+
# internal priced breakdown.
|
|
19
|
+
#
|
|
20
|
+
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
|
|
21
|
+
# uninspectable call (Bash is a general-purpose tool and the agent never
|
|
22
|
+
# controls this hook's stdin, so failing open opens no bypass while never
|
|
23
|
+
# bricking unrelated commands). No task numbers or internal refs in
|
|
24
|
+
# operator-visible text.
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
# No envelope (tty or empty stdin) → allow: cannot inspect, must not brick Bash.
|
|
29
|
+
if [ -t 0 ]; then exit 0; fi
|
|
30
|
+
INPUT=$(cat)
|
|
31
|
+
[ -z "$INPUT" ] && exit 0
|
|
32
|
+
|
|
33
|
+
DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
|
|
34
|
+
import os, json, re, sys
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
d = json.loads(os.environ["INPUT"])
|
|
38
|
+
except Exception:
|
|
39
|
+
sys.exit(0)
|
|
40
|
+
if (d.get("tool_name", "") or "") != "Bash":
|
|
41
|
+
sys.exit(0)
|
|
42
|
+
cmd = (d.get("tool_input", {}) or {}).get("command", "") or ""
|
|
43
|
+
if not cmd:
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
|
|
46
|
+
# A gated source: an .html under the customer documents scope or a job quote
|
|
47
|
+
# bucket. The filename class admits spaces (the filed quotes carry them) but
|
|
48
|
+
# stops at a quote character or a shell separator, so one command path cannot
|
|
49
|
+
# swallow the next. Quote characters are written \x22 / \x27 because this block
|
|
50
|
+
# sits inside a bash command substitution, which parses quotes even in a
|
|
51
|
+
# quoted heredoc.
|
|
52
|
+
GATED_HTML = re.compile(
|
|
53
|
+
r'(?:memory/users/[^/\x22\x27\s]+/documents/[^\x22\x27;|&]*\.html'
|
|
54
|
+
r'|jobs/[^/\x22\x27\s]+/(?:quote|Quotations)/[^\x22\x27;|&]*\.html)'
|
|
55
|
+
)
|
|
56
|
+
if not GATED_HTML.search(cmd):
|
|
57
|
+
sys.exit(0)
|
|
58
|
+
# Naming a PDF anywhere in the same command is what turns a read into a
|
|
59
|
+
# production. `cat q.html` names none and passes.
|
|
60
|
+
if not re.search(r'\.pdf\b', cmd):
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
print("block")
|
|
63
|
+
PY
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
[ "$DECISION" = "block" ] || exit 0
|
|
67
|
+
|
|
68
|
+
echo "[quote-render] op=bypass reason=bash-pdf-production" >&2
|
|
69
|
+
echo "Blocked: a client quote PDF must come from the quote-render tool, not from a converter run by hand. Run quote-render for this job — it files the document and its render receipt — then print the client HTML path it returns." >&2
|
|
70
|
+
exit 2
|
|
@@ -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
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"roster-census.d.ts","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC;IACrB;;;uDAGmD;IACnD,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;uBAImB;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;qBAGiB;IACjB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;;;;4EAIwE;IACxE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B;;qEAEiE;IACjE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;oEAEgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB;;;;sEAIkE;IAClE,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;
|
|
1
|
+
{"version":3,"file":"roster-census.d.ts","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC;IACrB;;;uDAGmD;IACnD,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;uBAImB;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;qBAGiB;IACjB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;;;;4EAIwE;IACxE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B;;qEAEiE;IACjE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;oEAEgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB;;;;sEAIkE;IAClE,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAoFD,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,kBAAkB,CAsC5E"}
|
|
@@ -10,6 +10,49 @@ function markdownIn(dir) {
|
|
|
10
10
|
return [];
|
|
11
11
|
return readdirSync(dir).filter((entry) => entry.endsWith(".md"));
|
|
12
12
|
}
|
|
13
|
+
/** The `name:` value from a card's frontmatter, or null. CRLF-tolerant and
|
|
14
|
+
* quote-stripping, the same idiom `specialist-roster.ts` and `spawn-context.ts`
|
|
15
|
+
* use, so all three agree on what a card is called. A card that cannot be read
|
|
16
|
+
* — a dangling registry symlink, EACCES, a directory at the path — yields
|
|
17
|
+
* null, which is the honest answer to "does this resolve?". */
|
|
18
|
+
function frontmatterName(file) {
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = readFileSync(file, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!fm)
|
|
28
|
+
return null;
|
|
29
|
+
const m = fm[1].match(/^name:\s*(.*?)\r?$/m);
|
|
30
|
+
if (!m)
|
|
31
|
+
return null;
|
|
32
|
+
let value = m[1].trim();
|
|
33
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
return value || null;
|
|
38
|
+
}
|
|
39
|
+
/** Every subagent name the CLI can resolve from `registryDir`.
|
|
40
|
+
*
|
|
41
|
+
* Task 2469 — keyed on the frontmatter `name`, not the filename. Those differ
|
|
42
|
+
* by construction for a bundle-scoped card: `glsmith--order-clerk.md`
|
|
43
|
+
* registers `order-clerk`, and `order-clerk` is also what AGENTS.md declares,
|
|
44
|
+
* so asking for `<rosterName>.md` missed all four of GLS's clerks while every
|
|
45
|
+
* one of them dispatched. For a bare card the filename stem and the name are
|
|
46
|
+
* equal, so nothing about that class changes. */
|
|
47
|
+
function resolvableNames(registryDir) {
|
|
48
|
+
const names = new Set();
|
|
49
|
+
for (const filename of markdownIn(registryDir)) {
|
|
50
|
+
const name = frontmatterName(join(registryDir, filename));
|
|
51
|
+
if (name !== null)
|
|
52
|
+
names.add(name);
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
13
56
|
function subdirs(dir) {
|
|
14
57
|
if (!existsSync(dir))
|
|
15
58
|
return [];
|
|
@@ -45,10 +88,13 @@ export function runRosterCensus(input) {
|
|
|
45
88
|
}
|
|
46
89
|
// Task 2460 — measured against the registry, not the source directory. Those
|
|
47
90
|
// two disagreed on the account that filed that task: 13 authored, 12
|
|
48
|
-
// registered, and this line reported zero orphans three times.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
91
|
+
// registered, and this line reported zero orphans three times.
|
|
92
|
+
//
|
|
93
|
+
// Task 2469 — and measured by the name the registry actually resolves rather
|
|
94
|
+
// than by filename. A dangling link still reads as absent, because reading it
|
|
95
|
+
// throws and yields no name.
|
|
96
|
+
const resolvable = resolvableNames(input.registryDir);
|
|
97
|
+
const orphaned = declared.filter((name) => !resolvable.has(name));
|
|
52
98
|
const durable = new Set();
|
|
53
99
|
for (const dir of input.durableDirs) {
|
|
54
100
|
for (const filename of markdownIn(dir))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"roster-census.js","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAkFjC;;;;uDAIuD;AACvD,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACnE,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;SAC7C,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;SACtC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,iBAAiB,CACxB,oBAA4B,EAC5B,iBAA0B;IAE1B,IAAI,CAAC,iBAAiB;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC3C,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,sEAAsE;IACtE,wEAAwE;IACxE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,0EAA0E;IAC1E,6EAA6E;IAC7E,cAAc;IACd,OAAO,UAAU,CAAC,oBAAoB,CAAC;SACpC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;SACrC,MAAM,CACL,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CACvF,CAAC;AACN,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAwB;IACtD,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC;QACzC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;QAC1C,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,mCAAmC,CAAC,EAAE,CAAC;QACvE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IACD,6EAA6E;IAC7E,qEAAqE;IACrE,
|
|
1
|
+
{"version":3,"file":"roster-census.js","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"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"}
|
|
@@ -62,10 +62,21 @@ Each glassmorphism section needs its own screenshot — you cannot reuse a print
|
|
|
62
62
|
4. **Capture the specific element** with `browser-screenshot` passing the element's CSS selector (e.g. `.cover`, `.backpage`) — the selector clips to that element, not the full page.
|
|
63
63
|
5. **Save the PNG** alongside the HTML, named for the section it replaces (e.g. `cover-print.png`, `backpage-print.png`).
|
|
64
64
|
6. **Restore** hidden elements and overflow with `browser-evaluate` after capture.
|
|
65
|
-
7. **Verify** the saved PNG
|
|
65
|
+
7. **Verify** the saved PNG — confirm no scrollbars, no UI chrome, correct dimensions. Do not compose your own rasterise or cleanup shell for this or for the PDF check below. A hand-written removal whose target is a shell variable joined to a wildcard is a target Claude Code's `dangerousRemoval` check cannot resolve, and that check ignores bypass-permissions mode, so the call stalls behind a card no channel operator can answer.
|
|
66
66
|
|
|
67
67
|
After capturing all print images, save the final PDF with `browser-pdf-save` to the output path.
|
|
68
68
|
|
|
69
|
+
### Verifying the saved PDF
|
|
70
|
+
|
|
71
|
+
Check the produced PDF with the shipped inspector rather than a hand-composed `pdfinfo`/`pdftoppm` shell:
|
|
72
|
+
|
|
73
|
+
PDF=/abs/path/to/output.pdf \
|
|
74
|
+
node "$PLATFORM_ROOT/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs"
|
|
75
|
+
|
|
76
|
+
It prints the page count, the page size in points, and one absolute PNG path per page, then exits 0. Read those PNGs to confirm the rendered result. Pass `OUT=/abs/dir` to render into a stable directory (it clears only its own prior `pg-*.png` output); pass `DPI=<n>` to change the default 150. A non-zero exit means nothing was rendered.
|
|
77
|
+
|
|
78
|
+
Never write your own rasterise shell for this. Such shells converge on a cleanup step whose removal target is a scratch-dir variable joined to a wildcard, and that target is refused by a check bypass-permissions mode cannot override, so the resulting card stalls the session invisibly.
|
|
79
|
+
|
|
69
80
|
## Page margins, page numbers, and running footers
|
|
70
81
|
|
|
71
82
|
Use `@page` margin boxes for page numbers and running footers — not `position: fixed` elements.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pdf-inspect.mjs — the A4 print-document PDF check.
|
|
4
|
+
*
|
|
5
|
+
* Prints a PDF's page count and page size, rasterises every page to PNG, and
|
|
6
|
+
* prints the absolute PNG paths. No shell, ever.
|
|
7
|
+
*
|
|
8
|
+
* This ships with the platform rather than being written out by the agent at
|
|
9
|
+
* dispatch time, because the agent-authored version was the defect. Each
|
|
10
|
+
* specialist composed its own rasterise shell, and the shape it converged on
|
|
11
|
+
* was a `cd`, a scratch-dir variable, a forced removal of that variable joined
|
|
12
|
+
* to a PNG wildcard, then pdfinfo and pdftoppm.
|
|
13
|
+
* The removal target there is a shell variable plus a glob, which Claude Code's
|
|
14
|
+
* static analyser cannot resolve; its `dangerousRemoval` check is
|
|
15
|
+
* `bypassImmune: true`, so no permission rule and no permission mode
|
|
16
|
+
* pre-approves it. The card it raises inside a background subagent has no
|
|
17
|
+
* prompt surface a channel operator can reach, and the session stalls
|
|
18
|
+
* (Task 2476). Precedent: e-sign/stamp.mjs under Task 1921.
|
|
19
|
+
*
|
|
20
|
+
* Every external command runs through execFileSync with an argv array, so no
|
|
21
|
+
* shell parses any argument: no glob expansion, no variable expansion, no word
|
|
22
|
+
* splitting anywhere in this path.
|
|
23
|
+
*
|
|
24
|
+
* Invocation (the skill's PDF verify step):
|
|
25
|
+
*
|
|
26
|
+
* PDF=<abs path to pdf> [OUT=<abs dir>] [DPI=<n>] \
|
|
27
|
+
* node "$PLATFORM_ROOT/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs"
|
|
28
|
+
*
|
|
29
|
+
* With no OUT the output directory is created by mkdtempSync and is fresh per
|
|
30
|
+
* run. With OUT the directory is reused, and this script's own prior output is
|
|
31
|
+
* removed by unlinkSync on absolute paths built from readdirSync — never `rm`,
|
|
32
|
+
* never a glob.
|
|
33
|
+
*
|
|
34
|
+
* Exit 0 prints one record line then the absolute PNG paths, one per line:
|
|
35
|
+
*
|
|
36
|
+
* [pdf-inspect] pages=<n> pageSize=<w>x<h>pts dpi=<n> outDir=<abs> pngs=<n>
|
|
37
|
+
*
|
|
38
|
+
* Any non-zero exit means nothing was rendered.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { execFileSync } from 'node:child_process'
|
|
42
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
43
|
+
import { tmpdir } from 'node:os'
|
|
44
|
+
import { join, isAbsolute } from 'node:path'
|
|
45
|
+
|
|
46
|
+
const TAG = '[pdf-inspect]'
|
|
47
|
+
/** Every PNG this script writes starts here. The cleanup below removes only
|
|
48
|
+
* files matching this prefix, so a caller-supplied OUT holding other work is
|
|
49
|
+
* never touched. */
|
|
50
|
+
const PREFIX = 'pg'
|
|
51
|
+
|
|
52
|
+
function die(message) {
|
|
53
|
+
process.stderr.write(`${TAG} error=${message}\n`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pdf = process.env.PDF
|
|
58
|
+
if (!pdf) die('PDF is required (absolute path to the pdf)')
|
|
59
|
+
if (!isAbsolute(pdf)) die(`PDF must be an absolute path, got ${JSON.stringify(pdf)}`)
|
|
60
|
+
if (!existsSync(pdf)) die(`no such file: ${pdf}`)
|
|
61
|
+
|
|
62
|
+
const dpi = process.env.DPI ? Number(process.env.DPI) : 150
|
|
63
|
+
if (!Number.isFinite(dpi) || dpi <= 0) die(`DPI must be a positive number, got ${JSON.stringify(process.env.DPI)}`)
|
|
64
|
+
|
|
65
|
+
let outDir = process.env.OUT
|
|
66
|
+
if (outDir) {
|
|
67
|
+
if (!isAbsolute(outDir)) die(`OUT must be an absolute path, got ${JSON.stringify(outDir)}`)
|
|
68
|
+
mkdirSync(outDir, { recursive: true })
|
|
69
|
+
// Reused directory: remove THIS script's prior output by absolute path.
|
|
70
|
+
// readdirSync gives literal names, so nothing here is ever a pattern.
|
|
71
|
+
for (const name of readdirSync(outDir)) {
|
|
72
|
+
if (name.startsWith(`${PREFIX}-`) && name.endsWith('.png')) {
|
|
73
|
+
unlinkSync(join(outDir, name))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Fresh per run, so there is no prior output to remove.
|
|
78
|
+
outDir = mkdtempSync(join(tmpdir(), 'pdf-inspect-'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function run(bin, args) {
|
|
82
|
+
try {
|
|
83
|
+
return execFileSync(bin, args, { encoding: 'utf8' })
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err && err.code === 'ENOENT') die(`${bin} not found — install poppler-utils`)
|
|
86
|
+
die(`${bin} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const info = run('pdfinfo', [pdf])
|
|
91
|
+
const pagesMatch = info.match(/^Pages:\s+(\d+)$/m)
|
|
92
|
+
const sizeMatch = info.match(/^Page size:\s+([\d.]+) x ([\d.]+) pts/m)
|
|
93
|
+
if (!pagesMatch) die('pdfinfo reported no page count')
|
|
94
|
+
const pages = Number(pagesMatch[1])
|
|
95
|
+
if (pages < 1) die(`pdf has ${pages} pages`)
|
|
96
|
+
const pageSize = sizeMatch ? `${sizeMatch[1]}x${sizeMatch[2]}` : 'unknown'
|
|
97
|
+
|
|
98
|
+
run('pdftoppm', ['-r', String(dpi), '-png', pdf, join(outDir, PREFIX)])
|
|
99
|
+
|
|
100
|
+
const pngs = readdirSync(outDir)
|
|
101
|
+
.filter((n) => n.startsWith(`${PREFIX}-`) && n.endsWith('.png'))
|
|
102
|
+
.sort()
|
|
103
|
+
.map((n) => join(outDir, n))
|
|
104
|
+
|
|
105
|
+
if (pngs.length === 0) die('pdftoppm wrote no PNGs')
|
|
106
|
+
for (const p of pngs) {
|
|
107
|
+
if (statSync(p).size === 0) die(`empty PNG: ${p}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
process.stdout.write(
|
|
111
|
+
`${TAG} pages=${pages} pageSize=${pageSize}pts dpi=${dpi} outDir=${outDir} pngs=${pngs.length}\n`,
|
|
112
|
+
)
|
|
113
|
+
for (const p of pngs) process.stdout.write(`${p}\n`)
|