@rubytech/create-realagent-code 0.1.568 → 0.1.570

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +7 -5
  3. package/payload/platform/plugins/admin/hooks/__tests__/pdf-text-layer-inject.test.sh +177 -0
  4. package/payload/platform/plugins/admin/hooks/__tests__/quote-path-write-gate.test.sh +152 -0
  5. package/payload/platform/plugins/admin/hooks/__tests__/quote-pdf-bash-gate.test.sh +95 -0
  6. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +141 -45
  7. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +114 -0
  8. package/payload/platform/plugins/admin/hooks/pdf-text-layer-inject.sh +137 -0
  9. package/payload/platform/plugins/admin/hooks/quote-path-write-gate.sh +137 -0
  10. package/payload/platform/plugins/admin/hooks/quote-pdf-bash-gate.sh +70 -0
  11. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +105 -37
  12. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +178 -0
  13. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.d.ts.map +1 -1
  14. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js +50 -4
  15. package/payload/platform/plugins/admin/mcp/dist/lib/roster-census.js.map +1 -1
  16. package/payload/platform/plugins/admin/skills/a4-print-documents/SKILL.md +12 -1
  17. package/payload/platform/plugins/admin/skills/a4-print-documents/pdf-inspect.mjs +113 -0
  18. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +14 -0
  19. package/payload/platform/plugins/docs/references/admin-session.md +15 -0
  20. package/payload/platform/scripts/__tests__/mailbox-inject-registered.test.sh +19 -43
  21. package/payload/platform/scripts/__tests__/preference-hooks-registered.test.sh +3 -1
  22. package/payload/platform/scripts/__tests__/public-prompt-gate-registered.test.sh +22 -37
  23. package/payload/platform/scripts/__tests__/public-surface-registered.test.sh +12 -35
  24. package/payload/platform/scripts/__tests__/quote-ownership-contract.test.sh +86 -0
  25. package/payload/platform/scripts/lib/__tests__/account-settings-reconcile.test.sh +73 -0
  26. package/payload/platform/scripts/lib/account-settings-reconcile.sh +88 -0
  27. package/payload/platform/scripts/lib/provision-account-dir.sh +11 -144
  28. package/payload/platform/scripts/setup-account.sh +11 -53
  29. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts +20 -0
  30. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js +78 -1
  32. package/payload/platform/services/claude-session-manager/dist/account-agent-registry.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts +129 -0
  34. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js +344 -0
  36. package/payload/platform/services/claude-session-manager/dist/blocked-tool-census.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/index.js +107 -0
  38. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts +42 -0
  40. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +215 -0
  42. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -0
  43. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts +108 -0
  44. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.d.ts.map +1 -0
  45. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js +299 -0
  46. package/payload/platform/services/claude-session-manager/dist/specialist-preference-audit.js.map +1 -0
  47. package/payload/platform/templates/account-settings.json +159 -0
  48. package/payload/server/server.js +750 -656
  49. package/payload/platform/scripts/lib/__tests__/account-settings-askgate.test.sh +0 -144
  50. package/payload/platform/scripts/lib/__tests__/account-settings-mailbox-inject.test.sh +0 -166
  51. package/payload/platform/scripts/lib/__tests__/account-settings-pdf-conformance.test.sh +0 -151
  52. package/payload/platform/scripts/lib/__tests__/account-settings-public-surface.test.sh +0 -109
  53. package/payload/platform/scripts/lib/account-settings-askgate.sh +0 -98
  54. package/payload/platform/scripts/lib/account-settings-mailbox-inject.sh +0 -91
  55. package/payload/platform/scripts/lib/account-settings-pdf-conformance.sh +0 -98
  56. package/payload/platform/scripts/lib/account-settings-public-surface.sh +0 -109
@@ -9,6 +9,20 @@ 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.570)
13
+
14
+ - Reading a PDF now injects the text layer, so values copied from a PDF are not transcribed by eye any more.
15
+ - The four one-hook account-settings backfill libs have been retired; hook registration is now driven from the account-settings template.
16
+ - The operator is now notified when a tool call stays blocked, with a per-account census, and the notification is scoped to the blocked session's own account.
17
+
18
+ ## 2026-08-06 (0.1.569)
19
+
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+
12
26
  ## 2026-08-06 (0.1.568)
13
27
 
14
28
  - 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.
@@ -182,6 +182,21 @@ grep -E "\[(pty-spawn-tool-inventory|recorder-session-init|recorder-turn|mcp:(me
182
182
 
