@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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +7 -5
  3. package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  8. package/payload/platform/plugins/admin/hooks/pdf-text-layer-inject.sh +137 -0
  9. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  10. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  11. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  12. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  13. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  14. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
  15. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  16. package/payload/platform/plugins/admin/skills/a4-print-documents/SKILL.md +12 -1
  17. package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
  18. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
  19. package/payload/platform/plugins/docs/references/admin-session.md +15 -0
  20. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +19 -43
  21. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  22. package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
  23. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +12 -35
  24. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  25. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  26. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  27. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
  28. package/payload/platform/scripts/setup-account.sh +11 -53
  29. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
  30. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
  32. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts +129 -0
  34. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
  36. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/index.js +107 -0
  38. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  40. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  42. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  43. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  44. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  45. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  46. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  47. package/payload/platform/templates/account-settings.json +159 -0
  48. package/payload/server/server.js +750 -656
  49. package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
  50. package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
  51. package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
  52. package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
  53. package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
  54. package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
  55. package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
  56. 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 customer only ever receives the two client-facing quote documents from the
5
- # customer documents scope (memory/users/<phone>/documents/). This gate refuses
6
- # to print one to PDF unless the deterministic quote-render tool actually
7
- # produced it proven by the render receipt quote-render writes on success
8
- # (quoting/jobs/<jobId>/render-receipt.json). No receipt for the job means the
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: only a .pdf under memory/users/<phone>/documents/ named
14
- # quote-<jobId>.pdf or quote-<jobId>-breakdown.pdf is gated. Every other PDF —
15
- # brochures, invoices, the admin-scope internal view — passes untouched.
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
- # Print the jobId of a gated client-quote artefact, or nothing for any other
31
- # call. The customer documents scope is a path convention, so match on it
32
- # structurally rather than resolving symlinks.
33
- JOBID=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
34
- import os, json, re, sys
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
- tool = d.get("tool_name", "") or ""
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 not m:
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
- print(m.group(1))
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
- # Not a gated client-quote artefact → allow.
54
- [ -z "$JOBID" ] && exit 0
124
+ case "$DECISION" in
125
+ ""|allow) exit 0 ;;
126
+ esac
55
127
 
56
- # A jobId is a single path segment. A separator or traversal in it never keys a
57
- # legitimate receipt, so treat it as a bypass rather than resolving a stray file.
58
- case "$JOBID" in
59
- */*|*..*)
60
- echo "[quote-render] op=bypass jobId=$JOBID reason=bad-jobid" >&2
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
- exit 2
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;AAyCD,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,kBAAkB,CAqC5E"}
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. existsSync
49
- // follows symlinks, so a dangling registry link reads as absent, which is
50
- // correctit does not resolve.
51
- const orphaned = declared.filter((name) => !existsSync(join(input.registryDir, `${name}.md`)));
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":"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"}
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 visually — confirm no scrollbars, no UI chrome, correct dimensions.
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`)