@windyroad/voice-tone 0.5.3 → 0.5.4-preview.374

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.
@@ -123,5 +123,5 @@
123
123
  }
124
124
  },
125
125
  "name": "wr-voice-tone",
126
- "version": "0.5.3"
126
+ "version": "0.5.4"
127
127
  }
@@ -24,7 +24,13 @@
24
24
  # (Voice-tone evaluator: skips leak pre-filter — leak detection is the
25
25
  # risk evaluator's concern; voice-tone reviews tone/voice only.)
26
26
  # 4. Otherwise: check for THIS evaluator's per-evaluator marker keyed on
27
- # sha256(draft_body + '\n' + surface). Marker present → permit.
27
+ # compute_external_comms_key(draft, surface) =
28
+ # sha256(normalize(draft, surface) + '\n' + surface) — the SINGLE
29
+ # canonical key shared with the mark hook (lib/external-comms-key.sh).
30
+ # For the changeset-author surface normalize() strips the leading YAML
31
+ # frontmatter block so the gate (which sees the FULL Write content) and
32
+ # the mark hook (which sees only the <draft> body) hash identical input
33
+ # (P010 / ADR-028 amended 2026-05-25). Marker present → permit.
28
34
  # Marker absent → deny with directive to delegate to this plugin's
29
35
  # subagent (configured via external-comms-evaluator.conf).
30
36
  #
@@ -48,6 +54,12 @@ set -euo pipefail
48
54
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
49
55
  # shellcheck source=lib/leak-detect.sh
50
56
  source "$SCRIPT_DIR/lib/leak-detect.sh"
57
+ # shellcheck source=lib/external-comms-key.sh
58
+ # Provides compute_external_comms_key — the SINGLE canonical marker-key
59
+ # normalization shared with the PostToolUse mark hook (ADR-028 amended
60
+ # 2026-05-25 / P010). Sourced via the same $SCRIPT_DIR/lib convention as
61
+ # leak-detect.sh so byte-identity holds across the synced per-package copies.
62
+ source "$SCRIPT_DIR/lib/external-comms-key.sh"
51
63
 
52
64
  # ---------- Per-package evaluator config (ADR-028 amended 2026-05-14) ----------
53
65
  # Each consumer plugin ships its own external-comms-evaluator.conf alongside this
@@ -231,7 +243,12 @@ fi
231
243
  # ---------- Marker-based gate (per-evaluator marker per ADR-028 amended 2026-05-14) ----------
232
244
  SESSION_DIR="${TMPDIR:-/tmp}/claude-risk-${SESSION_ID}"
233
245
  mkdir -p "$SESSION_DIR"
