@rubytech/create-maxy-code 0.1.568 → 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 (37) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +4 -2
  3. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  7. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  8. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  9. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  10. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  11. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
  13. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  15. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
  16. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  17. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
  18. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  19. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  20. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  21. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
  22. package/payload/platform/scripts/setup-account.sh +13 -0
  23. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
  24. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
  26. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/index.js +87 -0
  28. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  30. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  31. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  32. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  33. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  34. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  36. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  37. package/payload/platform/templates/account-settings.json +153 -0
@@ -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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"}
@@ -9,6 +9,14 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-06 (0.1.569)
13
+
14
+ - Every account's safeguards are now brought up to date on each install. They used to be written once when the account was created and never revisited, so an account could be missing a protection that every other account had, with nothing reporting the gap.
15
+ - A customer quote can now only be produced by the specialist that owns it. Other assistants were writing quote documents by hand, bypassing the template and the renderer entirely; writes to a quote from anything else are now refused, including producing the PDF through the shell.
16
+ - A specialist you wrote for your own account is dispatchable again. The roster was matching on filenames rather than the name declared inside the file, so cards that looked registered could not actually be reached and the work fell to a general-purpose assistant instead.
17
+ - Two new standing checks: one counts work written into an owned area by the wrong specialist, the other reconciles quotes that were sent against the records of what was actually rendered.
18
+ - A quote's running header no longer depends on where the author happened to put page breaks, so it appears on every page rather than the ones that were hand-marked.
19
+
12
20
  ## 2026-08-06 (0.1.568)
13
21
 
14
22
  - Answers reach the conversation they came from again. An answer was being routed by who the account's admin is rather than by the conversation it was sent to, so a public agent's reply could go nowhere and an admin answer could strand with nothing said about it. Delivery now returns to the door the message arrived on, and every failure to find one is named rather than passing silently. If you rolled an install back because of this, you can move forward again.
@@ -11,7 +11,7 @@
11
11
  # a comment and proves nothing about what the lib would write.
12
12
  set -uo pipefail
13
13
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
- PROV="$DIR/../lib/provision-account-dir.sh"
14
+ PROV="$DIR/../../templates/account-settings.json"
15
15
  BACKFILL="$DIR/../lib/account-settings-mailbox-inject.sh"
16
16
  SETUP="$DIR/../setup-account.sh"
17
17
  PASS=0; FAIL=0
@@ -22,7 +22,7 @@ bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL+1)); }
22
22
  # What provision-account-dir.sh writes into the settings heredoc. HOOKS_PATH is
23
23
  # assigned there as the literal string $PLATFORM_ROOT/plugins/admin/hooks, so the
24
24
  # resolved command is the same string the backfill lib holds.
25
- PROV_LINE='{ "type": "command", "command": "bash $HOOKS_PATH/mailbox-inject.sh" }'
25
+ PROV_LINE='{ "type": "command", "command": "bash $PLATFORM_ROOT/plugins/admin/hooks/mailbox-inject.sh" }'
26
26
  RESOLVED='bash $PLATFORM_ROOT/plugins/admin/hooks/mailbox-inject.sh'
27
27
 
28
28
  if /usr/bin/grep -qF "$PROV_LINE" "$PROV"; then
@@ -53,10 +53,10 @@ fi
53
53
 
54
54
  # HOOKS_PATH must still resolve to the path the backfill lib assumes; if someone
55
55
  # repoints it, the two writers silently disagree and every account gets two hooks.
56
- if /usr/bin/grep -qF 'HOOKS_PATH="\$PLATFORM_ROOT/plugins/admin/hooks"' "$PROV"; then
57
- ok "HOOKS_PATH resolves to the path the backfill lib assumes"
56
+ if ! /usr/bin/grep -q 'HOOKS_PATH' "$PROV"; then
57
+ ok "the template carries no unresolved hook-path indirection"
58
58
  else
