@windyroad/voice-tone 0.4.0 → 0.5.0-preview.313

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-voice-tone",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Voice and tone enforcement for Claude Code"
5
5
  }
package/README.md CHANGED
@@ -15,6 +15,8 @@ The voice-tone plugin:
15
15
  3. **Reviews** the proposed copy against your `docs/VOICE-AND-TONE.md` guide
16
16
  4. **Reports** violations with suggested fixes that match your brand's voice principles, banned patterns, and word list
17
17
 
18
+ Beyond in-repo edits, the plugin also gates **external communications** — `gh issue create`, `gh pr create`, `gh issue/pr comment`, `gh api security-advisories`, `npm publish`, and `.changeset/*.md` author-time — via the [`wr-voice-tone:external-comms`](agents/external-comms.md) subagent and the on-demand [`/wr-voice-tone:assess-external-comms`](skills/assess-external-comms/SKILL.md) skill. This composes with `@windyroad/risk-scorer`'s sibling external-comms gate (see [ADR-028 amended 2026-05-14](../../docs/decisions/028-voice-tone-gate-external-comms.proposed.md)) — when both plugins are installed, voice/tone and risk/leak review fire independently on the same outbound prose call. Serves [JTBD-001 Enforce Governance Without Slowing Down](../../docs/jtbd/solo-developer/JTBD-001-enforce-governance.proposed.md) and [JTBD-202 Run Pre-Flight Governance Checks Before Release or Handover](../../docs/jtbd/tech-lead/JTBD-202-pre-flight-governance-check.proposed.md).
19
+
18
20
  ## Install
19
21
 