234
- KEY=$(printf '%s\n%s' "$DRAFT" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
246
+ # Canonical marker key normalize() strips changeset frontmatter + trailing
247
+ # whitespace so this PreToolUse key matches the PostToolUse mark-hook key
248
+ # (compute_external_comms_key in lib/external-comms-key.sh; P010 / ADR-028
249
+ # amended 2026-05-25). For changeset-author $DRAFT is the FULL Write content
250
+ # (frontmatter + body); compute_external_comms_key strips the frontmatter.
251
+ KEY=$(compute_external_comms_key "$DRAFT" "$SURFACE")
235
252
  MARKER="${SESSION_DIR}/external-comms-${EXTERNAL_COMMS_EVALUATOR_ID}-reviewed-${KEY}"
236
253
 
237
254
  if [ -f "$MARKER" ]; then
@@ -244,7 +261,7 @@ fi
244
261
  # PostToolUse mark hook can derive the canonical marker key locally
245
262
  # (sha256(DRAFT + '\n' + SURFACE)). Single fire per gate cycle.
246
263
  VERDICT_PREFIX="${EXTERNAL_COMMS_VERDICT_PREFIX:-EXTERNAL_COMMS_${EXTERNAL_COMMS_EVALUATOR_ID^^}}"
247
- REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers. The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
264
+ REASON=$(printf 'BLOCKED (external-comms gate / %s evaluator): %s draft has not been reviewed by %s. Delegate to %s (subagent_type: '"'"'%s'"'"') with a prompt that starts with the line `SURFACE: %s` and wraps the draft body verbatim inside `<draft>...</draft>` markers (for the changeset-author surface the body is the changeset summary WITHOUT the leading `---` frontmatter block — the gate strips frontmatter before hashing the marker key). The PostToolUse hook derives the marker key from that structure and marks the draft reviewed when the subagent emits %s_VERDICT: PASS — single fire suffices. Use %s for an interactive walkthrough. Override only when intentional: BYPASS_RISK_GATE=1.' \
248
265
  "$EXTERNAL_COMMS_EVALUATOR_ID" "$SURFACE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$EXTERNAL_COMMS_SUBAGENT_TYPE" "$SURFACE" "$VERDICT_PREFIX" "$EXTERNAL_COMMS_ASSESS_SKILL")
249
266
  deny_with_reason "$REASON"
250
267
  exit 0
@@ -1,44 +1,101 @@
1
1
  #!/bin/bash
2
2
  # Shared helper: derive the external-comms marker key from an agent's
3
3
  # tool_input.prompt by extracting the structured `SURFACE: <name>` line
4
- # and `<draft>...</draft>` block, then computing
5
- # sha256(DRAFT + '\n' + SURFACE) — the same key shape the gate computes
6
- # at PreToolUse time (external-comms-gate.sh line 229).
4
+ # and `<draft>...</draft>` block, then computing the canonical key via
5
+ # compute_external_comms_key — the same key shape the gate computes at
6
+ # PreToolUse time (external-comms-gate.sh).
7
7
  #
8
8
  # P166 + ADR-028 amended 2026-05-16: the PostToolUse:Agent mark hook
9
9
  # derives the marker key from observed runtime state instead of trusting
10
10
  # an agent-emitted EXTERNAL_COMMS_<EVAL>_KEY line. Removes the
11
11
  # double-invocation cost class — single fire per gate cycle suffices.
12
12
  #
13
+ # P010 + ADR-028 amended 2026-05-25: the marker key is computed via the
14
+ # SINGLE canonical normalization in compute_external_comms_key, shared
15
+ # byte-for-byte between the gate (PreToolUse) and this mark-hook helper
16
+ # (PostToolUse). The normalization strips the changeset YAML frontmatter
17
+ # block before hashing (the gate sees the FULL Write content incl.
18
+ # frontmatter; the agent wraps only the body in <draft>) and applies a
19
+ # single canonical trailing-whitespace strip so the two sides cannot
20
+ # diverge. Fixes the deny-after-PASS marker-key mismatch (P198 / #149).
21
+ #
13
22
  # Canonical source: packages/shared/hooks/lib/external-comms-key.sh
14
23
  # Synced byte-identically into each consumer plugin's hooks/lib/ via
15
24
  # scripts/sync-external-comms-gate.sh (ADR-017 duplicate-script pattern).
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # compute_external_comms_key <draft> <surface>
16
28
  #
17
- # Returns the 64-char hex sha256 on stdout when both markers are present
18
- # in the prompt. Returns empty string when either marker is absent — the
19
- # caller falls back to the agent-emitted KEY for backward compatibility
20
- # with cached old SKILL.md / agent prompts.
29
+ # THE single source of truth for the external-comms marker key. Both the
30
+ # PreToolUse gate and the PostToolUse mark hook compute the key through
31
+ # this function so they hash byte-identical input (ADR-028 amended
32
+ # 2026-05-25). Echoes the 64-char lowercase sha256 hex on stdout.
33
+ #
34
+ # normalize(draft, surface):
35
+ # - changeset-author surface: strip the leading YAML frontmatter block
36
+ # (`---\n...\n---\n` plus the blank line after it). Changeset files
37
+ # carry `---\n"@windyroad/x": minor\n---\n\n<body>`; the gate sees the
38
+ # whole thing via tool_input.content while the agent wraps only the
39
+ # <body> in <draft>. Stripping frontmatter makes the two inputs equal.
40
+ # All other surfaces (gh-*, npm-publish) are already body-only via the
41
+ # gate's --body / --field extraction, so they are left unchanged.
42
+ # - all surfaces: rstrip ALL trailing whitespace. This single canonical
43
+ # newline normalization subsumes both the gate's `$()` trailing-newline
44
+ # strip and this helper's `<draft>` regex single-newline strip, so the
45
+ # two sides are provably symmetric on trailing whitespace.
46
+ # key = sha256(normalize(draft, surface) + '\n' + surface)
47
+ # ---------------------------------------------------------------------------
48
+ compute_external_comms_key() {
49
+ local draft="$1" surface="$2"
50
+ EXTCOMMS_DRAFT="$draft" EXTCOMMS_SURFACE="$surface" python3 -c "
51
+ import os, re, hashlib
52
+ draft = os.environ.get('EXTCOMMS_DRAFT', '')
53
+ surface = os.environ.get('EXTCOMMS_SURFACE', '')
54
+ # changeset-author: strip the leading YAML frontmatter block + blank line.
55
+ if surface == 'changeset-author':
56
+ draft = re.sub(r'^---\n.*?\n---\n\n?', '', draft, count=1, flags=re.DOTALL)
57
+ # Single canonical newline normalization: strip all trailing whitespace.
58
+ draft = draft.rstrip()
59
+ print(hashlib.sha256((draft + '\n' + surface).encode('utf-8')).hexdigest())
60
+ " 2>/dev/null
61
+ }
21
62
 
63
+ # ---------------------------------------------------------------------------
64
+ # derive_external_comms_key_from_prompt <prompt>
65
+ #
66
+ # Extracts the (SURFACE, draft-body) pair from an agent prompt's structured
67
+ # `SURFACE: <name>` line + `<draft>...</draft>` block, then delegates to
68
+ # compute_external_comms_key so the normalization lives in exactly one place.
69
+ #
70
+ # Returns the 64-char hex sha256 on stdout when both markers are present in
71
+ # the prompt. Returns empty string when either marker is absent — the caller
72
+ # falls back to the agent-emitted KEY for backward compatibility with cached
73
+ # old SKILL.md / agent prompts.
74
+ # ---------------------------------------------------------------------------
22
75
  derive_external_comms_key_from_prompt() {
23
76
  local prompt="$1"
24
77
  [ -n "$prompt" ] || { echo ""; return 0; }
25
- printf '%s' "$prompt" | python3 -c "
26
- import sys, re, hashlib
78
+ # Extract SURFACE + <draft> body in one pass. The two fields are emitted
79
+ # \x1f-separated (ASCII unit separator) so a body containing newlines — or
80
+ # an empty body — round-trips through command substitution intact (only
81
+ # trailing newlines are dropped, which compute_external_comms_key rstrips
82
+ # anyway). Empty output when either marker is absent → empty key.
83
+ local extracted
84
+ extracted=$(printf '%s' "$prompt" | python3 -c "
85
+ import sys, re
27
86
  text = sys.stdin.read()
28
- # DRAFT extraction: non-greedy match between <draft>...</draft>.
29
- # Tolerates an optional newline immediately after <draft> and before </draft>
30
- # so the body content does not capture wrapping newlines.
87
+ # DRAFT: non-greedy match between <draft>...</draft>, tolerating an optional
88
+ # newline immediately after <draft> and before </draft>.
31
89
  draft_match = re.search(r'<draft>\n?(.*?)\n?</draft>', text, re.DOTALL)
32
- # SURFACE extraction: must be anchored to line start (MULTILINE) to avoid
33
- # matching prose like 'context says SURFACE: x'. Surface name is a single
34
- # token: letter + word/hyphen chars.
90
+ # SURFACE: anchored to line start (MULTILINE) so prose like 'context says
91
+ # SURFACE: x' does not match. Surface name is a single letter+word/hyphen token.
35
92
  surface_match = re.search(r'^SURFACE:\s*([A-Za-z][\w-]*)', text, re.MULTILINE)
36
93
  if not draft_match or not surface_match:
37
- print('')
38
94
  sys.exit(0)
39
- draft = draft_match.group(1)
40
- surface = surface_match.group(1)
41
- payload = (draft + '\n' + surface).encode('utf-8')
42
- print(hashlib.sha256(payload).hexdigest())
43
- " 2>/dev/null
95
+ sys.stdout.write(surface_match.group(1) + '\x1f' + draft_match.group(1))
96
+ " 2>/dev/null) || extracted=""
97
+ [ -n "$extracted" ] || { echo ""; return 0; }
98
+ local surface="${extracted%%$'\x1f'*}"
99
+ local body="${extracted#*$'\x1f'}"
100
+ compute_external_comms_key "$body" "$surface"
44
101
  }
@@ -198,3 +198,24 @@ run_hook() {
198
198
  [ "$status" -eq 0 ]
199
199
  [[ "$output" == *"deny"* ]]
200
200
  }
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # P010 / ADR-028 amended 2026-05-25 — deny-after-PASS regression (voice-tone).
204
+ # Mirror of the risk-scorer regression: the gate sees the FULL changeset
205
+ # content (YAML frontmatter + body) but the mark hook keys the marker on the
206
+ # <draft> body. After the fix the gate strips frontmatter before hashing, so
207
+ # a body-keyed voice-tone PASS marker permits the changeset Write.
208
+ # ---------------------------------------------------------------------------
209
+
210
+ @test "P010: changeset Write permits when the voice-tone PASS marker is keyed on the <draft> body (frontmatter stripped before hash)" {
211
+ BODY="external-comms gate strips changeset frontmatter before key hash"
212
+ SURFACE="changeset-author"
213
+ KEY=$(printf '%s\n%s' "$BODY" "$SURFACE" | shasum -a 256 | cut -d' ' -f1)
214
+ touch "${RDIR}/external-comms-voice-tone-reviewed-${KEY}"
215
+
216
+ CONTENT=$'---\n"@windyroad/voice-tone": patch\n---\n\n'"$BODY"
217
+ INPUT=$(build_write_input ".changeset/p010-fix.md" "$CONTENT")
218
+ run_hook "$INPUT"
219
+ [ "$status" -eq 0 ]
220
+ [ -z "$output" ]
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/voice-tone",
3
- "version": "0.5.3",
3
+ "version": "0.5.4-preview.374",
4
4
  "description": "Voice and tone enforcement for user-facing copy",
5
5
  "bin": {
6
6
  "windyroad-voice-tone": "./bin/install.mjs"