59
- bad "HOOKS_PATH in $PROV no longer matches the backfill lib assumption"
59
+ bad "$PROV still carries a HOOKS_PATH indirection, so the two writers can drift"
60
60
  fi
61
61
 
62
62
  if /usr/bin/grep -qF 'account-settings-mailbox-inject.sh' "$SETUP"; then
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  set -uo pipefail
3
3
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
- LIB="$DIR/../lib/provision-account-dir.sh"
4
+ # The provision-time source of the hook set is the template provision_account_dir
5
+ # copies verbatim, not a heredoc inside the writer.
6
+ LIB="$DIR/../../templates/account-settings.json"
5
7
  PASS=0; FAIL=0; FAILED=()
6
8
  c(){ if grep -qF "$1" "$LIB"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
7
9
  c 'preference-consult-gate.sh' "gate registered"
@@ -7,7 +7,7 @@
7
7
  # append a second copy to every house account on every install.
8
8
  set -uo pipefail
9
9
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
- PROV="$DIR/../lib/provision-account-dir.sh"
10
+ PROV="$DIR/../../templates/account-settings.json"
11
11
  BACKFILL="$DIR/../lib/account-settings-public-surface.sh"
12
12
  SETUP="$DIR/../setup-account.sh"
13
13
  PASS=0; FAIL=0
@@ -15,7 +15,7 @@ PASS=0; FAIL=0
15
15
  ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
16
16
  bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL+1)); }
17
17
 
18
- PROV_LINE='{ "type": "command", "command": "bash $HOOKS_PATH/public-tool-surface.sh" }'
18
+ PROV_LINE='{ "type": "command", "command": "bash $PLATFORM_ROOT/plugins/admin/hooks/public-tool-surface.sh" }'
19
19
  RESOLVED='bash $PLATFORM_ROOT/plugins/admin/hooks/public-tool-surface.sh'
20
20
 
21
21
  if [ "$(/usr/bin/grep -cF "$PROV_LINE" "$PROV")" = "3" ]; then
@@ -75,10 +75,10 @@ else
75
75
  bad "backfill _PUBSURFACE_CMD is not '$RESOLVED' in $BACKFILL"
76
76
  fi
77
77
 
78
- if /usr/bin/grep -qF 'HOOKS_PATH="\$PLATFORM_ROOT/plugins/admin/hooks"' "$PROV"; then
79
- ok "HOOKS_PATH resolves to the path the backfill lib assumes"
78
+ if ! /usr/bin/grep -q 'HOOKS_PATH' "$PROV"; then
79
+ ok "the template carries no unresolved hook-path indirection"
80
80
  else
81
- bad "HOOKS_PATH in $PROV no longer matches the backfill lib assumption"
81
+ bad "$PROV still carries a HOOKS_PATH indirection, so the two writers can drift"
82
82
  fi
83
83
 
