@rubytech/create-maxy-code 0.1.492 → 0.1.494

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 (63) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-23-task-1930-adherence-enforcement.md +422 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md +81 -0
  4. package/payload/platform/plugins/admin/PLUGIN.md +3 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-directive.test.sh +15 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/preference-consult-gate.test.sh +59 -0
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
  8. package/payload/platform/plugins/admin/hooks/preference-consult-directive.sh +25 -0
  9. package/payload/platform/plugins/admin/hooks/preference-consult-gate.sh +120 -0
  10. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
  11. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
  12. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
  13. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +11 -1
  14. package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
  15. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
  18. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
  19. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
  20. package/payload/platform/plugins/docs/references/internals.md +2 -0
  21. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +13 -0
  22. package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +151 -0
  23. package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +98 -0
  24. package/payload/platform/scripts/lib/provision-account-dir.sh +20 -1
  25. package/payload/platform/scripts/setup-account.sh +13 -0
  26. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
  28. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
  30. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
  36. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
  38. package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
  39. package/payload/platform/templates/account-schema/SCHEMA.md +5 -0
  40. package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
  41. package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
  42. package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
  43. package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
  44. package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
  45. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
  46. package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
  47. package/payload/platform/templates/specialists/agents/data-manager.md +3 -2
  48. package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
  49. package/payload/platform/templates/specialists/agents/librarian.md +1 -0
  50. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
  51. package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
  52. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
  53. package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
  54. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
  55. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
  56. package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
  57. package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
  58. package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
  59. package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
  60. package/payload/server/public/chat.html +2 -2
  61. package/payload/server/public/operator.html +2 -2
  62. package/payload/server/public/public.html +2 -2
  63. package/payload/server/server.js +35 -26
@@ -309,13 +309,85 @@ function generationClient() {
309
309
  return []
310
310
  }
