@rubytech/create-maxy-code 0.1.573 → 0.1.574
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/lib/admins-write/__tests__/index.test.ts +97 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts +12 -0
- package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/admins-write/dist/index.js +27 -5
- package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
- package/payload/platform/lib/admins-write/src/index.ts +35 -5
- package/payload/platform/lib/admins-write/vitest.config.ts +14 -0
- package/payload/platform/lib/dispatch-read/dist/session.d.ts.map +1 -1
- package/payload/platform/lib/dispatch-read/dist/session.js +19 -4
- package/payload/platform/lib/dispatch-read/dist/session.js.map +1 -1
- package/payload/platform/lib/dispatch-read/src/__tests__/session.test.ts +91 -0
- package/payload/platform/lib/dispatch-read/src/session.ts +25 -6
- package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +8 -3
- package/payload/platform/plugins/admin/hooks/__tests__/html-structure-gate.test.sh +264 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +122 -24
- package/payload/platform/plugins/admin/hooks/__tests__/specialist-dispatch-gate.test.sh +158 -0
- package/payload/platform/plugins/admin/hooks/html-structure-gate.sh +165 -0
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +111 -19
- package/payload/platform/plugins/admin/hooks/specialist-dispatch-gate.sh +159 -0
- package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts +27 -0
- package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js +29 -0
- package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts +11 -0
- package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/binding-validate.js +20 -0
- package/payload/platform/plugins/admin/mcp/dist/binding-validate.js.map +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +133 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +32 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +157 -46
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +71 -31
- package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +38 -13
- package/payload/platform/plugins/docs/references/internals.md +6 -0
- package/payload/platform/plugins/docs/references/outlook-guide.md +31 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
- package/payload/platform/plugins/outlook/PLUGIN.md +3 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js +177 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js +130 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js +162 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js +168 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts +64 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js +110 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts +119 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js +0 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts +8 -0
- package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js +247 -0
- package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js +10 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +21 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts +16 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +5 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +4 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +2 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +24 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +2 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +29 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js +21 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts +13 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js +10 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +54 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts +32 -0
- package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js +144 -0
- package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts +44 -0
- package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/html-structure-census.js +185 -0
- package/payload/platform/services/claude-session-manager/dist/html-structure-census.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.js +125 -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.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +7 -0
- package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts +42 -0
- package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/quote-print-census.js +254 -0
- package/payload/platform/services/claude-session-manager/dist/quote-print-census.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts +92 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-census.js +227 -0
- package/payload/platform/services/claude-session-manager/dist/specialist-census.js.map +1 -0
- package/payload/platform/templates/account-settings.json +8 -4
- package/payload/server/server.js +465 -361
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for specialist-dispatch-gate.sh.
|
|
3
|
+
#
|
|
4
|
+
# An account's own specialists own their tools: the admin does not do their work
|
|
5
|
+
# by hand. The gate refuses a main-conversation call to a tool one of the
|
|
6
|
+
# account's authored cards declares, and names the specialist to dispatch.
|
|
7
|
+
#
|
|
8
|
+
# Two narrowings are load-bearing and both are asserted here. Only cards the
|
|
9
|
+
# ACCOUNT authored count — provisioning copies every shipped template in, and one
|
|
10
|
+
# of them declares roughly 130 tools including the whole email surface, so
|
|
11
|
+
# reading every card would refuse the admin nearly its entire seat. And native
|
|
12
|
+
# tools are never gated, because every card declares some of them.
|
|
13
|
+
#
|
|
14
|
+
# Caller identity is `agent_type`, which Claude Code sets when a hook fires
|
|
15
|
+
# inside a subagent and leaves empty in the main conversation.
|
|
16
|
+
#
|
|
17
|
+
# Covers:
|
|
18
|
+
# A. main conversation, authored tool → BLOCKED, names the specialist
|
|
19
|
+
# B. same call inside a subagent → ALLOWED (never filters a subagent)
|
|
20
|
+
# C. a native tool the card also declares → ALLOWED
|
|
21
|
+
# D. a tool only a shipped template declares → ALLOWED
|
|
22
|
+
# E. a tool only a premium bundle declares → ALLOWED
|
|
23
|
+
# F. an account with no authored cards → ALLOWED
|
|
24
|
+
# G. empty stdin → ALLOWED
|
|
25
|
+
# H. unparseable envelope → ALLOWED
|
|
26
|
+
# I. an unreadable specialists dir → ALLOWED, op=degraded on stderr
|
|
27
|
+
# J. a card under the ACCOUNT's own plugin root → BLOCKED (still the account's own)
|
|
28
|
+
# K. the refuse line carries the account and the tool
|
|
29
|
+
# L. the native set matches CC_NATIVE_TOOLS in the session manager
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
HOOKS_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
33
|
+
GATE="$HOOKS_DIR/specialist-dispatch-gate.sh"
|
|
34
|
+
DRIFT_SOURCE="$(cd "$HOOKS_DIR/../../../services/claude-session-manager/src" && pwd)/specialist-drift.ts"
|
|
35
|
+
|
|
36
|
+
PASS=0
|
|
37
|
+
FAIL=0
|
|
38
|
+
ok() { PASS=$((PASS + 1)); echo " ok $1"; }
|
|
39
|
+
bad() { FAIL=$((FAIL + 1)); echo " FAIL $1"; }
|
|
40
|
+
|
|
41
|
+
ROOT="$(mktemp -d)"
|
|
42
|
+
trap 'chmod -R u+rwX "$ROOT" 2>/dev/null; rm -rf "$ROOT"' EXIT
|
|
43
|
+
|
|
44
|
+
ACCT="$ROOT/2078cb54aaaa"
|
|
45
|
+
PLATFORM="$ROOT/platform"
|
|
46
|
+
PREMIUM="$ROOT/premium-plugins"
|
|
47
|
+
mkdir -p "$ACCT/specialists/agents" \
|
|
48
|
+
"$PLATFORM/templates/specialists/agents" \
|
|
49
|
+
"$PLATFORM/plugins" \
|
|
50
|
+
"$PREMIUM/sitedesk/agents"
|
|
51
|
+
|
|
52
|
+
card() { # dir file name tools
|
|
53
|
+
mkdir -p "$1"
|
|
54
|
+
printf -- '---\nname: %s\ndescription: "d"\ntools: %s\n---\nbody\n' "$3" "$4" > "$1/$2"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# The account's own clerk: no copy in any shipped root.
|
|
58
|
+
card "$ACCT/specialists/agents" "glsmith--email-clerk.md" "email-clerk" \
|
|
59
|
+
"Read, Bash, mcp__plugin_email_email__email-draft"
|
|
60
|
+
# A shipped template, copied into the account by provisioning.
|
|
61
|
+
card "$ACCT/specialists/agents" "personal-assistant.md" "personal-assistant" \
|
|
62
|
+
"mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin"
|
|
63
|
+
card "$PLATFORM/templates/specialists/agents" "personal-assistant.md" "personal-assistant" \
|
|
64
|
+
"mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin"
|
|
65
|
+
# A premium bundle card, also copied in.
|
|
66
|
+
card "$ACCT/specialists/agents" "sitedesk--quoter.md" "quoter" \
|
|
67
|
+
"mcp__plugin_sitedesk-job_sitedesk-job__quote-render"
|
|
68
|
+
card "$PREMIUM/sitedesk/agents" "sitedesk--quoter.md" "quoter" \
|
|
69
|
+
"mcp__plugin_sitedesk-job_sitedesk-job__quote-render"
|
|
70
|
+
# An authored card whose durable copy lives under the ACCOUNT's own plugin root.
|
|
71
|
+
card "$ACCT/specialists/agents" "glsmith--order-clerk.md" "order-clerk" \
|
|
72
|
+
"mcp__plugin_work_work__work-create"
|
|
73
|
+
card "$ACCT/plugins/glsmith/agents" "glsmith--order-clerk.md" "order-clerk" \
|
|
74
|
+
"mcp__plugin_work_work__work-create"
|
|
75
|
+
|
|
76
|
+
envelope() { printf '{"tool_name":"%s","agent_type":%s,"tool_input":{}}' "$1" "$2"; }
|
|
77
|
+
|
|
78
|
+
# fire <cwd> <json> — sets RC and ERR
|
|
79
|
+
fire() {
|
|
80
|
+
ERR="$(cd "$1" && printf '%s' "$2" | PLATFORM_ROOT="$PLATFORM" bash "$GATE" 2>&1 >/dev/null)"
|
|
81
|
+
RC=$?
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# A
|
|
85
|
+
fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '""')"
|
|
86
|
+
if [ "$RC" = 2 ] && printf '%s' "$ERR" | grep -q 'email-clerk'; then
|
|
87
|
+
ok "A blocks the main conversation and names the specialist"
|
|
88
|
+
else bad "A rc=$RC err=$ERR"; fi
|
|
89
|
+
|
|
90
|
+
# B
|
|
91
|
+
fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '"specialists:email-clerk"')"
|
|
92
|
+
if [ "$RC" = 0 ]; then ok "B allows the same call inside a subagent"; else bad "B rc=$RC err=$ERR"; fi
|
|
93
|
+
|
|
94
|
+
# C
|
|
95
|
+
fire "$ACCT" "$(envelope Bash '""')"
|
|
96
|
+
if [ "$RC" = 0 ]; then ok "C allows a native tool the card declares"; else bad "C rc=$RC err=$ERR"; fi
|
|
97
|
+
|
|
98
|
+
# D
|
|
99
|
+
fire "$ACCT" "$(envelope mcp__plugin_whatsapp_whatsapp__whatsapp-send-admin '""')"
|
|
100
|
+
if [ "$RC" = 0 ]; then ok "D allows a tool only a shipped template declares"; else bad "D rc=$RC err=$ERR"; fi
|
|
101
|
+
|
|
102
|
+
# E
|
|
103
|
+
fire "$ACCT" "$(envelope mcp__plugin_sitedesk-job_sitedesk-job__quote-render '""')"
|
|
104
|
+
if [ "$RC" = 0 ]; then ok "E allows a tool only a premium bundle declares"; else bad "E rc=$RC err=$ERR"; fi
|
|
105
|
+
|
|
106
|
+
# F
|
|
107
|
+
EMPTY="$ROOT/empty"
|
|
108
|
+
mkdir -p "$EMPTY/specialists/agents"
|
|
109
|
+
fire "$EMPTY" "$(envelope mcp__plugin_email_email__email-draft '""')"
|
|
110
|
+
if [ "$RC" = 0 ]; then ok "F allows on an account with no authored cards"; else bad "F rc=$RC err=$ERR"; fi
|
|
111
|
+
|
|
112
|
+
# G
|
|
113
|
+
fire "$ACCT" ""
|
|
114
|
+
if [ "$RC" = 0 ]; then ok "G allows on empty stdin"; else bad "G rc=$RC err=$ERR"; fi
|
|
115
|
+
|
|
116
|
+
# H
|
|
117
|
+
fire "$ACCT" '{not json'
|
|
118
|
+
if [ "$RC" = 0 ]; then ok "H allows on an unparseable envelope"; else bad "H rc=$RC err=$ERR"; fi
|
|
119
|
+
|
|
120
|
+
# I — failing open is fine; failing open SILENTLY is the fault.
|
|
121
|
+
BROKEN="$ROOT/brokenacct"
|
|
122
|
+
mkdir -p "$BROKEN/specialists/agents"
|
|
123
|
+
chmod 000 "$BROKEN/specialists/agents"
|
|
124
|
+
fire "$BROKEN" "$(envelope mcp__plugin_email_email__email-draft '""')"
|
|
125
|
+
chmod 755 "$BROKEN/specialists/agents"
|
|
126
|
+
if [ "$RC" = 0 ] && printf '%s' "$ERR" | grep -q 'op=degraded'; then
|
|
127
|
+
ok "I fails open and says so"
|
|
128
|
+
else bad "I rc=$RC err=$ERR"; fi
|
|
129
|
+
|
|
130
|
+
# J
|
|
131
|
+
fire "$ACCT" "$(envelope mcp__plugin_work_work__work-create '""')"
|
|
132
|
+
if [ "$RC" = 2 ] && printf '%s' "$ERR" | grep -q 'order-clerk'; then
|
|
133
|
+
ok "J blocks a card whose durable copy is under the account plugin root"
|
|
134
|
+
else bad "J rc=$RC err=$ERR"; fi
|
|
135
|
+
|
|
136
|
+
# K
|
|
137
|
+
fire "$ACCT" "$(envelope mcp__plugin_email_email__email-draft '""')"
|
|
138
|
+
if printf '%s' "$ERR" | grep -q 'op=refuse account=2078cb54 tool=mcp__plugin_email_email__email-draft specialist=email-clerk'; then
|
|
139
|
+
ok "K the refuse line carries the account, the tool and the specialist"
|
|
140
|
+
else bad "K err=$ERR"; fi
|
|
141
|
+
|
|
142
|
+
# L — the hook cannot import the manager's TypeScript, so it mirrors
|
|
143
|
+
# CC_NATIVE_TOOLS as a python literal. A mirror that drifts silently un-gates or
|
|
144
|
+
# over-gates, so the two sets are compared rather than trusted.
|
|
145
|
+
TS_SET="$(sed -n '/export const CC_NATIVE_TOOLS/,/^\])/p' "$DRIFT_SOURCE" \
|
|
146
|
+
| grep -oE "'[A-Za-z]+'" | tr -d "'" | sort -u)"
|
|
147
|
+
SH_SET="$(sed -n '/^NATIVE = {/,/^}/p' "$GATE" \
|
|
148
|
+
| grep -oE '"[A-Za-z]+"' | tr -d '"' | sort -u)"
|
|
149
|
+
if [ "$TS_SET" = "$SH_SET" ]; then
|
|
150
|
+
ok "L the native set matches CC_NATIVE_TOOLS"
|
|
151
|
+
else
|
|
152
|
+
bad "L native sets differ:"
|
|
153
|
+
diff <(printf '%s\n' "$TS_SET") <(printf '%s\n' "$SH_SET") | sed 's/^/ /'
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
echo "──────── specialist-dispatch-gate summary ────────"
|
|
157
|
+
echo "pass=$PASS fail=$FAIL"
|
|
158
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# html-structure-gate — PreToolUse gate on Write / Edit / MultiEdit.
|
|
3
|
+
#
|
|
4
|
+
# Refuses a write that would produce an HTML document with a <style> element
|
|
5
|
+
# opened inside another one, or with more openers than closers.
|
|
6
|
+
#
|
|
7
|
+
# The fault is not cosmetic. HTML parses style content as raw text, so the FIRST
|
|
8
|
+
# </style> closes the outer element: everything after it becomes body content and
|
|
9
|
+
# renders as visible text, and the rule immediately after the stray opener is
|
|
10
|
+
# eaten as part of an invalid selector. One fault, two symptoms — a leaked
|
|
11
|
+
# stylesheet above the header and an unstyled box further down the same page.
|
|
12
|
+
#
|
|
13
|
+
# The detector has three rules, and the same three are implemented in the
|
|
14
|
+
# session-manager census that walks what is already on disk:
|
|
15
|
+
# 1. HTML comments are stripped first, each replaced by its own newlines, so a
|
|
16
|
+
# <style> inside <!-- … --> is not scanned and line numbers do not shift.
|
|
17
|
+
# That comment case is real: two account templates carry one and are clean,
|
|
18
|
+
# and a comment-blind detector reports both.
|
|
19
|
+
# 2. <style…> and </style…> are walked case-insensitively, tracking depth. An
|
|
20
|
+
# opener seen at depth > 0 is the fault.
|
|
21
|
+
# 3. At end of input, openers must equal closers; an unmatched opener is
|
|
22
|
+
# reported at its own line.
|
|
23
|
+
#
|
|
24
|
+
# It walks the document the call would PRODUCE, not the fragment it carries. For
|
|
25
|
+
# Write that is `content`; for Edit and MultiEdit the on-disk file is read and
|
|
26
|
+
# the replacements applied in memory first. Walking a fragment cannot work: rule
|
|
27
|
+
# 3 would refuse every partial edit, and a nested opener whose outer <style> is
|
|
28
|
+
# already on disk would pass.
|
|
29
|
+
#
|
|
30
|
+
# Scope is the whole account with an exclusion list, never a bucket list: every
|
|
31
|
+
# .html/.htm resolving inside the account dir except under node_modules/,
|
|
32
|
+
# .wrangler/, .tool/ and .trash/. A bucket list is what let the live document
|
|
33
|
+
# through — it sat under sites/<project>/public/, which no quote bucket covers.
|
|
34
|
+
# The account dir is this hook's cwd, as in fs-schema-guard.sh.
|
|
35
|
+
#
|
|
36
|
+
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
|
|
37
|
+
# uninspectable envelope — tty, empty stdin, unreadable target, unparseable edit.
|
|
38
|
+
# A gate that bricks writes on a parse failure is worse than the fault it catches.
|
|
39
|
+
#
|
|
40
|
+
# The observability line is a direct append to the resolved server.log, not an
|
|
41
|
+
# echo: a hook's stderr reaches no sink on an allow, so a stderr-only line would
|
|
42
|
+
# be an audit nothing reads. No task numbers or internal refs in operator text.
|
|
43
|
+
|
|
44
|
+
set -uo pipefail
|
|
45
|
+
|
|
46
|
+
# No envelope (tty or empty stdin) → allow: cannot inspect, must not brick writes.
|
|
47
|
+
if [ -t 0 ]; then exit 0; fi
|
|
48
|
+
INPUT=$(cat)
|
|
49
|
+
[ -z "$INPUT" ] && exit 0
|
|
50
|
+
|
|
51
|
+
VERDICT=$(INPUT="$INPUT" ACCT="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
52
|
+
import os, re, json, sys
|
|
53
|
+
|
|
54
|
+
COMMENT = re.compile(r"<!--.*?-->", re.S)
|
|
55
|
+
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
|
56
|
+
TAG = re.compile(r"<\s*(/)?\s*style\b[^>]*>", re.I)
|
|
57
|
+
EXCLUDED = ("node_modules", ".wrangler", ".tool", ".trash")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fault_line(s):
|
|
61
|
+
"""Line of the first nested or unmatched <style> opener, else None."""
|
|
62
|
+
t = COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), s)
|
|
63
|
+
spans = [(m.start(), m.end()) for m in CSS_COMMENT.finditer(t)]
|
|
64
|
+
depth = 0
|
|
65
|
+
open_line = 0
|
|
66
|
+
for m in TAG.finditer(t):
|
|
67
|
+
line = t.count("\n", 0, m.start()) + 1
|
|
68
|
+
if m.group(1):
|
|
69
|
+
if depth > 0:
|
|
70
|
+
depth -= 1
|
|
71
|
+
elif depth > 0:
|
|
72
|
+
# A CSS comment protects an opener but never a closer. Style content
|
|
73
|
+
# is raw text to the HTML parser, so </style> terminates the element
|
|
74
|
+
# wherever it appears; an opener is never a parse event there, only
|
|
75
|
+
# text the CSS parser reads as an invalid selector — and inside a
|
|
76
|
+
# comment it is discarded, so nothing is eaten.
|
|
77
|
+
if any(a <= m.start() < b for a, b in spans):
|
|
78
|
+
continue
|
|
79
|
+
return line
|
|
80
|
+
else:
|
|
81
|
+
depth = 1
|
|
82
|
+
open_line = line
|
|
83
|
+
return open_line if depth > 0 else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
d = json.loads(os.environ["INPUT"])
|
|
88
|
+
except Exception:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
if (d.get("tool_name", "") or "") not in ("Write", "Edit", "MultiEdit"):
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
tool = d["tool_name"]
|
|
93
|
+
ti = d.get("tool_input", {}) or {}
|
|
94
|
+
target = ti.get("file_path") or ""
|
|
95
|
+
if not target:
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
acct = os.path.realpath(os.environ["ACCT"])
|
|
99
|
+
ab = os.path.realpath(target if os.path.isabs(target) else os.path.join(acct, target))
|
|
100
|
+
if not ab.startswith(acct + os.sep):
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
rel = os.path.relpath(ab, acct)
|
|
103
|
+
if os.path.splitext(rel)[1].lower() not in (".html", ".htm"):
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
if any(p in EXCLUDED for p in rel.split(os.sep)):
|
|
106
|
+
sys.exit(0)
|
|
107
|
+
|
|
108
|
+
if tool == "Write":
|
|
109
|
+
produced = ti.get("content")
|
|
110
|
+
if not isinstance(produced, str):
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
with open(ab, encoding="utf-8", errors="replace") as fh:
|
|
115
|
+
produced = fh.read()
|
|
116
|
+
except Exception:
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
edits = ti.get("edits") if tool == "MultiEdit" else [ti]
|
|
119
|
+
if not isinstance(edits, list):
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
for e in edits:
|
|
122
|
+
if not isinstance(e, dict):
|
|
123
|
+
sys.exit(0)
|
|
124
|
+
old, new = e.get("old_string"), e.get("new_string")
|
|
125
|
+
if not isinstance(old, str) or not isinstance(new, str):
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
produced = produced.replace(old, new) if e.get("replace_all") else produced.replace(old, new, 1)
|
|
128
|
+
|
|
129
|
+
line = fault_line(produced)
|
|
130
|
+
if line is None:
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
print(line)
|
|
133
|
+
print(json.dumps(rel))
|
|
134
|
+
PY
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
[ -z "$VERDICT" ] && exit 0
|
|
138
|
+
LINE=$(printf '%s' "$VERDICT" | sed -n 1p)
|
|
139
|
+
QREL=$(printf '%s' "$VERDICT" | sed -n 2p)
|
|
140
|
+
[ -z "$LINE" ] && exit 0
|
|
141
|
+
|
|
142
|
+
# Sink ladder, as quote-render-pdf-conformance.sh: LOG_DIR is stamped on the
|
|
143
|
+
# rc-spawn path, CLAUDE_SESSION_MANAGER_PERSIST_DIR is on the systemd unit and
|
|
144
|
+
# resolves to the same directory, the account's own logs dir is the last resort.
|
|
145
|
+
#
|
|
146
|
+
# There is deliberately no hard-coded brand fallback. Four brands are co-resident
|
|
147
|
+
# on one box, each with its own ~/.<brand>/logs, so a literal would file this
|
|
148
|
+
# account's path in a SIBLING brand's server.log. When no rung resolves, the line
|
|
149
|
+
# is dropped: the refusal still blocks and still reaches the agent on stderr.
|
|
150
|
+
LOGD=""
|
|
151
|
+
if [ -n "${LOG_DIR:-}" ] && [ -d "${LOG_DIR}" ]; then
|
|
152
|
+
LOGD="$LOG_DIR"
|
|
153
|
+
elif [ -n "${CLAUDE_SESSION_MANAGER_PERSIST_DIR:-}" ] && [ -d "${CLAUDE_SESSION_MANAGER_PERSIST_DIR}/logs" ]; then
|
|
154
|
+
LOGD="$CLAUDE_SESSION_MANAGER_PERSIST_DIR/logs"
|
|
155
|
+
elif [ -d "$PWD/logs" ]; then
|
|
156
|
+
LOGD="$PWD/logs"
|
|
157
|
+
fi
|
|
158
|
+
if [ -n "$LOGD" ]; then
|
|
159
|
+
ID8=$(basename "$PWD" | cut -c1-8)
|
|
160
|
+
printf '[html-structure-gate] op=refuse account=%s file=%s line=%s\n' \
|
|
161
|
+
"$ID8" "$QREL" "$LINE" >>"$LOGD/server.log" 2>/dev/null || true
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
echo "Blocked: this document opens a <style> element inside another one (line $LINE). The browser closes the outer element at the first </style>, so the rest of the stylesheet renders as visible text on the page and the rule after the stray opener is discarded. Put the block in the body as its own <style> element, or merge its rules into the one already open." >&2
|
|
165
|
+
exit 2
|
|
@@ -4,20 +4,37 @@
|
|
|
4
4
|
# The companion to the PreToolUse quote-render-gate: that gate checks the
|
|
5
5
|
# rendered HTML on the deterministic path (via the render receipt) before the
|
|
6
6
|
# PDF is printed; the HTML the tool gated cannot yet be a PDF at PreToolUse, so
|
|
7
|
-
# the
|
|
7
|
+
# the PDF-only conformance checks run here, once the PDF exists.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# PAGE CHROME — receipt-free and path-agnostic, so it also covers template-seeded
|
|
10
|
+
# documents (budget estimates, valuations, works orders) that never pass through
|
|
11
|
+
# the quote renderer. Each check gates only a document that DECLARES the thing it
|
|
12
|
+
# checks, so a brochure is never touched:
|
|
13
|
+
# 1. a running header on every physical page, when the source declares a text
|
|
14
|
+
# margin box. Chrome decides pagination at print time, so a header placed
|
|
15
|
+
# next to a hand-declared page break misses every bleed page.
|
|
16
|
+
# 2. that header's image no wider than a third of the page, when the source
|
|
17
|
+
# declares an image margin box. Chromium paints a margin box's content:url()
|
|
18
|
+
# at intrinsic pixel size and does NOT scale it to the declared box.
|
|
19
|
+
# Both are offline, and both report before either exits, so one print surfaces
|
|
20
|
+
# both faults rather than the second appearing only after the first is reprinted.
|
|
21
|
+
#
|
|
22
|
+
# LINKS — for a gated client-quote PDF (memory/users/<phone>/documents/
|
|
23
|
+
# quote-<jobId>.pdf or ...-breakdown.pdf) whose render receipt names the source:
|
|
24
|
+
# 3. the PDF's /URI annotation count >= the source HTML's absolute-link count
|
|
12
25
|
# (a print that dropped links — a rasterised or flattened copy — carries
|
|
13
26
|
# fewer /URI than the HTML had anchors);
|
|
14
|
-
#
|
|
27
|
+
# 4. every embedded https:// anchor in the source HTML answers 200 (a pending
|
|
15
28
|
# redeploy that would 404 the Sign-Online link is caught before the PDF is
|
|
16
29
|
# sent).
|
|
17
30
|
# The /URI-count check is offline and runs first; the network fetch runs only
|
|
18
31
|
# when the count check passes, so a link-loss regression never depends on the
|
|
19
32
|
# network being up.
|
|
20
33
|
#
|
|
34
|
+
# Every path that inspects nothing emits `op=skip reason=…`: a silent return is
|
|
35
|
+
# indistinguishable from a gate that never ran, which is the state an account
|
|
36
|
+
# with a stale PostToolUse matcher is actually in.
|
|
37
|
+
#
|
|
21
38
|
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
|
|
22
39
|
# uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
|
|
23
40
|
# written): the no-receipt case is already the PreToolUse gate's to block, and
|
|
@@ -184,16 +201,73 @@ for p in range(first, pages + 1):
|
|
|
184
201
|
missing.append(p)
|
|
185
202
|
|
|
186
203
|
checked = pages - first + 1
|
|
204
|
+
print("LOG|op=pdf-header-conformance file=%s pages=%d pagesChecked=%d pagesHeaderless=%d"
|
|
205
|
+
% (base, pages, checked, len(missing)))
|
|
187
206
|
if missing:
|
|
188
|
-
print("
|
|
189
|
-
% (base,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
207
|
+
print("HBLOCK|%s|pagesHeaderless=%d pages=%s"
|
|
208
|
+
% (base, len(missing), ",".join(str(x) for x in missing)))
|
|
209
|
+
|
|
210
|
+
# --- running-image geometry -------------------------------------------------
|
|
211
|
+
# Chromium paints a page-margin box's content:url() at INTRINSIC pixel size (one
|
|
212
|
+
# image px to one CSS px at 96 px/in) and does NOT scale it to the box's declared
|
|
213
|
+
# width/height, so a full-resolution logo covers most of the page on every page
|
|
214
|
+
# the box paints. Measured 2026-08-05 on a real print: a 700x252 asset in a
|
|
215
|
+
# declared 34mm box rendered 525.0 pt wide on a 594.96 pt A4 page, 88%.
|
|
216
|
+
#
|
|
217
|
+
# The assertion is scoped to the RUNNING image — the object present on every
|
|
218
|
+
# checked page, which is what a running header is by construction — never to
|
|
219
|
+
# every image. A content photo legitimately runs wider: measured across three
|
|
220
|
+
# real quotes, running headers occupy 9.0% to 18.9% of page width while
|
|
221
|
+
# single-page content photos occupy 38.7% to 77.2%, so a whole-page threshold
|
|
222
|
+
# blocks legitimate documents while the recurrence-scoped one separates the
|
|
223
|
+
# correct case from the defect by more than four to one.
|
|
224
|
+
#
|
|
225
|
+
# This runs after the header verdict rather than instead of it, so one print
|
|
226
|
+
# reports a headerless page AND an oversized logo together. Both are offline, so
|
|
227
|
+
# the file's offline-before-network ordering still holds.
|
|
228
|
+
if re.search(r'@top-(?:left|center|right)\s*\{[^}]*?content\s*:\s*url\(', doc, re.S) and checked >= 2:
|
|
229
|
+
try:
|
|
230
|
+
listing = subprocess.run(["pdfimages", "-list", pdf],
|
|
231
|
+
capture_output=True, text=True, timeout=30).stdout
|
|
232
|
+
except Exception:
|
|
233
|
+
listing = ""
|
|
234
|
+
if not listing:
|
|
235
|
+
out("LOG|op=pdf-image-geometry file=%s pagesChecked=0 reason=no-poppler" % base)
|
|
236
|
+
# Columns, zero-based: 0 page, 3 width, 10 object, 12 x-ppi. A row whose
|
|
237
|
+
# object or ppi is not numeric (an inline image) is skipped rather than
|
|
238
|
+
# guessed at.
|
|
239
|
+
per = {}
|
|
240
|
+
for row in listing.splitlines()[2:]:
|
|
241
|
+
f = row.split()
|
|
242
|
+
if len(f) < 13:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
pg, wpx, obj, ppi = int(f[0]), int(f[3]), f[10], float(f[12])
|
|
246
|
+
except ValueError:
|
|
247
|
+
continue
|
|
248
|
+
if pg < first or pg > pages or ppi <= 0:
|
|
249
|
+
continue
|
|
250
|
+
seen = per.setdefault(obj, {})
|
|
251
|
+
seen[pg] = max(seen.get(pg, 0.0), wpx / ppi * 72.0)
|
|
252
|
+
limit = pw / 3.0
|
|
253
|
+
running = {o: max(v.values()) for o, v in per.items() if len(v) == checked}
|
|
254
|
+
widest = max(running.values()) if running else 0.0
|
|
255
|
+
over = sorted(w for w in running.values() if w > limit)
|
|
256
|
+
# Logged whether or not it fails: this is the line that decides the verdict.
|
|
257
|
+
print("LOG|op=pdf-image-geometry file=%s pagesChecked=%d runningImages=%d "
|
|
258
|
+
"widestPt=%.1f fracPage=%.1f%% limitPt=%.1f"
|
|
259
|
+
% (base, checked, len(running), widest, widest / pw * 100.0, limit))
|
|
260
|
+
if over:
|
|
261
|
+
print("GBLOCK|%s|widestPt=%.1f fracPage=%.1f%% limitPt=%.1f"
|
|
262
|
+
% (base, over[-1], over[-1] / pw * 100.0, limit))
|
|
263
|
+
sys.exit(0)
|
|
194
264
|
PY
|
|
195
265
|
)
|
|
196
266
|
|
|
267
|
+
# Both page-chrome faults are collected before exiting, so a document with a
|
|
268
|
+
# headerless page AND an oversized running logo reports both on one print rather
|
|
269
|
+
# than surfacing the second only after the first is fixed and reprinted.
|
|
270
|
+
BLOCKED=0
|
|
197
271
|
while IFS= read -r hline; do
|
|
198
272
|
case "$hline" in
|
|
199
273
|
LOG\|*) emit_log "${hline#LOG|}" ;;
|
|
@@ -203,32 +277,49 @@ while IFS= read -r hline; do
|
|
|
203
277
|
hdetail=${hrest#*|}
|
|
204
278
|
emit_log "op=bypass file=$hfile reason=pdf-header-conformance detail=$hdetail"
|
|
205
279
|
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
|
-
|
|
280
|
+
BLOCKED=1
|
|
281
|
+
;;
|
|
282
|
+
GBLOCK\|*)
|
|
283
|
+
grest=${hline#GBLOCK|}
|
|
284
|
+
gfile=${grest%%|*}
|
|
285
|
+
gdetail=${grest#*|}
|
|
286
|
+
emit_log "op=bypass file=$gfile reason=pdf-header-image-geometry detail=$gdetail"
|
|
287
|
+
echo "Blocked: the running header image on this printed document covers most of the page width ($gdetail). An image used as a page margin box's content is painted at its own pixel size and is not scaled down to the box you declared, so the image file itself has to be made the size the box expects. Shrink the asset, rebuild the document, print it again, and check a page after the first." >&2
|
|
288
|
+
BLOCKED=1
|
|
207
289
|
;;
|
|
208
290
|
esac
|
|
209
291
|
done <<< "$HRESULT"
|
|
292
|
+
[ "$BLOCKED" -eq 1 ] && exit 2
|
|
210
293
|
|
|
211
294
|
RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
212
295
|
import os, json, re, sys, urllib.request
|
|
213
296
|
|
|
297
|
+
# Every return that inspects nothing names its reason. A silent return is
|
|
298
|
+
# indistinguishable from a gate that never ran at all, which is the state a
|
|
299
|
+
# stale PostToolUse matcher leaves an account in; fail-open behaviour itself is
|
|
300
|
+
# unchanged, only its visibility.
|
|
301
|
+
def skip(reason):
|
|
302
|
+
print("SKIP|%s" % reason)
|
|
303
|
+
sys.exit(0)
|
|
304
|
+
|
|
214
305
|
try:
|
|
215
306
|
d = json.loads(os.environ["INPUT"])
|
|
216
307
|
except Exception:
|
|
217
|
-
|
|
308
|
+
skip("bad-envelope")
|
|
218
309
|
|
|
219
310
|
tool = d.get("tool_name", "") or ""
|
|
220
311
|
if not tool.endswith("browser-pdf-save"):
|
|
221
|
-
|
|
312
|
+
skip("not-browser-tool")
|
|
222
313
|
path = (d.get("tool_input", {}) or {}).get("path", "") or ""
|
|
223
314
|
if not path:
|
|
224
|
-
|
|
315
|
+
skip("no-path")
|
|
225
316
|
norm = os.path.normpath(path)
|
|
226
317
|
m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
|
|
227
318
|
if not m:
|
|
228
|
-
|
|
319
|
+
skip("not-gated") # not a gated client-quote artefact
|
|
229
320
|
jobid = m.group(1)
|
|
230
321
|
if "/" in jobid or ".." in jobid:
|
|
231
|
-
|
|
322
|
+
skip("bad-jobid") # the PreToolUse gate owns bad-jobid; here fail open
|
|
232
323
|
variant = m.group(2) # "-breakdown" or None
|
|
233
324
|
|
|
234
325
|
base = os.environ["BASE"]
|
|
@@ -236,12 +327,12 @@ receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json
|
|
|
236
327
|
try:
|
|
237
328
|
receipt = json.load(open(receipt_path))
|
|
238
329
|
except Exception:
|
|
239
|
-
|
|
330
|
+
skip("no-receipt") # PreToolUse gate already blocks that; fail open here
|
|
240
331
|
|
|
241
332
|
filed = receipt.get("filed", {}) or {}
|
|
242
333
|
html_path = filed.get("breakdown" if variant else "quote", "")
|
|
243
334
|
if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
|
|
244
|
-
|
|
335
|
+
skip("no-source") # nothing to compare against -> fail open
|
|
245
336
|
|
|
246
337
|
html = open(html_path, encoding="utf-8", errors="replace").read()
|
|
247
338
|
# Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
|
|
@@ -274,6 +365,7 @@ PY
|
|
|
274
365
|
)
|
|
275
366
|
|
|
276
367
|
case "$RESULT" in
|
|
368
|
+
SKIP\|*) emit_log "op=skip reason=${RESULT#SKIP|}" ;;
|
|
277
369
|
BLOCK\|*)
|
|
278
370
|
rest=${RESULT#BLOCK|}
|
|
279
371
|
jobid=${rest%%|*}
|