84
84
  if /usr/bin/grep -qF 'account-settings-public-surface.sh' "$SETUP"; then
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # The client quote document has exactly one owner, and that ownership is a
3
+ # capability as well as a claim.
4
+ #
5
+ # A hand-orchestrated quote reached a customer because the quoter's own card
6
+ # disclaimed document work ("not a document edit for another agent"), so the
7
+ # admin's routing to a general content specialist was correct against it. The
8
+ # guarantee that replaced that prose is a capability: the quote tools are
9
+ # granted to the quoter's card and to no other, so no other agent can produce a
10
+ # receipt-bearing quote by any route.
11
+ #
12
+ # Covers:
13
+ # A. the quoter's body claims ownership of the document's presentation
14
+ # B. the disclaimer that sent document work elsewhere is gone
15
+ # C. the description front-matter carries the same claim (the roster text the
16
+ # admin reads at dispatch time)
17
+ # D. quote-render / quote-engine-run are granted to the quoter
18
+ # E. no other card in the tree grants either tool
19
+
20
+ set -u
21
+
22
+ ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
23
+ QUOTER="$ROOT/premium-plugins/sitedesk/agents/sitedesk--quoter.md"
24
+
25
+ PASS=0
26
+ FAIL=0
27
+ ok() { echo "PASS: $1"; PASS=$((PASS + 1)); }
28
+ bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
29
+
30
+ if [ ! -f "$QUOTER" ]; then
31
+ echo "FAIL: quoter card missing at $QUOTER" >&2
32
+ exit 1
33
+ fi
34
+
35
+ # Body = everything outside the frontmatter block.
36
+ BODY="$(sed -n '/^---$/,/^---$/!p' "$QUOTER")"
37
+ DESC="$(grep -m1 '^description:' "$QUOTER" || true)"
38
+
39
+ for word in layout "cover page" photograph "running header" correction; do
40
+ if printf '%s' "$BODY" | grep -qi -- "$word"; then
41
+ ok "A: body claims $word"
42
+ else
43
+ bad "A: body does not claim $word"
44
+ fi
45
+ done
46
+
47
+ if grep -q 'not a document edit for another agent' "$QUOTER"; then
48
+ bad "B: the document-edit disclaimer is still present"
49
+ else
50
+ ok "B: document-edit disclaimer absent"
51
+ fi
52
+
53
+ if printf '%s' "$DESC" | grep -qi 'owns the client quote document'; then
54
+ ok "C: description carries the ownership claim"
55
+ else
56
+ bad "C: description does not carry the ownership claim"
57
+ fi
58
+
59
+ TOOLS="$(grep -m1 '^tools:' "$QUOTER" || true)"
60
+ for t in quote-render quote-engine-run; do
61
+ if printf '%s' "$TOOLS" | grep -q -- "$t"; then
62
+ ok "D: quoter grants $t"
63
+ else
64
+ bad "D: quoter does not grant $t"
65
+ fi
66
+ done
67
+
68
+ STRAY=0
69
+ while IFS= read -r card; do
70
+ [ "$card" = "$QUOTER" ] && continue
71
+ line="$(grep -m1 '^tools:' "$card" || true)"
72
+ if printf '%s' "$line" | grep -qE 'quote-render|quote-engine-run'; then
73
+ echo " stray grant: $card" >&2
74
+ STRAY=$((STRAY + 1))
75
+ fi
76
+ done < <(find "$ROOT/premium-plugins" "$ROOT/platform/templates/specialists/agents" -name '*.md' -type f 2>/dev/null)
77
+
78
+ if [ "$STRAY" -eq 0 ]; then
79
+ ok "E: no other card grants a quote tool"
80
+ else
81
+ bad "E: $STRAY other card(s) grant a quote tool"
82
+ fi
83
+
84
+ echo "──────── quote ownership contract summary ────────"
85
+ echo "PASS=$PASS FAIL=$FAIL"
86
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # reconcile_all_accounts_settings converges every account's hook set against the
3
+ # shipped template and states the divergence count on every run.
4
+ #
5
+ # provision_account_dir writes the account's .claude/settings.json at provision
6
+ # time only, and the upgrade path re-provisions the house account and no other.
7
+ # An account created before a hook was added therefore runs a stale hook set
8
+ # indefinitely, silently — which is how a client-quote gate came to be
9
+ # registered on one of four accounts on a live install. Five one-hook backfill
10
+ # libs were written that way, each after a different hook was found missing.
11
+ # This lib converges the whole set instead.
12
+ #
13
+ # Covers:
14
+ # A. an account missing a template hook converges → status=updated
15
+ # B. an already-matching account is left alone → status=already-set
16
+ # C. an account with no settings file is seeded → status=absent-seeded
17
+ # D. an unparseable file is reported, not rewritten → status=rewrite-failed
18
+ # E. the summary line is emitted on a re-run, stating its count
19
+ # F. the summary line counts the divergent accounts
20
+
21
+ set -u
22
+
23
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/account-settings-reconcile.sh"
24
+ if [ ! -f "$LIB" ]; then
25
+ echo "FAIL: $LIB missing" >&2
26
+ exit 1
27
+ fi
28
+ # shellcheck disable=SC1090
29
+ . "$LIB"
30
+
31
+ TMP=$(mktemp -d)
32
+ trap 'rm -rf "$TMP"' EXIT
33
+
34
+ TPL="$TMP/account-settings.json"
35
+ printf '{"hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"bash a.sh"}]}]}}' > "$TPL"
36
+
37
+ ACCTS="$TMP/accounts"
38
+ mk() { mkdir -p "$ACCTS/$1/.claude"; printf '{}' > "$ACCTS/$1/account.json"; }
39
+
40
+ PASS=0
41
+ FAIL=0
42
+ ok() { echo "PASS: $1"; PASS=$((PASS + 1)); }
43
+ bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
44
+
45
+ mk stale; printf '{"hooks":{"PreToolUse":[]}}' > "$ACCTS/stale/.claude/settings.json"
46
+ mk current; cp "$TPL" "$ACCTS/current/.claude/settings.json"
47
+ mk fresh
48
+ mk broken; printf 'not json' > "$ACCTS/broken/.claude/settings.json"
49
+
50
+ OUT="$(MAXY_ACCOUNT_SETTINGS_TEMPLATE="$TPL" reconcile_all_accounts_settings "$ACCTS" 2>&1)"
51
+
52
+ printf '%s' "$OUT" | grep -q 'account=stale status=updated' \
53
+ && ok "A: stale account converged" || bad "A: stale account not converged"
54
+ printf '%s' "$OUT" | grep -q 'account=current status=already-set' \
55
+ && ok "B: matching account left alone" || bad "B: matching account not already-set"
56
+ printf '%s' "$OUT" | grep -q 'account=fresh status=absent-seeded' \
57
+ && ok "C: missing settings seeded" || bad "C: missing settings not seeded"
58
+ printf '%s' "$OUT" | grep -q 'account=broken status=rewrite-failed' \
59
+ && ok "D: unparseable file reported" || bad "D: unparseable file not reported"
60
+ [ "$(cat "$ACCTS/broken/.claude/settings.json")" = "not json" ] \
61
+ && ok "D: unparseable file left untouched" || bad "D: unparseable file was rewritten"
62
+ printf '%s' "$OUT" | grep -q 'accounts=4 divergent=3' \
63
+ && ok "F: summary counts the divergent accounts" || bad "F: wrong summary — $OUT"
64
+
65
+ # The converged accounts stay converged; the unparseable one never does, which
66
+ # is the standing signal.
67
+ OUT2="$(MAXY_ACCOUNT_SETTINGS_TEMPLATE="$TPL" reconcile_all_accounts_settings "$ACCTS" 2>&1)"
68
+ printf '%s' "$OUT2" | grep -q 'accounts=4 divergent=1' \
69
+ && ok "E: re-run states its count" || bad "E: wrong re-run summary — $OUT2"
70
+
71
+ echo "──────── account-settings-reconcile summary ────────"
72
+ echo "PASS=$PASS FAIL=$FAIL"
73
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,88 @@
1
+ # account-settings-reconcile.sh — converge every account's Claude Code project
2
+ # settings against the shipped template, and state the divergence count.
3
+ #
4
+ # provision_account_dir writes <accountDir>/.claude/settings.json from
5
+ # platform/templates/account-settings.json at provision time only. The upgrade
6
+ # path re-provisions the house account and no other, so an account created
7
+ # before a hook was added runs a stale hook set indefinitely, and nothing says
8
+ # so. A client-quote delivery gate was registered on one of four accounts on a
9
+ # live install for exactly that reason, and the account it was missing from was
10
+ # the one that quotes.
11
+ #
12
+ # Five one-hook backfill libs were written the same way, each after a different
13
+ # hook was found missing on a live account: owned-dirs, askgate,
14
+ # pdf-conformance, mailbox-inject, public-surface. This lib converges the whole
15
+ # set instead, so a hook added to the template reaches every existing account on
16
+ # the next install without a sixth bespoke backfill.
17
+ #
18
+ # Requires jq. Idempotent. Never echoes file contents. Fail-open: an unusable
19
+ # file logs a status and is left untouched, never rewritten from a guess.
20
+ #
21
+ # Sourced by setup-account.sh; also runnable standalone for a manual repair.
22
+
23
+ # ensure_account_settings <settings_file> <template_file>
24
+ # Returns 0 when the file already matched the template, 1 when it diverged
25
+ # (seeded, updated, unwritable, or unparseable). Logs exactly one
26
+ # [settings-reconcile] account=<id> status=<already-set|updated|absent-seeded|rewrite-failed|no-template>
27
+ ensure_account_settings() {
28
+ local f="$1" tpl="$2"
29
+ local acct
30
+ acct="$(basename "$(dirname "$(dirname "$f")")")"
31
+
32
+ if [ ! -f "$tpl" ]; then
33
+ echo "[settings-reconcile] account=$acct status=no-template"
34
+ return 1
35
+ fi
36
+
37
+ if [ ! -f "$f" ]; then
38
+ mkdir -p "$(dirname "$f")" 2>/dev/null || true
39
+ if cp "$tpl" "$f" 2>/dev/null; then
40
+ echo "[settings-reconcile] account=$acct status=absent-seeded"
41
+ else
42
+ echo "[settings-reconcile] account=$acct status=rewrite-failed"
43
+ fi
44
+ return 1
45
+ fi
46
+
47
+ # Compare canonical forms, so key order and whitespace never read as drift.
48
+ # An empty result means jq refused the file; that is reported, not repaired —
49
+ # an unparseable settings file may carry an operator edit worth keeping, and
50
+ # this pass never destroys what it cannot read.
51
+ local before after
52
+ before="$(jq -S . "$f" 2>/dev/null || true)"
53
+ after="$(jq -S . "$tpl" 2>/dev/null || true)"
54
+ if [ -z "$before" ] || [ -z "$after" ]; then
55
+ echo "[settings-reconcile] account=$acct status=rewrite-failed"
56
+ return 1
57
+ fi
58
+ if [ "$before" = "$after" ]; then
59
+ echo "[settings-reconcile] account=$acct status=already-set"
60
+ return 0
61
+ fi
62
+
63
+ if cp "$tpl" "$f" 2>/dev/null; then
64
+ echo "[settings-reconcile] account=$acct status=updated"
65
+ else
66
+ echo "[settings-reconcile] account=$acct status=rewrite-failed"
67
+ fi
68
+ return 1
69
+ }
70
+
71
+ # reconcile_all_accounts_settings <accounts_dir>
72
+ # Standing check + durable backfill across every account carrying an
73
+ # account.json. Emits one status line per account plus one summary line on every
74
+ # run, including a zero count — silence is not evidence of convergence.
75
+ reconcile_all_accounts_settings() {
76
+ local accts="$1"
77
+ local tpl="${MAXY_ACCOUNT_SETTINGS_TEMPLATE:-${TEMPLATES_DIR:-}/account-settings.json}"
78
+ local n=0 divergent=0 acct
79
+ if [ -d "$accts" ]; then
80
+ for acct in "$accts"/*/; do
81
+ [ -f "$acct/account.json" ] || continue
82
+ n=$((n + 1))
83
+ ensure_account_settings "$acct/.claude/settings.json" "$tpl" || divergent=$((divergent + 1))
84
+ done
85
+ fi
86
+ echo "[settings-reconcile] accounts=$n divergent=$divergent"
87
+ return 0
88
+ }