183
183
  **Loopback gate.** The `/api/admin/log-ingest` route is loopback-only on both the socket address and (when present) the `X-Forwarded-For` header. A LAN client whose request reaches the route via a proxy still gets a 403 because XFF tokens are required to be loopback when XFF is set. Without this gate the evidence chain would be worthless — any LAN client could forge `[recorder-session-init]` lines under a spoofed sessionId.
184
184
 
185
+ ## A session that stopped with the PTY alive
186
+
187
+ Claude Code raises a permission card for some checks even under `permissionMode=bypassPermissions`: `dangerousRemoval` is `bypassImmune: true` in its safety table, so no allow rule and no permission mode pre-approves it. When such a card is raised inside a background subagent, its only prompt surface is the parent session's UI, so an operator on WhatsApp or Telegram cannot see or answer it and the session simply stops.
188
+
189
+ The manager's blocked-tool census reconciles this against live state every 60s. It reads a bounded tail of each live session's own transcript and of every `<sessionId>/subagents/agent-*.jsonl`, and treats an assistant `tool_use` with no matching `tool_result` as blocked once that transcript has not advanced for 120s. Three line shapes, all under `[blocked-tool]`:
190
+
191
+ - `op=census sessions=<n> scanned=<n> blocked=<n>` — every tick, including zero. A gap in this line means the census stopped, not that nothing was blocked. `sessions` counts live PTYs and `scanned` counts transcripts actually read, so `sessions=3 scanned=0` means no transcript could be resolved for any live session, which is a fault rather than a quiet box.
192
+ - `op=blocked sessionId=<id8> agentId=<id> toolUseId=<id> tool=<name> blockedForMs=<n>` — once per blocked call. `agentId=—` means the card is in the session's own transcript, where the operator can see it in the session UI; `agent-<id>` means it is inside that background subagent, where they cannot. `blockedForMs` is the age of the transcript's last record, not a timer on the call.
193
+ - `op=cleared` with the same ids, once the result lands on a still-live session.
194
+ - `op=session-gone` with the same ids when the session stopped being live with the card still unanswered. This is deliberately not `op=cleared`: the call never resolved, and conflating the two would read as "the block lifted".
195
+
196
+ At 180s blocked the manager sends one notification through the loopback route `/api/admin/session-blocked`, which is gated the same way as `log-ingest`. `op=blocked-notify channel=<name> delivered=<bool>` records where it went and whether it landed. `channel=whatsapp` or `telegram` means the session's own admin door; `channel=house-admin` means the house admin phones, which is what a public session always gets — a public session's own channel is a customer and never receives internal machinery. `delivered=false` with `channel=none` means the manager could not reach the route at all.
197
+
198
+ The notification cannot answer the card. Answer it in the session's PTY or in Claude Desktop.
199
+
185
200
  ## Out of scope
186
201
 
187
202
  - Public/WhatsApp/Telegram sessions — out of scope. Their sessionKeys remain `crypto.randomUUID()` and follow the existing rejection-on-restart contract.
@@ -1,32 +1,26 @@
1
1
  #!/usr/bin/env bash
2
- # Task 2157 — the hook must be registered in BOTH writers, with the SAME resolved
3
- # command string. The provision-time heredoc is a frequent merge-conflict site,
4
- # and the backfill lib is the only thing that reaches client sub-accounts, so
5
- # losing either is silent. Drift between the two is worse than losing one: the
6
- # backfill would not recognise what provision wrote, and would append a second
7
- # copy to every house account on every install.
2
+ # Task 2157 — the hook must be registered in the account-settings template, in
3
+ # the UserPromptSubmit event, with a fully resolved command path. The template
4
+ # is the single writer since Task 2467, and Task 2475 retired the backfill lib
5
+ # that used to be the second one, so losing the entry here loses it everywhere.
8
6
  #
9
- # Substring greps are deliberately avoided for the command itself: the lib's own
10
- # filename contains "mailbox-inject.sh", so a bare grep for that is satisfied by
11
- # a comment and proves nothing about what the lib would write.
7
+ # Substring greps are deliberately avoided for the command itself: a bare grep
8
+ # for "mailbox-inject.sh" is satisfied by a comment and proves nothing about
9
+ # what would be written.
12
10
  set -uo pipefail
13
11
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
- PROV="$DIR/../lib/provision-account-dir.sh"
15
- BACKFILL="$DIR/../lib/account-settings-mailbox-inject.sh"
16
- SETUP="$DIR/../setup-account.sh"
12
+ PROV="$DIR/../../templates/account-settings.json"
17
13
  PASS=0; FAIL=0
