@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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +4 -2
- package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
- package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
- package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
- package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
- package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
- package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
- package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +5 -5
- package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
- package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
- package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
- package/payload/platform/scripts/setup-account.sh +13 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
- package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +87 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
- package/payload/platform/templates/account-settings.json +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;
|
|
1
|
+
{"version":3,"file":"roster-census.d.ts","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAmCA,MAAM,WAAW,iBAAiB;IAChC,yEAAyE;IACzE,YAAY,EAAE,MAAM,CAAC;IACrB;;;uDAGmD;IACnD,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;uBAImB;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB;;;qBAGiB;IACjB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB;;;;4EAIwE;IACxE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B;;qEAEiE;IACjE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;oEAEgE;IAChE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB;;;;sEAIkE;IAClE,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAoFD,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,kBAAkB,CAsC5E"}
|
|
@@ -10,6 +10,49 @@ function markdownIn(dir) {
|
|
|
10
10
|
return [];
|
|
11
11
|
return readdirSync(dir).filter((entry) => entry.endsWith(".md"));
|
|
12
12
|
}
|
|
13
|
+
/** The `name:` value from a card's frontmatter, or null. CRLF-tolerant and
|
|
14
|
+
* quote-stripping, the same idiom `specialist-roster.ts` and `spawn-context.ts`
|
|
15
|
+
* use, so all three agree on what a card is called. A card that cannot be read
|
|
16
|
+
* — a dangling registry symlink, EACCES, a directory at the path — yields
|
|
17
|
+
* null, which is the honest answer to "does this resolve?". */
|
|
18
|
+
function frontmatterName(file) {
|
|
19
|
+
let raw;
|
|
20
|
+
try {
|
|
21
|
+
raw = readFileSync(file, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!fm)
|
|
28
|
+
return null;
|
|
29
|
+
const m = fm[1].match(/^name:\s*(.*?)\r?$/m);
|
|
30
|
+
if (!m)
|
|
31
|
+
return null;
|
|
32
|
+
let value = m[1].trim();
|
|
33
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
34
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
return value || null;
|
|
38
|
+
}
|
|
39
|
+
/** Every subagent name the CLI can resolve from `registryDir`.
|
|
40
|
+
*
|
|
41
|
+
* Task 2469 — keyed on the frontmatter `name`, not the filename. Those differ
|
|
42
|
+
* by construction for a bundle-scoped card: `glsmith--order-clerk.md`
|
|
43
|
+
* registers `order-clerk`, and `order-clerk` is also what AGENTS.md declares,
|
|
44
|
+
* so asking for `<rosterName>.md` missed all four of GLS's clerks while every
|
|
45
|
+
* one of them dispatched. For a bare card the filename stem and the name are
|
|
46
|
+
* equal, so nothing about that class changes. */
|
|
47
|
+
function resolvableNames(registryDir) {
|
|
48
|
+
const names = new Set();
|
|
49
|
+
for (const filename of markdownIn(registryDir)) {
|
|
50
|
+
const name = frontmatterName(join(registryDir, filename));
|
|
51
|
+
if (name !== null)
|
|
52
|
+
names.add(name);
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
13
56
|
function subdirs(dir) {
|
|
14
57
|
if (!existsSync(dir))
|
|
15
58
|
return [];
|
|
@@ -45,10 +88,13 @@ export function runRosterCensus(input) {
|
|
|
45
88
|
}
|
|
46
89
|
// Task 2460 — measured against the registry, not the source directory. Those
|
|
47
90
|
// two disagreed on the account that filed that task: 13 authored, 12
|
|
48
|
-
// registered, and this line reported zero orphans three times.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
91
|
+
// registered, and this line reported zero orphans three times.
|
|
92
|
+
//
|
|
93
|
+
// Task 2469 — and measured by the name the registry actually resolves rather
|
|
94
|
+
// than by filename. A dangling link still reads as absent, because reading it
|
|
95
|
+
// throws and yields no name.
|
|
96
|
+
const resolvable = resolvableNames(input.registryDir);
|
|
97
|
+
const orphaned = declared.filter((name) => !resolvable.has(name));
|
|
52
98
|
const durable = new Set();
|
|
53
99
|
for (const dir of input.durableDirs) {
|
|
54
100
|
for (const filename of markdownIn(dir))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"roster-census.js","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAkFjC;;;;uDAIuD;AACvD,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACnE,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;SAC7C,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;SACtC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,iBAAiB,CACxB,oBAA4B,EAC5B,iBAA0B;IAE1B,IAAI,CAAC,iBAAiB;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC3C,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,sEAAsE;IACtE,wEAAwE;IACxE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,0EAA0E;IAC1E,6EAA6E;IAC7E,cAAc;IACd,OAAO,UAAU,CAAC,oBAAoB,CAAC;SACpC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;SACrC,MAAM,CACL,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CACvF,CAAC;AACN,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAwB;IACtD,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC;QACzC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;QAC1C,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,mCAAmC,CAAC,EAAE,CAAC;QACvE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IACD,6EAA6E;IAC7E,qEAAqE;IACrE,
|
|
1
|
+
{"version":3,"file":"roster-census.js","sourceRoot":"","sources":["../../src/lib/roster-census.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAkFjC;;;;uDAIuD;AACvD,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACnE,CAAC;AAED;;;;gEAIgE;AAChE,SAAS,eAAe,CAAC,IAAY;IACnC,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACpD,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACrB,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAC7C,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACxB,IACE,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QAC9C,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAC9C,CAAC;QACD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,KAAK,IAAI,IAAI,CAAC;AACvB,CAAC;AAED;;;;;;;kDAOkD;AAClD,SAAS,eAAe,CAAC,WAAmB;IAC1C,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,QAAQ,IAAI,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;QAC1D,IAAI,IAAI,KAAK,IAAI;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,OAAO,CAAC,GAAW;IAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IAChC,OAAO,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;SAC7C,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;SACtC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED,SAAS,iBAAiB,CACxB,oBAA4B,EAC5B,iBAA0B;IAE1B,IAAI,CAAC,iBAAiB;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC3C,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,sEAAsE;IACtE,wEAAwE;IACxE,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,0EAA0E;IAC1E,6EAA6E;IAC7E,cAAc;IACd,OAAO,UAAU,CAAC,oBAAoB,CAAC;SACpC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;SACrC,MAAM,CACL,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,iBAAiB,EAAE,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CACvF,CAAC;AACN,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAwB;IACtD,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC;QACzC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;QAC1C,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,QAAQ,CAAC,mCAAmC,CAAC,EAAE,CAAC;QACvE,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IACD,6EAA6E;IAC7E,qEAAqE;IACrE,+DAA+D;IAC/D,EAAE;IACF,6EAA6E;IAC7E,8EAA8E;IAC9E,6BAA6B;IAC7B,MAAM,UAAU,GAAG,eAAe,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IACtD,MAAM,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IAElE,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC;QACpC,KAAK,MAAM,QAAQ,IAAI,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAChE,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,kBAAkB,EAAE,CAAC;QAC5C,KAAK,MAAM,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,KAAK,MAAM,QAAQ,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;gBAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACzF,CAAC;IACH,CAAC;IACD,MAAM,SAAS,GAAG,UAAU,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC,MAAM,CAC7D,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,CACrC,CAAC;IAEF,OAAO;QACL,QAAQ,EAAE,QAAQ,CAAC,MAAM;QACzB,YAAY,EAAE,QAAQ,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM;QAC/C,QAAQ;QACR,SAAS;QACT,aAAa,EAAE,iBAAiB,CAAC,KAAK,CAAC,oBAAoB,EAAE,KAAK,CAAC,iBAAiB,CAAC;KACtF,CAAC;AACJ,CAAC"}
|
|
@@ -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
|
|
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 $
|
|
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 -
|
|
57
|
-
ok "
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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 $
|
|
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 -
|
|
79
|
-
ok "
|
|
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 "
|
|
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
|
+
}
|