20
22
  ```bash
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: external-comms
3
+ description: Reviews drafts of external-facing prose (gh issues / PRs / advisories, npm publish content, .changeset/*.md bodies) against docs/VOICE-AND-TONE.md voice profile. Read-only — emits a structured PASS/FAIL verdict consumed by the external-comms-mark-reviewed.sh PostToolUse hook.
4
+ tools:
5
+ - Read
6
+ - Glob
7
+ - Grep
8
+ model: inherit
9
+ ---
10
+
11
+ You are the External-Comms Voice & Tone Reviewer. Your single job: read the draft of an outbound prose tool call (a `gh issue create --body ...`, a PR description, a security-advisory body, a `.changeset/*.md` file, or the README diff that `npm publish` will publish) and return a structured PASS/FAIL verdict against `docs/VOICE-AND-TONE.md`.
12
+
13
+ You are read-only. You do NOT write files, do NOT commit, do NOT modify the draft. Your verdict is consumed by the `external-comms-mark-reviewed.sh` PostToolUse hook (P038 / ADR-028 amended 2026-05-14), which writes the per-evaluator marker that allows the gated tool call to proceed.
14
+
15
+ You are the voice-tone half of the external-comms gate. The risk/leak half is handled by `wr-risk-scorer:external-comms`. When both plugins are installed, both evaluators must PASS independently before the gate permits the tool call. The two gates compose at the firing level (per-evaluator markers, no shared composite marker).
16
+
17
+ ## What you receive
18
+
19
+ The invoking skill (`/wr-voice-tone:assess-external-comms`) or the agent that hit the gate provides:
20
+
21
+ - The **draft body** verbatim — the exact prose that would land on the external surface.
22
+ - The **target surface** — one of: `gh-issue-create`, `gh-issue-comment`, `gh-issue-edit`, `gh-pr-create`, `gh-pr-comment`, `gh-pr-edit`, `gh-api-security-advisories`, `gh-api-comments`, `npm-publish`, `changeset-author`.
23
+ - The **destination** when known (e.g. `anthropics/claude-code#52831`).
24
+
25
+ Read `docs/VOICE-AND-TONE.md` (project root) to get the authoritative voice profile. Typical sections include voice principles, tone by context, banned patterns, word list / terminology, and language/locale conventions.
26
+
27
+ If `docs/VOICE-AND-TONE.md` is absent, the gate will run in advisory-only mode (the canonical hook handles this before delegating to you). You should only be invoked when the policy file exists.
28
+
29
+ ## Review process
30
+
31
+ 1. **Read the draft and the surface**. The surface determines the audience: `gh-issue-create` lands on a public third-party repo; `npm-publish` lands as a permanently-published artefact in `README.md`; `changeset-author` populates CHANGELOG.md, the Release PR body, GitHub Release page, AND every published npm tarball. Voice-tone expectations may shift by surface — formal advisory bodies sit differently to changelog entries.
32
+ 2. **Read `docs/VOICE-AND-TONE.md`** to ground every finding against the named principle / banned pattern / word-list entry. Do not invent rules; do not score by analogy if the guide already names a section that fits.
33
+ 3. **Apply context-aware judgement**:
34
+ - AI-tell patterns (em-dashes used decoratively, "it seems", "I'd suggest", excessive hedging, overly-polite closers like "happy to help further") are common voice failures on outbound prose.
35
+ - Stale-target language ("let's keep this ticket open", "happy to help further") on years-old issues is incongruous; surface the mismatch.
36
+ - A `.changeset/*.md` body lands in CHANGELOG.md and every published tarball. Treat changelog entries as the highest-permanence surface — voice/tone errors here persist across every npm tarball and release page.
37
+ - Generic-AI-voice phrases damage credibility on the public face of the user's work; the guide's "Banned patterns" section is the authoritative list.
38
+
39
+ ## Verdict format (MANDATORY)
40
+
41
+ End your report with a structured block consumed by `external-comms-mark-reviewed.sh`. Every field is required.
42
+
43
+ ```
44
+ EXTERNAL_COMMS_VOICE_TONE_VERDICT: PASS
45
+ EXTERNAL_COMMS_VOICE_TONE_KEY: <sha256 hex string>
46
+ ```
47
+
48
+ OR for a failed review:
49
+
50
+ ```
51
+ EXTERNAL_COMMS_VOICE_TONE_VERDICT: FAIL
52
+ EXTERNAL_COMMS_VOICE_TONE_KEY: <sha256 hex string>
53
+ EXTERNAL_COMMS_VOICE_TONE_REASON: <one-line description of the voice/tone violation + matched pattern>
54
+ ```
55
+
56
+ Compute the key as:
57
+
58
+ ```
59
+ printf '%s\n%s' "<draft body verbatim>" "<surface name>" | shasum -a 256 | cut -d' ' -f1
60
+ ```
61
+
62
+ The key MUST match the gate's computation exactly — a key mismatch means the marker is written for a different draft and the original gated call will continue to deny.
63
+
64
+ ## Grounding (ADR-026)
65
+
66
+ Every FAIL verdict MUST cite:
67
+
68
+ - The specific `docs/VOICE-AND-TONE.md` section / principle / banned-pattern entry violated (verbatim — copy the bullet from the guide).
69
+ - The exact substring from the draft that triggered the call.
70
+ - A one-line explanation of why this combination of surface + content violates the guide.
71
+
72
+ Example:
73
+
74
+ > EXTERNAL_COMMS_VOICE_TONE_REASON: "Banned patterns — hedging closers" — draft contains "happy to help further" closing a 2-year-old issue; voice-tone guide names "happy to help further" as banned on stale targets because it implies ongoing engagement that the project cannot sustain.
75
+
76
+ ## Constraints
77
+
78
+ - You are a reviewer, not an editor — do NOT propose rewrites in the verdict block. (Free prose suggestions outside the verdict block are fine and helpful.)
79
+ - Do NOT score by analogy when the guide names the principle.
80
+ - Do NOT write to `/tmp/` or any marker location yourself — the PostToolUse hook owns that.
81
+ - Do NOT skip the `EXTERNAL_COMMS_VOICE_TONE_KEY` line; without it, the marker hook has no key to write the marker against and the gate will deny again on retry.
82
+ - When the draft is empty (e.g. `npm publish` with no extractable body fragment), review the staged content the publish would push (README diff, package.json description) instead. If neither is available, FAIL with reason "draft body unresolvable; cannot voice-tone-review without text" so the user can pre-review manually.
83
+
84
+ ## Below-Appetite Output Rule (ADR-013 Rule 5)
85
+
86
+ When the verdict is PASS and no `docs/VOICE-AND-TONE.md` rule matched, your output may be terse: a one-line "no voice/tone violation matched" plus the verdict block. Do not pad with advisory prose; voice-compliant drafts proceed silently.
87
+
88
+ ## Above-Appetite (FAIL) Output
89
+
90
+ When the verdict is FAIL, surface remediation suggestions in PROSE BEFORE the verdict block — what specific substrings to rewrite, what tone shift to apply, where to consult the guide. The verdict block itself stays structured and machine-parseable.
@@ -0,0 +1,25 @@
1
+ # Per-package evaluator config for external-comms-gate.sh (ADR-028 amended 2026-05-14).
2
+ # Sourced by the canonical external-comms-gate.sh; NOT synced (each consumer
3
+ # plugin maintains its own .conf).
4
+
5
+ # Short evaluator id — used in marker filenames (external-comms-<id>-reviewed-<key>).
6
+ EXTERNAL_COMMS_EVALUATOR_ID=voice-tone
7
+
8
+ # Subagent type the deny message directs to.
9
+ EXTERNAL_COMMS_SUBAGENT_TYPE=wr-voice-tone:external-comms
10
+
11
+ # Structured-output prefix the PostToolUse:Agent hook parses from the subagent's
12
+ # stdout (EXTERNAL_COMMS_VOICE_TONE_VERDICT + EXTERNAL_COMMS_VOICE_TONE_KEY).
13
+ EXTERNAL_COMMS_VERDICT_PREFIX=EXTERNAL_COMMS_VOICE_TONE
14
+
15
+ # On-demand skill for pre-flight delegation.
16
+ EXTERNAL_COMMS_ASSESS_SKILL=/wr-voice-tone:assess-external-comms
17
+
18
+ # Policy file whose absence triggers advisory-only mode.
19
+ EXTERNAL_COMMS_POLICY_FILE=docs/VOICE-AND-TONE.md
20
+
21
+ # Whether to run the leak-pattern pre-filter (lib/leak-detect.sh). Voice-tone
22
+ # evaluator reviews tone/voice only; leak detection is the risk evaluator's
23
+ # concern (run by packages/risk-scorer/hooks/external-comms-gate.sh when that
24
+ # plugin is also installed).
25
+ EXTERNAL_COMMS_LEAK_PREFILTER=no
@@ -0,0 +1,241 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: gates outbound prose for evaluator review (P064 / P038 / ADR-028 amended 2026-05-14).
3
+ #
4
+ # This is the CANONICAL hook synced byte-identically into each consumer plugin
5
+ # (risk-scorer, voice-tone, …) via ADR-017 duplicate-script pattern. Each copy
6
+ # sources `${SCRIPT_DIR}/external-comms-evaluator.conf` to determine its
7
+ # evaluator identity (risk / voice-tone / …) — the .conf file is per-package
8
+ # and NOT synced.
9
+ #
10
+ # Surface (matched on Bash command text or Edit/Write file_path):
11
+ # - gh issue create | comment | edit (public issue bodies)
12
+ # - gh pr create | comment | edit (public PR bodies)
13
+ # - gh api .../security-advisories (advisory drafts)
14
+ # - gh api .../comments (any REST surface accepting prose)
15
+ # - npm publish (README / package metadata to npm)
16
+ # - PreToolUse:Write|Edit on .changeset/*.md (P073 — gates author-time)
17
+ #
18
+ # Gate behaviour:
19
+ # 1. BYPASS_RISK_GATE=1 short-circuits the gate (consistent with git-push-gate.sh).
20
+ # 2. POLICY_FILE absent → advisory-only mode (permits with systemMessage).
21
+ # 3. Hybrid leak-pattern pre-filter (lib/leak-detect.sh) hard-fails on
22
+ # credentials, prod-URL prefixes, business-context-paired financial figures,
23
+ # or business-context-paired user counts. Deny includes the matched class.
24
+ # (Voice-tone evaluator: skips leak pre-filter — leak detection is the
25
+ # risk evaluator's concern; voice-tone reviews tone/voice only.)
26
+ # 4. Otherwise: check for THIS evaluator's per-evaluator marker keyed on
27
+ # sha256(draft_body + '\n' + surface). Marker present → permit.
28
+ # Marker absent → deny with directive to delegate to this plugin's
29
+ # subagent (configured via external-comms-evaluator.conf).
30
+ #
31
+ # Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/external-comms-<EVALUATOR_ID>-reviewed-<sha256>
32
+ # Marker writer: PostToolUse:Agent hook in each consumer plugin
33
+ # (risk-score-mark.sh or external-comms-mark-reviewed.sh) on
34
+ # subagent type wr-<plugin>:external-comms.
35
+ #
36
+ # Per-evaluator marker scheme (ADR-028 amended 2026-05-14): when both
37
+ # voice-tone and risk-scorer are installed, both gates fire on the same
38
+ # PreToolUse event; each gate denies until its own per-evaluator marker
39
+ # exists. Gates compose at firing level — no shared composite marker.
40
+
41
+ set -euo pipefail
42
+
43
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
44
+ # shellcheck source=lib/leak-detect.sh
45
+ source "$SCRIPT_DIR/lib/leak-detect.sh"
46
+
47
+ # ---------- Per-package evaluator config (ADR-028 amended 2026-05-14) ----------
48
+ # Each consumer plugin ships its own external-comms-evaluator.conf alongside this
49
+ # byte-identical canonical hook. The .conf defines:
50
+ # EXTERNAL_COMMS_EVALUATOR_ID — short id (risk, voice-tone)
51
+ # EXTERNAL_COMMS_SUBAGENT_TYPE — subagent to delegate to (wr-<plugin>:external-comms)
52
+ # EXTERNAL_COMMS_VERDICT_PREFIX — structured-output prefix the mark hook parses
53
+ # EXTERNAL_COMMS_ASSESS_SKILL — on-demand skill path for manual delegation
54
+ # EXTERNAL_COMMS_POLICY_FILE — policy doc whose absence triggers advisory-only
55
+ # EXTERNAL_COMMS_LEAK_PREFILTER — yes|no — whether to run leak-detect pre-filter
56
+ # Fail-closed if absent: this hook cannot operate without a configured evaluator.
57
+ CONF_FILE="$SCRIPT_DIR/external-comms-evaluator.conf"
58
+ if [ ! -f "$CONF_FILE" ]; then
59
+ echo "ERROR: external-comms-gate.sh requires $CONF_FILE (ADR-028 amended 2026-05-14)" >&2
60
+ exit 0
61
+ fi
62
+ # shellcheck source=/dev/null
63
+ source "$CONF_FILE"
64
+ : "${EXTERNAL_COMMS_EVALUATOR_ID:?evaluator id missing from $CONF_FILE}"
65
+ : "${EXTERNAL_COMMS_SUBAGENT_TYPE:?subagent type missing from $CONF_FILE}"
66
+ : "${EXTERNAL_COMMS_ASSESS_SKILL:?assess-skill missing from $CONF_FILE}"
67
+ EXTERNAL_COMMS_POLICY_FILE="${EXTERNAL_COMMS_POLICY_FILE:-RISK-POLICY.md}"
68
+ EXTERNAL_COMMS_LEAK_PREFILTER="${EXTERNAL_COMMS_LEAK_PREFILTER:-yes}"
69
+
70
+ # ---------- Bypass ----------
71
+ if [ "${BYPASS_RISK_GATE:-0}" = "1" ]; then
72
+ exit 0
73
+ fi
74
+
75
+ INPUT=$(cat)
76
+
77
+ # Extract tool name + tool_input via python3 (consistent with sibling hooks).
78
+ TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
79
+ import sys, json
80
+ try:
81
+ print(json.load(sys.stdin).get('tool_name', ''))
82
+ except Exception:
83
+ print('')
84
+ " 2>/dev/null || echo "")
85
+
86
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
87
+ import sys, json
88
+ try:
89
+ print(json.load(sys.stdin).get('session_id', ''))
90
+ except Exception:
91
+ print('')
92
+ " 2>/dev/null || echo "")
93
+
94
+ # Permit silently when session_id is absent; the gate cannot key a marker.
95
+ [ -n "$SESSION_ID" ] || exit 0
96
+
97
+ # ---------- Surface detection ----------
98
+ SURFACE=""
99
+ DRAFT=""
100
+
101
+ case "$TOOL_NAME" in
102
+ Bash)
103
+ COMMAND=$(printf '%s' "$INPUT" | python3 -c "
104
+ import sys, json
105
+ try:
106
+ print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
107
+ except Exception:
108
+ print('')
109
+ " 2>/dev/null || echo "")
110
+
111
+ # Surface match — most-specific first.
112
+ if echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue create(\s|$)'; then
113
+ SURFACE="gh-issue-create"
114
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue comment(\s|$)'; then
115
+ SURFACE="gh-issue-comment"
116
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh issue edit(\s|$)'; then
117
+ SURFACE="gh-issue-edit"
118
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr create(\s|$)'; then
119
+ SURFACE="gh-pr-create"
120
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr comment(\s|$)'; then
121
+ SURFACE="gh-pr-comment"
122
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*gh pr edit(\s|$)'; then
123
+ SURFACE="gh-pr-edit"
124
+ elif echo "$COMMAND" | grep -qE 'gh api .*security-advisories'; then
125
+ SURFACE="gh-api-security-advisories"
126
+ elif echo "$COMMAND" | grep -qE 'gh api .*/comments'; then
127
+ SURFACE="gh-api-comments"
128
+ elif echo "$COMMAND" | grep -qE '(^|;|&&|\|\|)\s*npm publish(\s|$)'; then
129
+ SURFACE="npm-publish"
130
+ else
131
+ exit 0
132
+ fi
133
+
134
+ # Best-effort body extraction: --body 'TEXT' or --body "TEXT" or --field summary='TEXT'.
135
+ # When absent (npm publish, --body-file), DRAFT="" is acceptable: the agent will
136
+ # be invoked with command context and read whatever body source the call uses.
137
+ DRAFT=$(printf '%s' "$COMMAND" | python3 -c "
138
+ import sys, re
139
+ cmd = sys.stdin.read()
140
+ # Match --body '...' or --body \"...\" or --field summary='...'
141
+ for pat in [r\"--body[= ]'([^']*)'\", r'--body[= ]\"([^\"]*)\"',
142
+ r\"--field [a-zA-Z_]+='([^']*)'\", r'--field [a-zA-Z_]+=\"([^\"]*)\"']:
143
+ m = re.search(pat, cmd)
144
+ if m:
145
+ print(m.group(1))
146
+ break
147
+ " 2>/dev/null || echo "")
148
+ ;;
149
+
150
+ Write|Edit)
151
+ FILE_PATH=$(printf '%s' "$INPUT" | python3 -c "
152
+ import sys, json
153
+ try:
154
+ ti = json.load(sys.stdin).get('tool_input', {})
155
+ print(ti.get('file_path', ti.get('path', '')))
156
+ except Exception:
157
+ print('')
158
+ " 2>/dev/null || echo "")
159
+
160
+ case "$FILE_PATH" in
161
+ *.changeset/*.md|*/.changeset/*.md|.changeset/*.md)
162
+ SURFACE="changeset-author"
163
+ ;;
164
+ *)
165
+ exit 0
166
+ ;;
167
+ esac
168
+
169
+ DRAFT=$(printf '%s' "$INPUT" | python3 -c "
170
+ import sys, json
171
+ try:
172
+ ti = json.load(sys.stdin).get('tool_input', {})
173
+ print(ti.get('content', '') + ti.get('new_string', ''))
174
+ except Exception:
175
+ print('')
176
+ " 2>/dev/null || echo "")
177
+ ;;
178
+
179
+ *)
180
+ exit 0
181
+ ;;
182
+ esac
183
+
184
+ # ---------- Helpers ----------
185
+ deny_with_reason() {
186
+ local reason="$1"
187
+ python3 -c "
188
+ import json, sys
189
+ print(json.dumps({
190
+ 'hookSpecificOutput': {
191
+ 'hookEventName': 'PreToolUse',
192
+ 'permissionDecision': 'deny',
193
+ 'permissionDecisionReason': sys.argv[1]
194
+ }
195
+ }))
196
+ " "$reason"
197
+ }
198
+
199
+ permit_with_advisory() {
200
+ local msg="$1"
201
+ python3 -c "
202
+ import json, sys
203
+ print(json.dumps({'systemMessage': sys.argv[1]}))
204
+ " "$msg"
205
+ }
206
+
207
+ # ---------- Advisory-only fallback when policy file is absent ----------
208
+ if [ ! -f "$EXTERNAL_COMMS_POLICY_FILE" ]; then
209
+ permit_with_advisory "$EXTERNAL_COMMS_POLICY_FILE not found — $EXTERNAL_COMMS_SUBAGENT_TYPE gate is advisory-only on $SURFACE."
210
+ exit 0
211
+ fi
212
+
213
+ # ---------- Hard-fail leak-pattern pre-filter (risk evaluator only) ----------
214
+ # Voice-tone evaluator skips this — leak detection is the risk evaluator's
215
+ # concern. Each per-package external-comms-evaluator.conf sets
216
+ # EXTERNAL_COMMS_LEAK_PREFILTER=yes (risk) or =no (voice-tone).
217
+ if [ "$EXTERNAL_COMMS_LEAK_PREFILTER" = "yes" ]; then
218
+ if ! leak_detect_scan "$DRAFT"; then
219
+ REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s on %s. Remove the leak before retrying. Override only if intentional: BYPASS_RISK_GATE=1.' \
220
+ "$EXTERNAL_COMMS_EVALUATOR_ID" "$LEAK_DETECT_REASON" "$SURFACE")
221
+ deny_with_reason "$REASON"
222
+ exit 0
223
+ fi
224
+ fi
225
+
226
+ # ---------- Marker-based gate (per-evaluator marker per ADR-028 amended 2026-05-14) ----------
227
+ SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
228
+ mkdir -p "$SESSION_DIR"
229
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
230
+ MARKER="${SESSION_DIR}/external-comms-${EXTERNAL_COMMS_EVALUATOR_ID}-reviewed-${KEY}"
231
+
232
+ if [ -f "$MARKER" ]; then
233
+ exit 0
234
+ fi
235
+
236
+ # Marker absent — deny + delegate.
237
+ VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
238
+ REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with the draft body for review. The PostToolUse hook will mark this draft reviewed when the subagent emits %s_VERDICT: PASS. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
239
+ "$EXTERNAL_COMMS_EVALUATOR_ID" "$SURFACE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$VERDICT_PREFIX" "$EXTERNAL_COMMS_ASSESS_SKILL")
240
+ deny_with_reason "$REASON"
241
+ exit 0
@@ -0,0 +1,83 @@
1
+ #!/bin/bash
2
+ # PostToolUse:Agent hook for the wr-voice-tone:external-comms subagent.
3
+ # Parses structured verdict from agent output and writes the per-evaluator
4
+ # marker that the canonical external-comms-gate.sh checks (P038 / ADR-028
5
+ # amended 2026-05-14).
6
+ #
7
+ # Marker filename: external-comms-voice-tone-reviewed-<KEY>
8
+ # Marker location: ${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}/
9
+ #
10
+ # The risk-scorer evaluator writes its own per-evaluator marker
11
+ # (external-comms-risk-reviewed-<KEY>) from packages/risk-scorer/hooks/
12
+ # risk-score-mark.sh. When both plugins installed, both gates fire on the
13
+ # same PreToolUse event; both deny until both per-evaluator markers exist.
14
+ # Gates compose at firing level — no shared composite marker.
15
+
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+
20
+ INPUT=$(cat)
21
+
22
+ TOOL_NAME=$(printf '%s' "$INPUT" | python3 -c "
23
+ import sys, json
24
+ try:
25
+ print(json.load(sys.stdin).get('tool_name', ''))
26
+ except Exception:
27
+ print('')
28
+ " 2>/dev/null || echo "")
29
+ [ "$TOOL_NAME" = "Agent" ] || exit 0
30
+
31
+ SUBAGENT=$(printf '%s' "$INPUT" | python3 -c "
32
+ import sys, json
33
+ try:
34
+ print(json.load(sys.stdin).get('tool_input', {}).get('subagent_type', ''))
35
+ except Exception:
36
+ print('')
37
+ " 2>/dev/null || echo "")
38
+
39
+ SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "
40
+ import sys, json
41
+ try:
42
+ print(json.load(sys.stdin).get('session_id', ''))
43
+ except Exception:
44
+ print('')
45
+ " 2>/dev/null || echo "")
46
+ [ -n "$SESSION_ID" ] || exit 0
47
+
48
+ # Only handle the voice-tone external-comms subagent.
49
+ case "$SUBAGENT" in
50
+ *voice-tone*external-comms*|*wr-voice-tone:external-comms*) ;;
51
+ *) exit 0 ;;
52
+ esac
53
+
54
+ AGENT_OUTPUT=$(printf '%s' "$INPUT" | python3 -c "
55
+ import sys, json
56
+ try:
57
+ data = json.load(sys.stdin)
58
+ out = data.get('tool_response', {}).get('output', '')
59
+ if not out:
60
+ out = data.get('tool_response', '')
61
+ print(out if isinstance(out, str) else json.dumps(out))
62
+ except Exception:
63
+ print('')
64
+ " 2>/dev/null || echo "")
65
+
66
+ RDIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
67
+ mkdir -p "$RDIR"
68
+
69
+ VERDICT_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_VOICE_TONE_VERDICT:' | tail -1) || true
70
+ KEY_LINE=$(echo "$AGENT_OUTPUT" | grep -E '^EXTERNAL_COMMS_VOICE_TONE_KEY:' | tail -1) || true
71
+ VERDICT=$(echo "$VERDICT_LINE" | sed 's/^EXTERNAL_COMMS_VOICE_TONE_VERDICT:[[:space:]]*//' | tr -d '[:space:]')
72
+ KEY=$(echo "$KEY_LINE" | sed 's/^EXTERNAL_COMMS_VOICE_TONE_KEY:[[:space:]]*//' | tr -d '[:space:]')
73
+
74
+ # Validate key: 64 hex chars (sha256 output). Reject anything else.
75
+ if echo "$KEY" | grep -qE '^[0-9a-f]{64}$'; then
76
+ case "$VERDICT" in
77
+ PASS) touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}" ;;
78
+ FAIL) ;; # Do NOT create marker — draft must be revised
79
+ *) ;; # Unknown verdict — fail closed
80
+ esac
81
+ fi
82
+
83
+ exit 0
package/hooks/hooks.json CHANGED
@@ -4,10 +4,12 @@
4
4
  { "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-eval.sh" }] }