18
14
 
19
15
  ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
20
16
  bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL+1)); }
21
17
 
22
- # What provision-account-dir.sh writes into the settings heredoc. HOOKS_PATH is
23
- # assigned there as the literal string $PLATFORM_ROOT/plugins/admin/hooks, so the
24
- # resolved command is the same string the backfill lib holds.
25
- PROV_LINE='{ "type": "command", "command": "bash $HOOKS_PATH/mailbox-inject.sh" }'
26
- RESOLVED='bash $PLATFORM_ROOT/plugins/admin/hooks/mailbox-inject.sh'
18
+ # The exact line the template must carry. $PLATFORM_ROOT is a literal in the
19
+ # settings file, resolved by Claude Code at hook-run time.
20
+ PROV_LINE='{ "type": "command", "command": "bash $PLATFORM_ROOT/plugins/admin/hooks/mailbox-inject.sh" }'
27
21
 
28
22
  if /usr/bin/grep -qF "$PROV_LINE" "$PROV"; then
29
- ok "provision-account-dir.sh writes the hook into a UserPromptSubmit entry"
23
+ ok "the template writes the hook into a UserPromptSubmit entry"
30
24
  else
31
25
  bad "exact provision command line absent from $PROV"
32
26
  fi
@@ -44,36 +38,18 @@ else
44
38
  bad "mailbox-inject.sh is not inside the UserPromptSubmit array in $PROV"
45
39
  fi
46
40
 
47
- # The backfill lib must hold the RESOLVED form of that same command.
48
- if /usr/bin/grep -qF "_MBXINJECT_CMD='$RESOLVED'" "$BACKFILL"; then
49
- ok "the backfill lib writes the same resolved command string"
41
+ # The command must be fully resolved in the template. An unresolved HOOKS_PATH
42
+ # indirection would leave the path to be expanded by whoever copies the file,
43
+ # and the account would carry a hook command that resolves to nothing.
44
+ if ! /usr/bin/grep -q 'HOOKS_PATH' "$PROV"; then
45
+ ok "the template carries no unresolved hook-path indirection"
50
46
  else
51
- bad "backfill _MBXINJECT_CMD is not '$RESOLVED' in $BACKFILL"
52
- fi
53
-
54
- # HOOKS_PATH must still resolve to the path the backfill lib assumes; if someone
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"
58
- else
59
- bad "HOOKS_PATH in $PROV no longer matches the backfill lib assumption"
60
- fi
61
-
62
- if /usr/bin/grep -qF 'account-settings-mailbox-inject.sh' "$SETUP"; then
63
- ok "backfill lib sourced by setup-account.sh"
64
- else
65
- bad "backfill lib not sourced by $SETUP"
66
- fi
67
-
68
- if /usr/bin/grep -qE '^reconcile_all_accounts_mailbox_inject "\$ACCOUNTS_DIR"' "$SETUP"; then
69
- ok "backfill invoked over every account by setup-account.sh"
70
- else
71
- bad "reconcile_all_accounts_mailbox_inject not invoked in $SETUP"
47
+ bad "$PROV still carries a HOOKS_PATH indirection, so the written command would not resolve"
72
48
  fi
73
49
 
74
50
  echo "----"
75
51
  echo "PASS=$PASS FAIL=$FAIL"
76
- if [ $((PASS+FAIL)) -ne 6 ]; then
52
+ if [ $((PASS+FAIL)) -ne 3 ]; then
77
53
  echo "FAIL: expected 6 checks, ran $((PASS+FAIL))" >&2
78
54
  exit 1
79
55
  fi
@@ -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"
@@ -4,12 +4,12 @@
4
4
  # event and does not reproduce on demand: the only symptom is a stranger being
5
5
  # told something about the operator.
6
6
  #
7
- # Both writers are scanned. provision-account-dir.sh writes the array at account
8
- # creation; the account-settings-*.sh libs patch it onto accounts that already
9
- # exist, which is the only path that reaches client sub-accounts. Of the four
10
- # libs only mailbox-inject and public-surface touch this event askgate patches
11
- # PreToolUse and pdf-conformance patches PostToolUse, so the filter skipping
12
- # them is correct rather than a broken parser.
7
+ # The template is the single writer: provision-account-dir.sh copies
8
+ # platform/templates/account-settings.json at account-creation time, and
9
+ # setup-account.sh's reconcile_all_accounts_settings converges every existing
10
+ # account against the same file on every install. Task 2475 retired the four
11
+ # one-hook backfill libs that used to patch this array, so the template is the
12
+ # only place a UserPromptSubmit hook can be registered.
13
13
  #