311
311
  if (/DELETE FROM directory\b/i.test(sql)) {
312
- const keep = Number(params[1])
313
- for (let i = rows.length - 1; i >= 0; i--) {
314
- if (rows[i].generation !== keep) rows.splice(i, 1)
312
+ const g = Number(params[1])
313
+ // Two DELETE shapes reach this client: the post-flip sweep removes
314
+ // every OTHER generation (`generation != ?`); the pre-stage clear
315
+ // (Task 1923) removes exactly THIS generation (`generation = ?`).
316
+ if (/generation\s*!=\s*\?/i.test(sql)) {
317
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
318
+ } else {
319
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
315
320
  }
316
321
  return []
317
322
  }
318
323
  if (/SELECT COUNT/i.test(sql)) {
324
+ // The pre-stage orphan count (Task 1923) filters `generation = ?`; the
325
+ // verify count joins the pointer. Only the former binds a generation.
326
+ if (/generation\s*=\s*\?/i.test(sql)) {
327
+ const g = Number(params[1])
328
+ return [{ n: rows.filter((r) => r.generation === g).length }]
329
+ }
330
+ return [{ n: rows.filter((r) => r.generation === pointer).length }]
331
+ }
332
+ return []
333
+ },
334
+ }
335
+ }
336
+
337
+ // The wedge Task 1923 fixes: a stage that half-completed on a D1 500 leaves
338
+ // orphan rows at the NEXT generation while the pointer stays at current. This
339
+ // client models the one property that turns that into a permanent freeze — the
340
+ // D1 UNIQUE(accountId, relPath, generation) constraint — by throwing on a
341
+ // colliding INSERT, exactly as every later cycle's first re-stage did.
342
+ function wedgeableClient(seed: { pointer: number; current?: string[]; orphans: string[] }) {
343
+ const calls: { sql: string; params: unknown[] }[] = []
344
+ const rows: { accountId: string; relPath: string; generation: number }[] = []
345
+ let pointer: number | null = seed.pointer
346
+ const orphanGen = seed.pointer + 1
347
+ // The last good tree the pointer names, present in a real wedge alongside the
348
+ // orphans. Seeding it proves the pre-stage clear scopes to `next` and leaves
349
+ // the reader-visible current generation untouched.
350
+ for (const relPath of seed.current ?? []) rows.push({ accountId: 'acc-1', relPath, generation: seed.pointer })
351
+ for (const relPath of seed.orphans) rows.push({ accountId: 'acc-1', relPath, generation: orphanGen })
352
+ return {
353
+ calls,
354
+ rows,
355
+ pointerValue: () => pointer,
356
+ async query(sql: string, params: unknown[] = []) {
357
+ calls.push({ sql, params })
358
+ if (/SELECT\s+currentGeneration/i.test(sql)) {
359
+ return pointer === null ? [] : [{ currentGeneration: pointer }]
360
+ }
361
+ if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
362
+ pointer = Number(params[1] ?? params[0])
363
+ return []
364
+ }
365
+ if (/INSERT INTO directory \(/i.test(sql)) {
366
+ const accountId = String(params[0])
367
+ const relPath = String(params[1])
368
+ const generation = Number(params[6])
369
+ if (rows.some((r) => r.accountId === accountId && r.relPath === relPath && r.generation === generation)) {
370
+ throw new Error(
371
+ 'd1 query failed: HTTP 500 — UNIQUE constraint failed: directory.accountId, directory.relPath, directory.generation',
372
+ )
373
+ }
374
+ rows.push({ accountId, relPath, generation })
375
+ return []
376
+ }
377
+ if (/DELETE FROM directory\b/i.test(sql)) {
378
+ const g = Number(params[1])
379
+ if (/generation\s*!=\s*\?/i.test(sql)) {
380
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
381
+ } else {
382
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
383
+ }
384
+ return []
385
+ }
386
+ if (/SELECT COUNT/i.test(sql)) {
387
+ if (/generation\s*=\s*\?/i.test(sql)) {
388
+ const g = Number(params[1])
389
+ return [{ n: rows.filter((r) => r.generation === g).length }]
390
+ }
319
391
  return [{ n: rows.filter((r) => r.generation === pointer).length }]
320
392
  }
321
393
  return []
@@ -323,6 +395,42 @@ function generationClient() {
323
395
  }
324
396
  }
325
397
 
398
+ describe('a partial stage self-heals (Task 1923)', () => {
399
+ it('clears orphans at the next generation, flips onto it, and logs stage-clear', async () => {
400
+ // Pointer at 5 naming the last good tree; three orphan rows already at
401
+ // generation 6 for relPaths the current walk re-stages ('output' is the
402
+ // walk's first row, so the current code's first INSERT is the one that
403
+ // collides). Without the pre-stage clear that INSERT throws on the UNIQUE
404
+ // constraint and the push fails, which is what froze gls-data for ~6 h on
405
+ // 2026-07-22.
406
+ const c = wedgeableClient({
407
+ pointer: 5,
408
+ current: ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'],
409
+ orphans: ['output', 'output/report.pdf', 'quotes'],
410
+ })
411
+ const lines: string[] = []
412
+ const r = await push(c, { log: (l: string) => lines.push(l) })
413
+
414
+ expect(r.rows).toBe(7)
415
+ expect(c.pointerValue()).toBe(6)
416
+ // Nothing survives at a generation the pointer does not name.
417
+ expect(c.rows.every((row) => row.generation === 6)).toBe(true)
418
+ const clear = lines.find((l) => l.includes('op=stage-clear'))!
419
+ expect(clear).toContain('account=acc-1')
420
+ expect(clear).toContain('generation=6')
421
+ // Exactly the 3 orphans at generation 6, not the 4 current-tree rows the
422
+ // pointer names: the clear scopes to `next`, never to a reader-visible row.
423
+ expect(clear).toContain('cleared=3')
424
+ })
425
+
426
+ it('emits no stage-clear line on a clean cycle with no orphans', async () => {
427
+ const c = wedgeableClient({ pointer: 5, orphans: [] })
428
+ const lines: string[] = []
429
+ await push(c, { log: (l: string) => lines.push(l) })
430
+ expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
431
+ })
432
+ })
433
+
326
434
  describe('the index replace is atomic', () => {
327
435
  it('never issues a delete before the pointer flip', async () => {
328
436
  // The delete-then-insert window this replaces was Task 1842: a client
@@ -60,6 +60,7 @@ const deadHead = async () => {
60
60
  throw new Error('ECONNREFUSED')
61
61
  }
62
62
  const errHead = async () => ({ ok: false, status: 502 })
63
+ const missingHead = async () => ({ ok: false, status: 404 })
63
64
 
64
65
  describe('processIndexedDownload', () => {
65
66
  it('returns a signed url for an indexed file when the device answers', async () => {
@@ -87,6 +88,18 @@ describe('processIndexedDownload', () => {
87
88
  expect(r.payload.error).toBe('device-offline')
88
89
  })
89
90
 
91
+ // A live device that answers 404 means the indexed row is stale: the file was
92
+ // renamed or removed while the index was frozen. That is "refresh the list",
93
+ // not "the device is down", so it must not collapse into device-offline.
94
+ it('reports file-missing when a reachable device 404s the path', async () => {
95
+ const lines: string[] = []
96
+ const r = await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, missingHead as never)
97
+ expect(r.status).toBe(404)
98
+ expect(r.payload.error).toBe('file-missing')
99
+ expect(r.url).toBeUndefined()
100
+ expect(lines.some((l) => l.includes('result=file-missing'))).toBe(true)
101
+ })
102
+
90
103
  it('404s a path that is not in the index, without contacting the device', async () => {
91
104
  let touched = false
92
105
  const spy = async () => {
@@ -171,6 +171,18 @@ export async function processIndexedDownload(
171
171
  try {
172
172
  const head = await fetchFn(url, { method: 'HEAD' })
173
173
  if (!head.ok) {
174
+ if (head.status === 404) {
175
+ // The device answered, so it is not down: this row is stale. The file
176
+ // was renamed or removed while the index was frozen, and the device
177
+ // returns the same 404 for a path that is missing and one that is not
178
+ // exposed — so this cannot and must not try to tell those apart. The
179
+ // client's remedy is "refresh the list", not "wait for the device".
180
+ log(
181
+ `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
182
+ `status=404 ms=${Date.now() - started} result=file-missing`,
183
+ )
184
+ return { status: 404, payload: { ok: false, error: 'file-missing' } }
185
+ }
174
186
  log(
175
187
  `[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
176
188
  `status=${head.status} ms=${Date.now() - started} result=device-error`,
@@ -292,6 +292,14 @@
292
292
  say('That file is on the device, which is not reachable right now. Try again shortly.');
293
293
  return;
294
294
  }
295
+ /* A reachable device that 404s a stale row sends error:'file-missing'.
296
+ The message says refresh, not wait. Keyed on the string, so the
297
+ edge's own not-found 404s (not-granted, absent, is-directory) carry
298
+ error:'not found' and keep the generic message below. */
299
+ if (res.d && res.d.error === 'file-missing') {
300
+ say('That file is no longer available under this name. Refresh the list and try again.');
301
+ return;
302
+ }
295
303
  if (!res.d.ok || !res.d.url) {
296
304
  say('That file could not be opened.');
297
305
  return;
@@ -408,6 +408,8 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
408
408
 
409
409
  **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
410
410
 
411
+ **Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up.
412
+
411
413
  ---
412
414
 
413
415
  ## Output Formatting and Budget
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ LIB="$DIR/../lib/provision-account-dir.sh"
5
+ PASS=0; FAIL=0; FAILED=()
6
+ c(){ if grep -qF "$1" "$LIB"; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2"); fi; }
7
+ c 'preference-consult-gate.sh' "gate registered"
8
+ c 'preference-consult-directive.sh' "directive registered"
9
+ c 'mcp__plugin_email_email__email-send' "email matcher present"
10
+ c 'mcp__plugin_outlook_outlook__outlook-mail-send' "outlook matcher present"
11
+ echo "----- $PASS passed, $FAIL failed -----"
12
+ for f in "${FAILED[@]:-}"; do [ -n "$f" ] && echo " $f"; done
13
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for account-settings-pdf-conformance.sh (Task 1929).
3
+ #
4
+ # The lib retrofits the quote-render PDF-conformance PostToolUse matcher onto
5
+ # existing accounts' .claude/settings.json on every install/upgrade. Task 1922
6
+ # added the matcher to provision-account-dir.sh, but that writer only runs at
7
+ # account-provision time and the upgrade path re-provisions only the house
8
+ # account -- so an account provisioned before 1922 keeps a settings.json without
9
+ # the browser-pdf-save PostToolUse matcher and never runs the PDF gate (live
10
+ # glsmith is such an account).
11
+ #
12
+ # Covers:
13
+ # 1. Stale matcher (browser-pdf-save present, conformance cmd absent) -> updated, appended, idempotent
14
+ # 2. Correct matcher (conformance cmd present) -> already-set, byte-identical
15
+ # 3. browser-pdf-save matcher absent (broad mcp__.* present) -> updated, matcher added, wildcard untouched
16
+ # 4. No .hooks.PostToolUse -> no-hooks, untouched
17
+ # 5. File absent -> absent
18
+ # 6. Malformed JSON -> rewrite-failed, untouched, no temp
19
+ # 7. reconcile_all_accounts_pdf_conformance -> patches every account.json dir, skips others
20
+ #
21
+ # All cases assert the [backfill-1929] status token and the post-state of the file.
22
+
23
+ set -u
24
+
25
+ LIB="$(cd "$(dirname "$0")/.." && pwd)/account-settings-pdf-conformance.sh"
26
+ if [ ! -f "$LIB" ]; then
27
+ echo "FAIL: $LIB not found" >&2
28
+ exit 1
29
+ fi
30
+ # shellcheck source=/dev/null
31
+ . "$LIB"
32
+
33
+ MATCHER='mcp__plugin_browser_browser__browser-pdf-save'
34
+ CONFORMANCE='bash $PLATFORM_ROOT/plugins/admin/hooks/quote-render-pdf-conformance.sh'
35
+
36
+ PASS=0
37
+ FAIL=0
38
+ TMP="$(mktemp -d)"
39
+ trap 'rm -rf "$TMP"' EXIT
40
+
41
+ ok() { PASS=$((PASS+1)); }
42
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
43
+
44
+ # settings.json with a PostToolUse browser-pdf-save matcher carrying the given
45
+ # command list. Args after $1 are command strings for that matcher.
46
+ write_settings() {
47
+ local file="$1"; shift
48
+ local cmds="" c
49
+ for c in "$@"; do
50
+ [ -n "$cmds" ] && cmds="$cmds,"
51
+ cmds="$cmds{\"type\":\"command\",\"command\":\"$c\"}"
52
+ done
53
+ mkdir -p "$(dirname "$file")"
54
+ cat > "$file" <<EOF
55
+ {
56
+ "permissions": { "defaultMode": "bypassPermissions", "allow": [], "deny": [] },
57
+ "hooks": {
58
+ "PostToolUse": [
59
+ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "bash \$P/b.sh" } ] },
60
+ { "matcher": "$MATCHER", "hooks": [ $cmds ] },
61
+ { "matcher": "mcp__.*", "hooks": [ { "type": "command", "command": "bash \$P/missing.sh" } ] }
62
+ ]
63
+ }
64
+ }
65
+ EOF
66
+ }
67
+
68
+ conformance_count() { grep -cF 'quote-render-pdf-conformance.sh' "$1" 2>/dev/null || true; }
69
+ pdfmatcher_present() {
70
+ jq -e --arg m "$MATCHER" '.hooks.PostToolUse | any(.[]; .matcher == $m)' "$1" >/dev/null 2>&1
71
+ }
72
+ wildcard_present() {
73
+ jq -e '.hooks.PostToolUse | any(.[]; .matcher == "mcp__.*")' "$1" >/dev/null 2>&1
74
+ }
75
+
76
+ # --- 1. stale matcher -> updated, then idempotent ---------------------------
77
+ F="$TMP/stale/.claude/settings.json"
78
+ write_settings "$F"
79
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
80
+ case "$OUT" in *status=updated*) ok;; *) bad "1a stale should be updated, got: $OUT";; esac
81
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "1b conformance not appended (count=$(conformance_count "$F"))"
82
+ OUT2="$(ensure_pdf_conformance_matcher "$F")"
83
+ case "$OUT2" in *status=already-set*) ok;; *) bad "1c re-run should be already-set, got: $OUT2";; esac
84
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "1d conformance duplicated on re-run (count=$(conformance_count "$F"))"
85
+
86
+ # --- 2. correct matcher -> already-set, byte-identical ----------------------
87
+ F="$TMP/good/.claude/settings.json"
88
+ write_settings "$F" "$CONFORMANCE"
89
+ BEFORE="$(cat "$F")"
90
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
91
+ case "$OUT" in *status=already-set*) ok;; *) bad "2a good should be already-set, got: $OUT";; esac
92
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "2b already-set file was rewritten"
93
+
94
+ # --- 3. browser-pdf-save matcher absent (wildcard present) -> updated, added --
95
+ F="$TMP/nomatcher/.claude/settings.json"
96
+ mkdir -p "$(dirname "$F")"
97
+ cat > "$F" <<'EOF'
98
+ { "permissions": {}, "hooks": { "PostToolUse": [
99
+ { "matcher": "Bash", "hooks": [] },
100
+ { "matcher": "mcp__.*", "hooks": [ { "type": "command", "command": "bash $P/missing.sh" } ] }
101
+ ] } }
102
+ EOF
103
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
104
+ case "$OUT" in *status=updated*) ok;; *) bad "3a nomatcher should be updated, got: $OUT";; esac
105
+ pdfmatcher_present "$F" && ok || bad "3b browser-pdf-save matcher not added"
106
+ [ "$(conformance_count "$F")" -eq 1 ] && ok || bad "3c conformance missing after add"
107
+ wildcard_present "$F" && ok || bad "3d wildcard matcher was disturbed"
108
+ # added matcher carries exactly the one conformance command
109
+ if jq -e --arg m "$MATCHER" '.hooks.PostToolUse[] | select(.matcher==$m) | .hooks | length == 1' "$F" >/dev/null 2>&1; then ok; else bad "3e added matcher should carry exactly one command"; fi
110
+
111
+ # --- 4. no PostToolUse -> no-hooks, untouched -------------------------------
112
+ F="$TMP/nohooks/.claude/settings.json"
113
+ mkdir -p "$(dirname "$F")"
114
+ echo '{ "permissions": {}, "hooks": {} }' > "$F"
115
+ BEFORE="$(cat "$F")"
116
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
117
+ case "$OUT" in *status=no-hooks*) ok;; *) bad "4a should be no-hooks, got: $OUT";; esac
118
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "4b no-hooks file was rewritten"
119
+
120
+ # --- 5. file absent -> absent ------------------------------------------------
121
+ OUT="$(ensure_pdf_conformance_matcher "$TMP/does-not-exist/.claude/settings.json")"
122
+ case "$OUT" in *status=absent*) ok;; *) bad "5 should be absent, got: $OUT";; esac
123
+
124
+ # --- 6. malformed JSON -> rewrite-failed, untouched -------------------------
125
+ F="$TMP/bad/.claude/settings.json"
126
+ mkdir -p "$(dirname "$F")"
127
+ printf '{ this is not json ' > "$F"
128
+ BEFORE="$(cat "$F")"
129
+ OUT="$(ensure_pdf_conformance_matcher "$F")"
130
+ case "$OUT" in *status=rewrite-failed*) ok;; *) bad "6a malformed should be rewrite-failed, got: $OUT";; esac
131
+ [ "$BEFORE" = "$(cat "$F")" ] && ok || bad "6b malformed file was mutated"
132
+ [ ! -e "$F.1929.tmp" ] && ok || bad "6c temp file left behind"
133
+
134
+ # --- 7. reconcile_all_accounts_pdf_conformance ------------------------------
135
+ ROOT="$TMP/accts"
136
+ mkdir -p "$ROOT/acctA/.claude" "$ROOT/acctB/.claude" "$ROOT/notanaccount/.claude"
137
+ echo '{"role":"house"}' > "$ROOT/acctA/account.json"
138
+ echo '{"role":"client"}' > "$ROOT/acctB/account.json"
139
+ # notanaccount has no account.json -> must be skipped
140
+ write_settings "$ROOT/acctA/.claude/settings.json"
141
+ write_settings "$ROOT/acctB/.claude/settings.json" "$CONFORMANCE"
142
+ write_settings "$ROOT/notanaccount/.claude/settings.json"
143
+ RECOUT="$(reconcile_all_accounts_pdf_conformance "$ROOT")"
144
+ [ "$(conformance_count "$ROOT/acctA/.claude/settings.json")" -eq 1 ] && ok || bad "7a acctA not patched"
145
+ [ "$(conformance_count "$ROOT/acctB/.claude/settings.json")" -eq 1 ] && ok || bad "7b acctB disturbed"
146
+ [ "$(conformance_count "$ROOT/notanaccount/.claude/settings.json")" -eq 0 ] && ok || bad "7c non-account dir was patched"
147
+ case "$RECOUT" in *acctA*status=updated*) ok;; *) bad "7d reconcile did not log acctA updated";; esac
148
+
149
+ echo "----------------------------------------"
150
+ echo "PASS=$PASS FAIL=$FAIL"
151
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,98 @@
1
+ # account-settings-pdf-conformance.sh — retrofit the quote-render PDF-conformance
2
+ # PostToolUse matcher onto existing accounts' .claude/settings.json. Task 1929.
3
+ #
4
+ # Task 1922 added a PostToolUse matcher on the browser-pdf-save tool running
5
+ # quote-render-pdf-conformance.sh to provision-account-dir.sh. That writer only
6
+ # runs at account-provision time; the upgrade path re-provisions the house
7
+ # account but NOT existing client sub-accounts, so any account provisioned before
8
+ # 1922 keeps a settings.json without the matcher and never runs the PDF gate
9
+ # (live glsmith is such an account). This lib is the standing backfill:
10
+ # setup-account.sh reconciles every account on every install so the gate reaches
11
+ # pre-1922 accounts. Mirrors account-settings-askgate.sh (Task 1683).
12
+ #
13
+ # Requires jq. Idempotent. Never echoes file contents. Fail-open (nudge, not
14
+ # brick): any unusable file logs a status and is left untouched.
15
+ #
16
+ # Sourced by setup-account.sh; also runnable standalone for a manual repair.
17
+
18
+ # The exact command string provision-account-dir.sh writes. $PLATFORM_ROOT is a
19
+ # literal in the settings file, resolved by Claude Code at hook-run time.
20
+ _PDF_CONFORMANCE_MATCHER='mcp__plugin_browser_browser__browser-pdf-save'
21
+ _PDF_CONFORMANCE_CMD='bash $PLATFORM_ROOT/plugins/admin/hooks/quote-render-pdf-conformance.sh'
22
+
23
+ # ensure_pdf_conformance_matcher <settings_file>
24
+ # Guarantees the PostToolUse browser-pdf-save matcher carries the conformance
25
+ # command: appends it if the matcher exists without it, or adds the whole matcher
26
+ # (single conformance command) if the matcher is absent. Logs exactly one
27
+ # `[backfill-1929] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>`
28
+ # to stdout. Always returns 0.
29
+ ensure_pdf_conformance_matcher() {
30
+ local f="$1"
31
+ if [ ! -f "$f" ]; then
32
+ echo "[backfill-1929] file=$f status=absent"
33
+ return 0
34
+ fi
35
+ # Patchable only when the file is valid JSON with a PostToolUse array.
36
+ if ! jq -e '.hooks.PostToolUse | type == "array"' "$f" >/dev/null 2>&1; then
37
+ if jq -e . "$f" >/dev/null 2>&1; then
38
+ echo "[backfill-1929] file=$f status=no-hooks"
39
+ else
40
+ echo "[backfill-1929] file=$f status=rewrite-failed"
41
+ fi
42
+ return 0
43
+ fi
44
+ local tmp="$f.1929.tmp"
45
+ if ! jq \
46
+ --arg matcher "$_PDF_CONFORMANCE_MATCHER" \
47
+ --arg cmd "$_PDF_CONFORMANCE_CMD" '
48
+ .hooks.PostToolUse |= (
49
+ if any(.[]; .matcher == $matcher)
50
+ then map(
51
+ if .matcher == $matcher
52
+ then .hooks = (
53
+ if any(.hooks[]; .command == $cmd)
54
+ then .hooks
55
+ else .hooks + [{type: "command", command: $cmd}]
56
+ end)
57
+ else . end)
58
+ else . + [{matcher: $matcher, hooks: [{type: "command", command: $cmd}]}]
59
+ end)
60
+ ' "$f" > "$tmp" 2>/dev/null; then
61
+ rm -f "$tmp"
62
+ echo "[backfill-1929] file=$f status=rewrite-failed"
63
+ return 0
64
+ fi
65
+ # Semantic no-op? Compare canonical forms; leave the file byte-identical if so.
66
+ # `|| true` keeps a jq failure here (e.g. $f truncated by a concurrent writer
67
+ # between the type-check above and this read) from tripping a caller's `set -e`
68
+ # and aborting the whole install. This lib is fail-open by contract.
69
+ local before after
70
+ before="$(jq -S . "$f" 2>/dev/null || true)"
71
+ after="$(jq -S . "$tmp" 2>/dev/null || true)"
72
+ if [ "$before" = "$after" ]; then
73
+ rm -f "$tmp"
74
+ echo "[backfill-1929] file=$f status=already-set"
75
+ return 0
76
+ fi
77
+ if mv "$tmp" "$f"; then
78
+ echo "[backfill-1929] file=$f status=updated"
79
+ else
80
+ rm -f "$tmp"
81
+ echo "[backfill-1929] file=$f status=rewrite-failed"
82
+ fi
83
+ return 0
84
+ }
85
+
86
+ # reconcile_all_accounts_pdf_conformance <accounts_dir>
87
+ # Standing check + backfill: every account dir with an account.json gets its
88
+ # PostToolUse browser-pdf-save matcher's conformance command ensured. Mirrors
89
+ # reconcile_all_accounts_askgate.
90
+ reconcile_all_accounts_pdf_conformance() {
91
+ local accts="$1"
92
+ [ -d "$accts" ] || return 0
93
+ local acct
94
+ for acct in "$accts"/*/; do
95
+ [ -f "$acct/account.json" ] || continue
96
+ ensure_pdf_conformance_matcher "$acct/.claude/settings.json"
97
+ done
98
+ }
@@ -113,6 +113,18 @@ provision_account_dir() {
113
113
  "hooks": [
114
114
  { "type": "command", "command": "bash $HOOKS_PATH/quote-render-gate.sh" }
115
115
  ]
116
+ },
117
+ {
118
+ "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
119
+ "hooks": [
120
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
121
+ ]
122
+ },
123
+ {
124
+ "matcher": "mcp__plugin_email_email__email-send|mcp__plugin_email_email__email-reply|mcp__plugin_email_email__email-draft-send|mcp__plugin_outlook_outlook__outlook-mail-send|mcp__plugin_outlook_outlook__outlook-mail-reply|mcp__plugin_outlook_outlook__outlook-draft-send|SendUserFile",
125
+ "hooks": [
126
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-gate.sh" }
127
+ ]
116
128
  }
117
129
  ],
118
130
  "PostToolUse": [
@@ -128,6 +140,12 @@ provision_account_dir() {
128
140
  { "type": "command", "command": "bash $HOOKS_PATH/post-tool-use-agent.sh" }
129
141
  ]
130
142
  },
143
+ {
144
+ "matcher": "mcp__plugin_browser_browser__browser-pdf-save",
145
+ "hooks": [
146
+ { "type": "command", "command": "bash $HOOKS_PATH/quote-render-pdf-conformance.sh" }
147
+ ]
148
+ },
131
149
  {
132
150
  "matcher": "mcp__.*__.*-(export|import)-parse$",
133
151
  "hooks": [
@@ -158,7 +176,8 @@ provision_account_dir() {
158
176
  "hooks": [
159
177
  { "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
160
178
  { "type": "command", "command": "bash $HOOKS_PATH/prompt-optimiser-directive.sh" },
161
- { "type": "command", "command": "bash $HOOKS_PATH/datetime-inject.sh" }
179
+ { "type": "command", "command": "bash $HOOKS_PATH/datetime-inject.sh" },
180
+ { "type": "command", "command": "bash $HOOKS_PATH/preference-consult-directive.sh" }
162
181
  ]
163
182
  }
164
183
  ],
@@ -36,6 +36,8 @@ ACCOUNTS_DIR="$INSTALL_DIR/data/accounts"
36
36
  . "$SCRIPT_DIR/lib/provision-account-dir.sh"
37
37
  # shellcheck source=lib/account-settings-askgate.sh
38
38
  . "$SCRIPT_DIR/lib/account-settings-askgate.sh"
39
+ # shellcheck source=lib/account-settings-pdf-conformance.sh
40
+ . "$SCRIPT_DIR/lib/account-settings-pdf-conformance.sh"
39
41
  # shellcheck source=lib/read-brand-json.sh
40
42
  . "$SCRIPT_DIR/lib/read-brand-json.sh"
41
43
  # Resolve brand-aware persistent users.json path before the resolver runs.
@@ -77,3 +79,14 @@ reconcile_all_accounts_owned_dirs "$ACCOUNTS_DIR"
77
79
  # so the gate reaches pre-1552 accounts. Idempotent; logs one [backfill-1683] line
78
80
  # per account.
79
81
  reconcile_all_accounts_askgate "$ACCOUNTS_DIR"
82
+
83
+ # ------------------------------------------------------------------
84
+ # 5. Retrofit the quote-render PDF-conformance matcher onto every account
85
+ # ------------------------------------------------------------------
86
+ # Standing check + durable backfill (Task 1929): provision_account_dir writes the
87
+ # PostToolUse browser-pdf-save matcher (quote-render-pdf-conformance.sh) only at
88
+ # provision time, and this script re-provisions only the house account. An
89
+ # account created before Task 1922 keeps a settings.json without the matcher and
90
+ # never runs the PDF gate. Reconcile every account on every install so the gate
91
+ # reaches pre-1922 accounts. Idempotent; logs one [backfill-1929] line per account.
92
+ reconcile_all_accounts_pdf_conformance "$ACCOUNTS_DIR"
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
@@ -2071,6 +2071,10 @@ export function buildHttpApp(deps) {
2071
2071
  }
2072
2072
  const byte = body.byte;
2073
2073
  const trigger = typeof body.trigger === 'string' ? body.trigger : 'manual';
2074
+ // Task 1920 — the stop button sets escalate:true so that when ESC proves
2075
+ // ineffective (turn blocked in a tool/subagent call) the manager escalates
2076
+ // once to Ctrl-C. Absent/false keeps the ESC-only re-send path.
2077
+ const escalate = body.escalate === true;
2074
2078
  const intId = `${sessionId.slice(0, 8)}-${start}`;
2075
2079
  deps.logger(`[webchat-interrupt] op=interrupt-requested intId=${intId} sessionId=${sessionId.slice(0, 8)} trigger=${trigger}`);
2076
2080
  const result = writeInterruptToPty(sessionId, byte, deps.logger);
@@ -2115,6 +2119,11 @@ export function buildHttpApp(deps) {
2115
2119
  let lastSendAt = sentAt;
2116
2120
  let escSends = 1; // the initial ESC already written above
2117
2121
  let canResend = true;
2122
+ // Task 1920 — the one-shot escalation guard. Seed it from the initial byte:
2123
+ // if the pre-loop write above already sent Ctrl-C (byte='ctrl-c'), the loop
2124
+ // must not send a second one. The invariant is "exactly one Ctrl-C ever per
2125
+ // call" because a second SIGINT can exit the CLI and kill the session.
2126
+ let ctrlCSent = byte === 'ctrl-c';
2118
2127
  while (Date.now() - sentAt < stopMs) {
2119
2128
  const st = deps.watcher.getBySessionId(sessionId)?.status;
2120
2129
  if (st !== 'busy') {
@@ -2122,14 +2131,34 @@ export function buildHttpApp(deps) {
2122
2131
  break;
2123
2132
  }
2124
2133
  if (canResend && Date.now() - lastSendAt >= reescalateMs) {
2125
- const re = writeInterruptToPty(sessionId, byte, deps.logger);
2126
- lastSendAt = Date.now();
2127
- if (re.ok) {
2128
- escSends += 1;
2129
- deps.logger(`[webchat-interrupt] op=interrupt-reescalated intId=${intId} sends=${escSends}`);
2134
+ if (escalate) {
2135
+ // Task 1920 — ESC ran for a full sub-window while still busy, so it is
2136
+ // ineffective (turn blocked in a tool/subagent call). Escalate once to
2137
+ // Ctrl-C (SIGINT reaches the harness mid-tool-call); never a second
2138
+ // time a second SIGINT can exit the CLI and kill the session. After
2139
+ // the one Ctrl-C, stop sending and just poll for busy-clear.
2140
+ if (!ctrlCSent) {
2141
+ const esc = writeInterruptToPty(sessionId, 'ctrl-c', deps.logger);
2142
+ lastSendAt = Date.now();
2143
+ if (esc.ok) {
2144
+ ctrlCSent = true;
2145
+ deps.logger(`[webchat-interrupt] op=interrupt-escalated intId=${intId} mechanism=ctrl-c sends=${escSends + 1}`);
2146
+ }
2147
+ else {
2148
+ canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2149
+ }
2150
+ }
2130
2151
  }
2131
2152
  else {
2132
- canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2153
+ const re = writeInterruptToPty(sessionId, byte, deps.logger);
2154
+ lastSendAt = Date.now();
2155
+ if (re.ok) {
2156
+ escSends += 1;
2157
+ deps.logger(`[webchat-interrupt] op=interrupt-reescalated intId=${intId} sends=${escSends}`);
2158
+ }
2159
+ else {
2160
+ canResend = false; // child vanished mid-interrupt; the next status read exits the loop
2161
+ }
2133
2162
  }
2134
2163
  }
2135
2164
  await new Promise((r) => setTimeout(r, 120));