@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.
Files changed (120) hide show
  1. package/dist/__tests__/brand-agent-root.test.js +91 -0
  2. package/dist/brand-agent-root.js +58 -0
  3. package/dist/index.js +25 -28
  4. package/package.json +1 -1
  5. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  8. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  9. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  10. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  11. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  12. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  13. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  14. package/payload/platform/plugins/admin/mcp/dist/index.js +7 -1
  15. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  16. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts +10 -1
  17. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  18. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +52 -1
  19. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  20. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts +9 -0
  21. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.d.ts.map +1 -1
  22. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js +1 -1
  23. package/payload/platform/plugins/admin/mcp/dist/tools/capabilities-here.js.map +1 -1
  24. package/payload/platform/plugins/admin/skills/capabilities-here/SKILL.md +1 -1
  25. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +44 -1
  26. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +15 -0
  27. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +43 -0
  28. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  29. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  30. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  31. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  32. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  33. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  34. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -146
  35. package/payload/platform/scripts/setup-account.sh +13 -0
  36. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +39 -0
  37. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -0
  38. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +223 -0
  39. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -0
  40. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +5 -0
  41. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  42. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +5 -5
  43. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +11 -1
  45. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/http-server.js +58 -12
  47. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/index.js +112 -13
  49. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  50. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +6 -1
  51. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/pty-census.js +6 -3
  53. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +21 -8
  55. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  56. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +38 -15
  57. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  58. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  59. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  61. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  62. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +2 -0
  63. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  64. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +4 -1
  65. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  66. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +3 -0
  67. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  68. package/payload/platform/services/claude-session-manager/dist/reaper.js +2 -1
  69. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts +2 -0
  71. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js +5 -2
  73. package/payload/platform/services/claude-session-manager/dist/session-cap-audit.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +2 -0
  75. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +5 -3
  77. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -1
  78. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  79. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  80. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  81. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  82. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +26 -16
  83. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  84. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +84 -31
  85. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  86. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +7 -0
  87. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -1
  88. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +6 -0
  89. package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -1
  90. package/payload/platform/services/telegram-channel/dist/notification.d.ts +10 -2
  91. package/payload/platform/services/telegram-channel/dist/notification.d.ts.map +1 -1
  92. package/payload/platform/services/telegram-channel/dist/notification.js +15 -3
  93. package/payload/platform/services/telegram-channel/dist/notification.js.map +1 -1
  94. package/payload/platform/services/telegram-channel/dist/server.d.ts.map +1 -1
  95. package/payload/platform/services/telegram-channel/dist/server.js +35 -3
  96. package/payload/platform/services/telegram-channel/dist/server.js.map +1 -1
  97. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts +10 -0
  98. package/payload/platform/services/telegram-channel/dist/turn-follow.d.ts.map +1 -1
  99. package/payload/platform/services/telegram-channel/dist/turn-follow.js +38 -1
  100. package/payload/platform/services/telegram-channel/dist/turn-follow.js.map +1 -1
  101. package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
  102. package/payload/platform/services/webchat-channel/dist/server.js +31 -2
  103. package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
  104. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts +10 -0
  105. package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
  106. package/payload/platform/services/webchat-channel/dist/turn-follow.js +38 -1
  107. package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
  108. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts +9 -0
  109. package/payload/platform/services/whatsapp-channel/dist/instructions.d.ts.map +1 -0
  110. package/payload/platform/services/whatsapp-channel/dist/instructions.js +22 -0
  111. package/payload/platform/services/whatsapp-channel/dist/instructions.js.map +1 -0
  112. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +3 -1
  113. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  114. package/payload/platform/services/whatsapp-channel/dist/notification.js +10 -2
  115. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  116. package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
  117. package/payload/platform/services/whatsapp-channel/dist/server.js +26 -2
  118. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  119. package/payload/platform/templates/account-settings.json +153 -0
  120. 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 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
 
@@ -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 under $CLAUDE_CONFIG_DIR/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 () => {
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