14
14
  # The guard is matched as a fixed string, never as a mention of the variable: a
15
15
  # hook that merely READS MAXY_PUBLIC_ALLOWED_TOOLS is not gated by it, and
@@ -17,6 +17,7 @@
17
17
  set -uo pipefail
18
18
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
19
  PROV="$DIR/../lib/provision-account-dir.sh"
20
+ TPL="$DIR/../../templates/account-settings.json"
20
21
  HOOKS_DIR="$DIR/../../plugins/admin/hooks"
21
22
  GUARD='[ -n "${MAXY_PUBLIC_ALLOWED_TOOLS:-}" ]'
22
23
  INERT="archive-ingest-surface-gate.sh public-tool-surface.sh"
@@ -25,44 +26,28 @@ PASS=0; FAIL=0
25
26
  ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
26
27
  bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL+1)); }
27
28
 
28
- # Every hook script named inside the provision heredoc's UserPromptSubmit array.
29
- # The script name is taken from the END of each `"command"` string, not from a
30
- # `hooks/` prefix: provision writes `bash $HOOKS_PATH/<name>.sh`, so the literal
31
- # directory never appears there and a prefix anchor silently matches nothing.
32
- PROV_HOOKS=$(python3 - "$PROV" <<'PY'
33
- import re, sys
34
- src = open(sys.argv[1], encoding="utf-8").read()
35
- m = re.search(r'"UserPromptSubmit"\s*:\s*\[(.*?)\n \]', src, re.S)
36
- if not m:
37
- print("")
38
- sys.exit(0)
39
- cmds = re.findall(r'"command"\s*:\s*"([^"]*)"', m.group(1))
40
- names = [n.group(1) for n in (re.search(r'([A-Za-z0-9_.-]+\.sh)\s*$', c.strip()) for c in cmds) if n]
29
+ # Every hook script named inside the template's UserPromptSubmit array. The
30
+ # script name is taken from the END of each `"command"` string, not from a
31
+ # `hooks/` prefix: the template writes `bash $PLATFORM_ROOT/plugins/admin/hooks/
32
+ # <name>.sh`, and anchoring on a literal directory would break the first time
33
+ # that prefix is spelled differently.
34
+ TPL_HOOKS=$(python3 - "$TPL" <<'PY'
35
+ import json, re, sys
36
+ doc = json.load(open(sys.argv[1], encoding="utf-8"))
37
+ cmds = [h.get("command", "")
38
+ for entry in doc.get("hooks", {}).get("UserPromptSubmit", [])
39
+ for h in entry.get("hooks", [])]
40
+ names = [m.group(1) for m in (re.search(r'([A-Za-z0-9_.-]+\.sh)\s*$', c.strip()) for c in cmds) if m]
41
41
  print("\n".join(sorted(set(names))))
42
42
  PY
43
43
  )
44
44
 
45
- # Every hook script named by a backfill lib that patches the same array. Read
46
- # only from the `_<NAME>_CMD=` constant each lib registers, not from every `.sh`
47
- # in the file: these libs name their siblings in prose, and a bare `.sh` sweep
48
- # turns those comments into phantom registrations.
49
- BACKFILL_HOOKS=""
50
- for lib in "$DIR"/../lib/account-settings-*.sh; do
51
- grep -q 'hooks\.UserPromptSubmit' "$lib" || continue
52
- cmd_names=$(grep -oE "^_[A-Z0-9_]+_CMD='[^']*'" "$lib" | grep -oE "[A-Za-z0-9_.-]+\.sh'$" | tr -d "'")
53
- if [ -z "$cmd_names" ]; then
54
- bad "$(basename "$lib") patches UserPromptSubmit but names no _<NAME>_CMD script — the parser cannot see what it registers"
55
- fi
56
- BACKFILL_HOOKS="$BACKFILL_HOOKS
57
- $cmd_names"
58
- done
59
-
60
- ALL=$(printf '%s\n%s\n' "$PROV_HOOKS" "$BACKFILL_HOOKS" | grep -v '^$' | sort -u)
45
+ ALL=$(printf '%s\n' "$TPL_HOOKS" | grep -v '^$' | sort -u)
61
46
 
62
47
  if [ -z "$ALL" ]; then
63
- bad "no UserPromptSubmit hooks found in either writer — the parser is broken, not the tree"
48
+ bad "no UserPromptSubmit hooks found in the template — the parser is broken, not the tree"
64
49
  else
65
- ok "found $(printf '%s\n' "$ALL" | wc -l | tr -d ' ') registered UserPromptSubmit hooks across both writers"
50
+ ok "found $(printf '%s\n' "$ALL" | wc -l | tr -d ' ') registered UserPromptSubmit hooks in the template"
66
51
  fi
67
52
 
68
53
  for h in $ALL; do
@@ -1,25 +1,20 @@
1
1
  #!/usr/bin/env bash
2
- # Task 2339 — the hook must be registered in BOTH writers, on BOTH events, with
3
- # the SAME resolved command string. The provision-time heredoc is a frequent
4
- # merge-conflict site and the backfill lib is the only thing that reaches client
5
- # sub-accounts, so losing either is silent. Drift between them is worse than
6
- # losing one: the backfill would not recognise what provision wrote and would
7
- # append a second copy to every house account on every install.
2
+ # Task 2339 — the hook must be registered in the account-settings template, on
3
+ # all three events, with a fully resolved command path. The template is the
4
+ # single writer since Task 2467, and Task 2475 retired the backfill lib that
5
+ # used to be the second one, so losing an entry here loses it everywhere.
8
6
  set -uo pipefail
9
7
  DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
- PROV="$DIR/../lib/provision-account-dir.sh"
11
- BACKFILL="$DIR/../lib/account-settings-public-surface.sh"
12
- SETUP="$DIR/../setup-account.sh"
8
+ PROV="$DIR/../../templates/account-settings.json"
13
9
  PASS=0; FAIL=0
14
10
 
15
11
  ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
16
12
  bad() { echo "FAIL: $1" >&2; FAIL=$((FAIL+1)); }
17
13
 
18
- PROV_LINE='{ "type": "command", "command": "bash $HOOKS_PATH/public-tool-surface.sh" }'
19
- RESOLVED='bash $PLATFORM_ROOT/plugins/admin/hooks/public-tool-surface.sh'
14
+ PROV_LINE='{ "type": "command", "command": "bash $PLATFORM_ROOT/plugins/admin/hooks/public-tool-surface.sh" }'
20
15
 
21
16
  if [ "$(/usr/bin/grep -cF "$PROV_LINE" "$PROV")" = "3" ]; then
22
- ok "provision-account-dir.sh writes the hook exactly three times"
17
+ ok "the template writes the hook exactly three times"
23
18
  else
24
19
  bad "expected 3 provision command lines in $PROV"
25
20
  fi
@@ -69,34 +64,16 @@ else
69
64
  bad "public-tool-surface.sh is not inside the UserPromptSubmit array in $PROV"
70
65
  fi
71
66
 
72
- if /usr/bin/grep -qF "_PUBSURFACE_CMD='$RESOLVED'" "$BACKFILL"; then
73
- ok "the backfill lib writes the same resolved command string"
67
+ if ! /usr/bin/grep -q 'HOOKS_PATH' "$PROV"; then
68
+ ok "the template carries no unresolved hook-path indirection"
74
69
  else
75
- bad "backfill _PUBSURFACE_CMD is not '$RESOLVED' in $BACKFILL"
76
- fi
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"
80
- else
81
- bad "HOOKS_PATH in $PROV no longer matches the backfill lib assumption"
82
- fi
83
-
84
- if /usr/bin/grep -qF 'account-settings-public-surface.sh' "$SETUP"; then
85
- ok "backfill lib sourced by setup-account.sh"
86
- else
87
- bad "backfill lib not sourced by $SETUP"
88
- fi
89
-
90
- if /usr/bin/grep -qE '^reconcile_all_accounts_public_surface "\$ACCOUNTS_DIR"' "$SETUP"; then
91
- ok "backfill invoked over every account by setup-account.sh"
92
- else
93
- bad "reconcile_all_accounts_public_surface not invoked in $SETUP"
70
+ bad "$PROV still carries a HOOKS_PATH indirection, so the written command would not resolve"
94
71
  fi
95
72
 
96
73
  echo "----"
97
74
  echo "PASS=$PASS FAIL=$FAIL"
98
- if [ $((PASS+FAIL)) -ne 8 ]; then
99
- echo "FAIL: expected 8 checks, ran $((PASS+FAIL))" >&2
75
+ if [ $((PASS+FAIL)) -ne 5 ]; then
76
+ echo "FAIL: expected 5 checks, ran $((PASS+FAIL))" >&2
100
77
  exit 1
101
78
  fi
102
79
  [ "$FAIL" -eq 0 ]
@@ -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
+ }