5
5
  ],
6
6
  "PreToolUse": [
7
- { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-enforce-edit.sh" }] }
7
+ { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-enforce-edit.sh" }] },
8
+ { "matcher": "Bash|Edit|Write", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-gate.sh" }] }
8
9
  ],
9
10
  "PostToolUse": [
10
11
  { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-mark-reviewed.sh" }] },
12
+ { "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/external-comms-mark-reviewed.sh" }] },
11
13
  { "matcher": "Agent|Bash", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-slide-marker.sh" }] }
12
14
  ]
13
15
  }
@@ -0,0 +1,99 @@
1
+ #!/bin/bash
2
+ # Hybrid leak-pattern pre-filter for external-comms-gate.sh (P064 / ADR-028 amended).
3
+ #
4
+ # Architecture (architect verdict — P064 iteration):
5
+ # - **Regex pre-filter** (this file) catches HIGH-CONFIDENCE leak shapes
6
+ # (credentials, prod URL prefixes, money/revenue + financial-context tokens).
7
+ # Hits deny immediately with a specific reason — no subagent round-trip.
8
+ # - **Subagent judgement** (wr-risk-scorer:external-comms) handles ambiguous
9
+ # prose. Anything this filter does NOT match is delegated to the agent for
10
+ # context-aware review against RISK-POLICY.md Confidential Information
11
+ # classes (client names, project names, engagement details, internal
12
+ # strategy/roadmap detail).
13
+ #
14
+ # Usage:
15
+ # source lib/leak-detect.sh
16
+ # if leak_detect_scan "$DRAFT"; then
17
+ # # No high-confidence hit; delegate to subagent.
18
+ # else
19
+ # # Set $LEAK_DETECT_REASON; deny with that reason.
20
+ # fi
21
+ #
22
+ # Returns 0 when the draft is CLEAN of high-confidence patterns.
23
+ # Returns 1 when a HARD-FAIL pattern is matched. $LEAK_DETECT_REASON
24
+ # is set to a one-line human-readable description of what matched.
25
+ #
26
+ # This is intentionally conservative — false negatives are expected and
27
+ # routed to the subagent. False positives here block the call entirely
28
+ # so the regex set must be high-specificity. Add new patterns only when
29
+ # the false-positive rate is verified low against representative drafts.
30
+
31
+ LEAK_DETECT_REASON=""
32
+
33
+ leak_detect_scan() {
34
+ local draft="$1"
35
+ LEAK_DETECT_REASON=""
36
+
37
+ [ -z "$draft" ] && return 0
38
+
39
+ # ---- Credentials (high confidence) ----
40
+
41
+ # AWS access keys: AKIA prefix + 16 upper-alphanum chars.
42
+ if echo "$draft" | grep -qE 'A''KIA[0-9A-Z]{16}'; then
43
+ LEAK_DETECT_REASON="AWS access key pattern detected in draft"
44
+ return 1
45
+ fi
46
+
47
+ # GitHub tokens: ghp_/ghs_/gho_/ghu_/ghr_ + 36+ url-safe chars.
48
+ if echo "$draft" | grep -qE 'g''h[pousr]_[A-Za-z0-9_]{36,}'; then
49
+ LEAK_DETECT_REASON="GitHub token pattern detected in draft"
50
+ return 1
51
+ fi
52
+
53
+ # Private keys.
54
+ if echo "$draft" | grep -qE 'BEGIN[[:space:]]+(RSA|DSA|EC|OPENSSH|PGP)?[[:space:]]*PRIVATE KEY'; then
55
+ LEAK_DETECT_REASON="Private key block detected in draft"
56
+ return 1
57
+ fi
58
+
59
+ # Bearer / Authorization headers with non-trivial value.
60
+ if echo "$draft" | grep -qE '[Bb]earer[[:space:]]+[A-Za-z0-9_.-]{20,}'; then
61
+ LEAK_DETECT_REASON="Bearer token detected in draft"
62
+ return 1
63
+ fi
64
+
65
+ # Generic api_key / api_secret / auth_token assignments with literal value.
66
+ if echo "$draft" | grep -qEi '(api_key|api_secret|auth_token|secret_key)[[:space:]]*[=:][[:space:]]*["\x27][A-Za-z0-9+/=_-]{16,}'; then
67
+ LEAK_DETECT_REASON="API key/secret/token assignment detected in draft"
68
+ return 1
69
+ fi
70
+
71
+ # ---- Confidential business metrics (RISK-POLICY.md) ----
72
+
73
+ # Revenue / financial figures: $<digits><K|M|B>? near financial-context
74
+ # keywords (ARR, MRR, revenue, profit). High-specificity to avoid catching
75
+ # generic price strings ($5 widget) — requires a financial token nearby.
76
+ if echo "$draft" | grep -qEi '\$[0-9]+([0-9.,]*)[KMB]?\b.{0,40}\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b'; then
77
+ LEAK_DETECT_REASON="Revenue/financial figure with business-context keyword detected"
78
+ return 1
79
+ fi
80
+ if echo "$draft" | grep -qEi '\b(ARR|MRR|revenue|profit|EBITDA|valuation)\b.{0,40}\$[0-9]+([0-9.,]*)[KMB]?\b'; then
81
+ LEAK_DETECT_REASON="Business-context keyword paired with revenue/financial figure detected"
82
+ return 1
83
+ fi
84
+
85
+ # User counts with explicit unit-of-business: <digits><K|M> + (users|customers|MAU|DAU|signups).
86
+ # Requires comma-formatted thousands or K/M suffix to skip "5 users tested this".
87
+ if echo "$draft" | grep -qEi '\b[0-9]{1,3}(,[0-9]{3})+\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
88
+ LEAK_DETECT_REASON="User-count with business-metric keyword detected"
89
+ return 1
90
+ fi
91
+ if echo "$draft" | grep -qEi '\b[0-9]+[KMB]\s*(active\s+)?(users|customers|signups|MAU|DAU)\b'; then
92
+ LEAK_DETECT_REASON="User-count (K/M/B suffix) with business-metric keyword detected"
93
+ return 1
94
+ fi
95
+
96
+ # ---- Allowlist-bypass: signed/known-public marketing language is fine ----
97
+ # No hits — signal CLEAN to the caller.
98
+ return 0
99
+ }
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env bats
2
+ # Tests for packages/voice-tone/hooks/external-comms-gate.sh
3
+ # (P038 / ADR-028 amended 2026-05-14).
4
+ #
5
+ # Behavioural: the gate denies outbound prose tool calls until the
6
+ # wr-voice-tone:external-comms subagent has reviewed the draft and the
7
+ # per-evaluator marker `external-comms-voice-tone-reviewed-<KEY>` has been
8
+ # written. Voice-tone evaluator does NOT run the leak-pattern pre-filter
9
+ # (EXTERNAL_COMMS_LEAK_PREFILTER=no in external-comms-evaluator.conf).
10
+ # Composition with the risk-scorer evaluator happens at firing level —
11
+ # both gates fire on the same PreToolUse event when both plugins installed.
12
+
13
+ setup() {
14
+ HOOKS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
15
+ HOOK="$HOOKS_DIR/external-comms-gate.sh"
16
+
17
+ TEST_SESSION="bats-vt-extcomms-gate-$$-${BATS_TEST_NUMBER}"
18
+ RDIR="${TMPDIR:-/tmp}/claude-risk-${TEST_SESSION}"
19
+ rm -rf "$RDIR"
20
+ mkdir -p "$RDIR"
21
+
22
+ # Voice-tone evaluator's policy file is docs/VOICE-AND-TONE.md per the .conf.
23
+ TEST_PROJECT_DIR="$(mktemp -d)"
24
+ mkdir -p "$TEST_PROJECT_DIR/docs"
25
+ printf "## Voice principles\n- Direct\n- No hedging\n## Banned patterns\n- 'happy to help further'\n" \
26
+ > "$TEST_PROJECT_DIR/docs/VOICE-AND-TONE.md"
27
+
28
+ unset BYPASS_RISK_GATE
29
+ }
30
+
31
+ teardown() {
32
+ rm -rf "$RDIR"
33
+ rm -rf "$TEST_PROJECT_DIR"
34
+ unset BYPASS_RISK_GATE
35
+ }
36
+
37
+ # ---------- Helpers ----------
38
+
39
+ build_bash_input() {
40
+ local cmd="$1"
41
+ python3 -c "
42
+ import json, sys
43
+ print(json.dumps({
44
+ 'session_id': '$TEST_SESSION',
45
+ 'tool_name': 'Bash',
46
+ 'tool_input': {'command': sys.argv[1]},
47
+ }))
48
+ " "$cmd"
49
+ }
50
+
51
+ build_write_input() {
52
+ local file_path="$1"
53
+ local content="$2"
54
+ python3 -c "
55
+ import json, sys
56
+ print(json.dumps({
57
+ 'session_id': '$TEST_SESSION',
58
+ 'tool_name': 'Write',
59
+ 'tool_input': {'file_path': sys.argv[1], 'content': sys.argv[2]},
60
+ }))
61
+ " "$file_path" "$content"
62
+ }
63
+
64
+ run_hook() {
65
+ local input="$1"
66
+ run bash -c "cd '$TEST_PROJECT_DIR' && printf '%s' \"\$1\" | '$HOOK'" _ "$input"
67
+ }
68
+
69
+ # ---------- Tests ----------
70
+
71
+ @test "non-matching Bash command (ls) is allowed silently" {
72
+ INPUT=$(build_bash_input "ls -la")
73
+ run_hook "$INPUT"
74
+ [ "$status" -eq 0 ]
75
+ [ -z "$output" ]
76
+ }
77
+
78
+ @test "gh issue create with clean draft denies and prompts wr-voice-tone:external-comms delegation (no marker yet)" {
79
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure on Node 20'")
80
+ run_hook "$INPUT"
81
+ [ "$status" -eq 0 ]
82
+ [[ "$output" == *"permissionDecision"* ]]
83
+ [[ "$output" == *"deny"* ]]
84
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
85
+ }
86
+
87
+ @test "voice-tone evaluator skips leak pre-filter (EXTERNAL_COMMS_LEAK_PREFILTER=no)" {
88
+ # A draft with leak-shaped content (revenue figure with business context) would
89
+ # hard-fail in the risk evaluator. The voice-tone gate must NOT hard-fail; leak
90
+ # detection is the risk evaluator's concern. Voice-tone deny-and-delegates for
91
+ # subagent review, same as any clean draft.
92
+ INPUT=$(build_bash_input "gh issue comment 42 --body 'Acme Corp 2.4M ARR is a real concern'")
93
+ run_hook "$INPUT"
94
+ [ "$status" -eq 0 ]
95
+ [[ "$output" == *"deny"* ]]
96
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
97
+ # Must NOT name a leak class.
98
+ [[ "$output" != *"credential"* ]]
99
+ [[ "$output" != *"financial"* ]]
100
+ }
101
+
102
+ @test "BYPASS_RISK_GATE=1 short-circuits the deny" {
103
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a build failure'")
104
+ run bash -c "cd '$TEST_PROJECT_DIR' && BYPASS_RISK_GATE=1 printf '%s' \"\$1\" | BYPASS_RISK_GATE=1 '$HOOK'" _ "$INPUT"
105
+ [ "$status" -eq 0 ]
106
+ [ -z "$output" ]
107
+ }
108
+
109
+ @test "per-evaluator marker (external-comms-voice-tone-reviewed-<KEY>) allows the call" {
110
+ DRAFT="we observed a build failure on Node 20"
111
+ SURFACE="gh-issue-create"
112
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
113
+ touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}"
114
+
115
+ INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
116
+ run_hook "$INPUT"
117
+ [ "$status" -eq 0 ]
118
+ [ -z "$output" ]
119
+ }
120
+
121
+ @test "risk-scorer marker (external-comms-risk-reviewed-<KEY>) does NOT satisfy the voice-tone gate" {
122
+ # Independent per-evaluator markers: a risk-evaluator PASS marker does not
123
+ # imply voice-tone has been reviewed.
124
+ DRAFT="we observed a build failure on Node 20"
125
+ SURFACE="gh-issue-create"
126
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
127
+ touch "${RDIR}/external-comms-risk-reviewed-${KEY}"
128
+
129
+ INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
130
+ run_hook "$INPUT"
131
+ [ "$status" -eq 0 ]
132
+ [[ "$output" == *"deny"* ]]
133
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
134
+ }
135
+
136
+ @test "docs/VOICE-AND-TONE.md absent yields advisory-only mode (permits)" {
137
+ rm -f "$TEST_PROJECT_DIR/docs/VOICE-AND-TONE.md"
138
+ INPUT=$(build_bash_input "gh issue create --title T --body 'we observed a failure'")
139
+ run_hook "$INPUT"
140
+ [ "$status" -eq 0 ]
141
+ # Must NOT deny when policy file is absent.
142
+ [[ "$output" != *"\"permissionDecision\": \"deny\""* ]]
143
+ [[ "$output" != *"\"permissionDecision\":\"deny\""* ]]
144
+ # Must surface the advisory systemMessage.
145
+ [[ "$output" == *"docs/VOICE-AND-TONE.md not found"* ]]
146
+ }
147
+
148
+ @test "PreToolUse:Write on .changeset/*.md triggers deny+delegate" {
149
+ INPUT=$(build_write_input ".changeset/test.md" "Add some feature. Happy to help further with details.")
150
+ run_hook "$INPUT"
151
+ [ "$status" -eq 0 ]
152
+ [[ "$output" == *"deny"* ]]
153
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
154
+ }
155
+
156
+ @test "PreToolUse:Write on a non-changeset path is ignored" {
157
+ INPUT=$(build_write_input "src/foo.ts" "happy to help further")
158
+ run_hook "$INPUT"
159
+ [ "$status" -eq 0 ]
160
+ [ -z "$output" ]
161
+ }
162
+
163
+ @test "gh api security-advisories triggers the gate" {
164
+ INPUT=$(build_bash_input "gh api repos/foo/bar/security-advisories --method POST --field summary='vulnerability detail'")
165
+ run_hook "$INPUT"
166
+ [ "$status" -eq 0 ]
167
+ [[ "$output" == *"deny"* ]]
168
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
169
+ }
170
+
171
+ @test "npm publish triggers the gate" {
172
+ INPUT=$(build_bash_input "npm publish")
173
+ run_hook "$INPUT"
174
+ [ "$status" -eq 0 ]
175
+ [[ "$output" == *"deny"* ]]
176
+ [[ "$output" == *"wr-voice-tone:external-comms"* ]]
177
+ }
178
+
179
+ @test "deny message references the on-demand skill (/wr-voice-tone:assess-external-comms)" {
180
+ INPUT=$(build_bash_input "gh issue comment 42 --body 'a draft'")
181
+ run_hook "$INPUT"
182
+ [ "$status" -eq 0 ]
183
+ [[ "$output" == *"/wr-voice-tone:assess-external-comms"* ]]
184
+ }
185
+
186
+ @test "marker name uses evaluator id from external-comms-evaluator.conf (voice-tone, not risk)" {
187
+ # Regression: the canonical hook sources the .conf and uses its EVALUATOR_ID
188
+ # in the marker filename. The risk-scorer's marker name does NOT satisfy the
189
+ # voice-tone gate even if the KEY matches.
190
+ DRAFT="some draft body"
191
+ SURFACE="gh-issue-create"
192
+ KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
193
+ # Pre-amendment combined marker — should NOT satisfy the new voice-tone gate.
194
+ touch "${RDIR}/external-comms-reviewed-${KEY}"
195
+
196
+ INPUT=$(build_bash_input "gh issue create --title T --body '$DRAFT'")
197
+ run_hook "$INPUT"
198
+ [ "$status" -eq 0 ]
199
+ [[ "$output" == *"deny"* ]]
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/voice-tone",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-preview.313",
4
4
  "description": "Voice and tone enforcement for user-facing copy",
5
5
  "bin": {
6
6
  "windyroad-voice-tone": "./bin/install.mjs"
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: wr-voice-tone:assess-external-comms
3
+ description: On-demand external-comms voice & tone review. Reviews a draft of an outbound prose tool call (gh issue/pr body, security advisory, npm publish content, or .changeset/*.md body) against docs/VOICE-AND-TONE.md. Delegates to wr-voice-tone:external-comms and pre-satisfies the external-comms-gate marker for the current session.
4
+ allowed-tools: Read, Glob, Grep, Bash, AskUserQuestion, Skill
5
+ ---
6
+
7
+ # External-Comms Voice & Tone Assessment Skill
8
+
9
+ Run a voice & tone review on demand against any drafted outbound prose — outside a hook gate trigger. Pre-satisfies the `external-comms-gate.sh` marker for the current session so the gated tool call (gh issue/pr/api/npm publish/changeset write) proceeds without re-prompting.
10
+
11
+ This skill is **read-only**. It does not commit, push, or modify files. The marker is written automatically by the `PostToolUse:Agent` hook (`external-comms-mark-reviewed.sh`) after the subagent completes — the skill never writes to `${TMPDIR:-/tmp}/claude-risk-*` directly.
12
+
13
+ This is the voice-tone half of the external-comms gate. The risk/leak half is handled by `/wr-risk-scorer:assess-external-comms`. When both plugins are installed, both evaluators must PASS independently before the gate permits the tool call.
14
+
15
+ ## When to use
16
+
17
+ - Before drafting a `gh issue create` / `gh pr create` / `gh issue comment` / `gh pr comment` to a third-party repo.
18
+ - Before drafting a `gh api .../security-advisories` body for a vendor private channel.
19
+ - Before authoring a `.changeset/*.md` body that will land in CHANGELOG.md and every published npm tarball (P073).
20
+ - Before `npm publish` when the README diff is non-trivial.
21
+ - After hitting the external-comms gate's deny-and-delegate prompt: this skill is the structured walkthrough that closes the voice-tone loop.
22
+
23
+ ## Steps
24
+
25
+ ### 1. Parse arguments
26
+
27
+ Read `$ARGUMENTS` for either:
28
+
29
+ - A draft body verbatim (e.g. the user pastes the prose they're about to post).
30
+ - A surface hint (`gh-issue-create`, `gh-pr-comment`, `gh-api-security-advisories`, `gh-issue-edit`, `gh-pr-edit`, `gh-issue-comment`, `gh-pr-create`, `gh-api-comments`, `npm-publish`, `changeset-author`).
31
+ - A destination hint (`anthropics/claude-code#52831`, `vendor private channel`, `npm public registry`).
32
+
33
+ If both draft and surface are present, proceed to step 3. If either is missing, step 2.
34
+
35
+ ### 2. Resolve missing context
36
+
37
+ If the draft is missing, use `AskUserQuestion`:
38
+
39
+ > "What draft do you want me to review? Paste the body verbatim — I will pass it to the external-comms voice-tone reviewer."
40
+
41
+ If the surface is missing AND cannot be inferred from context (e.g. user just said "before I post this comment"), use `AskUserQuestion`:
42
+
43
+ - header: "Target surface"
44
+ - options:
45
+ 1. `gh issue create` (public third-party repo)
46
+ 2. `gh issue comment` (public third-party repo)
47
+ 3. `gh pr create` / `gh pr comment` (public third-party repo)
48
+ 4. `gh api .../security-advisories` (vendor private channel)
49
+ 5. `npm publish` (permanently published artefact)
50
+ 6. `.changeset/*.md` (lands in CHANGELOG + Release PR + every npm tarball)
51
+
52
+ Do not ask if the surface is obvious from the conversation context.
53
+
54
+ ### 3. Construct the review prompt
55
+
56
+ Build a self-contained prompt for the `wr-voice-tone:external-comms` subagent that includes:
57
+
58
+ - The **draft body** verbatim (between explicit `<draft>...</draft>` markers so the agent's substring extraction is unambiguous).
59
+ - The **target surface** (one of the canonical strings above).
60
+ - The **destination** when known.
61
+ - A reminder to compute `EXTERNAL_COMMS_VOICE_TONE_KEY = sha256(draft + '\n' + surface)`.
62
+
63
+ ### 4. Delegate to wr-voice-tone:external-comms
64
+
65
+ Invoke the subagent via the `Skill` tool:
66
+
67
+ ```
68
+ subagent_type: wr-voice-tone:external-comms
69
+ prompt: <constructed review prompt from step 3>
70
+ ```
71
+
72
+ Wait for the subagent to complete. The subagent will output a structured verdict block (`EXTERNAL_COMMS_VOICE_TONE_VERDICT: PASS|FAIL` + `EXTERNAL_COMMS_VOICE_TONE_KEY: <sha>` + optional `EXTERNAL_COMMS_VOICE_TONE_REASON: ...`). The `PostToolUse:Agent` hook (`external-comms-mark-reviewed.sh`) reads that output and writes the per-evaluator marker automatically.
73
+
74
+ **Do not write to `${TMPDIR:-/tmp}/claude-risk-*` yourself.** The hook is the only correct mechanism.
75
+
76
+ ### 5. Present results
77
+
78
+ Present the full review report to the user. Highlight:
79
+
80
+ - The verdict (PASS / FAIL).
81
+ - Each `docs/VOICE-AND-TONE.md` section / principle / banned-pattern entry the draft violated (FAIL only).
82
+ - The exact substrings that triggered each finding (FAIL only).
83
+ - Whether the voice-tone gate is now pre-satisfied for the current session for this exact draft+surface key (PASS only): "The next attempt to <surface> with this draft body will proceed past the voice-tone evaluator without re-prompting."
84
+
85
+ When both evaluators are required (voice-tone + risk-scorer both installed), remind the user that the risk-scorer evaluator may still need its own delegation (run `/wr-risk-scorer:assess-external-comms`) before the gate fully permits.
86
+
87
+ ### 6. Above-appetite handling (ADR-013 Rule 6)
88
+
89
+ If the verdict is FAIL, do NOT auto-rewrite the draft. Use `AskUserQuestion`:
90
+
91
+ - header: "Voice/tone violation — next step"
92
+ - options:
93
+ 1. `Rewrite the draft and re-review` — return to step 1 with the rewritten body.
94
+ 2. `Override anyway` — set `BYPASS_RISK_GATE=1` for the next gated tool call. Reserved for cases where the user has confirmed the content is acceptable as-drafted (e.g. an explicitly informal context the guide doesn't cover).
95
+ 3. `Cancel` — abandon the post.
96
+
97
+ Do not make the decision unilaterally — per ADR-013 Rule 1, all voice-tone judgement calls outside hard rules belong to the user.
98
+
99
+ $ARGUMENTS