@windyroad/voice-tone 0.1.2 → 0.1.3-preview.27

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/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @windyroad/voice-tone
2
+
3
+ **Voice and tone enforcement for Claude Code.** Reviews user-facing copy against your brand's voice and tone guide before it ships.
4
+
5
+ Part of [Windy Road Agent Plugins](../../README.md).
6
+
7
+ ## What It Does
8
+
9
+ When an AI agent writes user-facing text -- button labels, error messages, onboarding copy, marketing pages -- it doesn't know your brand voice. This plugin teaches it.
10
+
11
+ The voice-tone plugin:
12
+
13
+ 1. **Detects** when an edit touches user-facing copy
14
+ 2. **Blocks** the edit until the voice-tone agent has reviewed it
15
+ 3. **Reviews** the proposed copy against your `docs/VOICE-AND-TONE.md` guide
16
+ 4. **Reports** violations with suggested fixes that match your brand's voice principles, banned patterns, and word list
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npx @windyroad/voice-tone
22
+ ```
23
+
24
+ Restart Claude Code after installing.
25
+
26
+ ## Usage
27
+
28
+ The plugin works automatically. On first use in a project without a voice guide, it blocks edits and directs you to create one:
29
+
30
+ ```
31
+ /wr-voice-tone:update-guide
32
+ ```
33
+
34
+ This examines your existing content and asks about your brand voice, target audience, and tone preferences to generate a `docs/VOICE-AND-TONE.md` tailored to your project.
35
+
36
+ ## How It Works
37
+
38
+ | Hook | Trigger | What it does |
39
+ |------|---------|-------------|
40
+ | `voice-tone-eval.sh` | Every prompt | Evaluates whether the task involves user-facing copy |
41
+ | `voice-tone-enforce-edit.sh` | Edit or Write | Blocks edits until the voice-tone agent has reviewed |
42
+ | `voice-tone-mark-reviewed.sh` | Agent completes | Marks the review as done |
43
+ | `voice-tone-reset-marker.sh` | Session end | Cleans up review markers |
44
+
45
+ ## Agent
46
+
47
+ The `wr-voice-tone:agent` reads your `docs/VOICE-AND-TONE.md` and reviews proposed copy changes against:
48
+
49
+ - Voice principles and personality traits
50
+ - Tone guidance for different contexts
51
+ - Banned words and patterns
52
+ - Preferred terminology
53
+
54
+ ## Updating and Uninstalling
55
+
56
+ ```bash
57
+ npx @windyroad/voice-tone --update
58
+ npx @windyroad/voice-tone --uninstall
59
+ ```
60
+
61
+ ## Licence
62
+
63
+ [MIT](../../LICENSE)
@@ -0,0 +1,174 @@
1
+ #!/bin/bash
2
+ # Shared portable helpers for gate enforcement hooks.
3
+ # Sourced by architect-gate.sh, risk-gate.sh, and all hook scripts.
4
+ # Provides: _mtime, _hashcmd, _doc_exclusions, _err_trap, _get_*
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Portable utilities
8
+ # ---------------------------------------------------------------------------
9
+
10
+ # Portable mtime: tries GNU stat, falls back to macOS stat
11
+ _mtime() { stat -c%Y "$1" 2>/dev/null || /usr/bin/stat -f%m "$1" 2>/dev/null || echo 0; }
12
+
13
+ # Portable hash: tries md5sum, falls back to md5 -r, then shasum
14
+ _hashcmd() { md5sum 2>/dev/null || md5 -r 2>/dev/null || shasum 2>/dev/null; }
15
+
16
+ # Paths excluded from pipeline state hashing and docs-only detection.
17
+ _doc_exclusions() {
18
+ echo ':!docs/' ':!.risk-reports/' ':!.changeset/' ':!governance/' ':!.claude/plans/' ':!CLAUDE.md' ':!AGENTS.md' ':!PRINCIPLES.md' ':!DECISION-MANAGEMENT.md' ':!AGENTIC_RISK_REGISTER.md' ':!PROBLEM-MANAGEMENT.md'
19
+ }
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # ERR trap: outputs diagnostic JSON on hook errors (P010)
23
+ # Usage: source gate-helpers.sh at top of hook, then call _enable_err_trap
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _enable_err_trap() {
27
+ trap '_err_trap_handler "$BASH_SOURCE" "$LINENO" "$BASH_COMMAND"' ERR
28
+ }
29
+
30
+ _err_trap_handler() {
31
+ local script="$1" line="$2" cmd="$3"
32
+ local name
33
+ name=$(basename "$script" 2>/dev/null || echo "$script")
34
+ # Output diagnostic as systemMessage so it's visible in conversation
35
+ cat <<EOF
36
+ {
37
+ "systemMessage": "Hook error in ${name} at line ${line}: ${cmd}"
38
+ }
39
+ EOF
40
+ }
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # JSON input parsing: standardised helpers replacing inline python3
44
+ # Each reads from _HOOK_INPUT (set by the hook before calling these)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # Store hook input for reuse by parsing helpers
48
+ _HOOK_INPUT=""
49
+
50
+ _parse_input() {
51
+ _HOOK_INPUT=$(cat)
52
+ }
53
+
54
+ _get_tool_name() {
55
+ echo "$_HOOK_INPUT" | python3 -c "
56
+ import sys, json
57
+ try:
58
+ data = json.load(sys.stdin)
59
+ print(data.get('tool_name', ''))
60
+ except:
61
+ print('')
62
+ " 2>/dev/null || echo ""
63
+ }
64
+
65
+ _get_session_id() {
66
+ echo "$_HOOK_INPUT" | python3 -c "
67
+ import sys, json
68
+ try:
69
+ data = json.load(sys.stdin)
70
+ print(data.get('session_id', ''))
71
+ except:
72
+ print('')
73
+ " 2>/dev/null || echo ""
74
+ }
75
+
76
+ _get_command() {
77
+ echo "$_HOOK_INPUT" | python3 -c "
78
+ import sys, json
79
+ try:
80
+ data = json.load(sys.stdin)
81
+ print(data.get('tool_input', {}).get('command', ''))
82
+ except:
83
+ print('')
84
+ " 2>/dev/null || echo ""
85
+ }
86
+
87
+ _get_file_path() {
88
+ echo "$_HOOK_INPUT" | python3 -c "
89
+ import sys, json
90
+ try:
91
+ data = json.load(sys.stdin)
92
+ ti = data.get('tool_input', {})
93
+ print(ti.get('file_path', ti.get('path', '')))
94
+ except:
95
+ print('')
96
+ " 2>/dev/null || echo ""
97
+ }
98
+
99
+ _get_subagent_type() {
100
+ echo "$_HOOK_INPUT" | python3 -c "
101
+ import sys, json
102
+ try:
103
+ data = json.load(sys.stdin)
104
+ print(data.get('tool_input', {}).get('subagent_type', ''))
105
+ except:
106
+ print('')
107
+ " 2>/dev/null || echo ""
108
+ }
109
+
110
+ _get_user_prompt() {
111
+ echo "$_HOOK_INPUT" | python3 -c "
112
+ import sys, json
113
+ try:
114
+ data = json.load(sys.stdin)
115
+ print(data.get('user_prompt', ''))
116
+ except:
117
+ print('')
118
+ " 2>/dev/null || echo ""
119
+ }
120
+
121
+ _get_tool_output() {
122
+ echo "$_HOOK_INPUT" | python3 -c "
123
+ import sys, json
124
+ try:
125
+ data = json.load(sys.stdin)
126
+ # PostToolUse provides tool_response (dict with content array), not tool_output
127
+ tr = data.get('tool_response', {})
128
+ if isinstance(tr, dict):
129
+ content = tr.get('content', [])
130
+ if isinstance(content, list):
131
+ texts = [c.get('text', '') for c in content if isinstance(c, dict) and c.get('type') == 'text']
132
+ if texts:
133
+ print('\n'.join(texts))
134
+ sys.exit(0)
135
+ # Fallback for older/different hook formats
136
+ print(data.get('tool_output', ''))
137
+ except:
138
+ print('')
139
+ " 2>/dev/null || echo ""
140
+ }
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Session-scoped tmp directory for risk files
144
+ # ---------------------------------------------------------------------------
145
+
146
+ # Returns the session-scoped directory for risk temp files.
147
+ # Creates the directory if it doesn't exist.
148
+ # Usage: DIR=$(_risk_dir "$SESSION_ID"); echo "1" > "$DIR/commit"
149
+ _risk_dir() {
150
+ local sid="$1"
151
+ local dir="${TMPDIR:-/tmp}/claude-risk-${sid}"
152
+ mkdir -p "$dir"
153
+ echo "$dir"
154
+ }
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Non-doc file detection for WIP gating
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _is_doc_file() {
161
+ local file_path="$1"
162
+ local EXCL
163
+ EXCL=$(_doc_exclusions)
164
+ for pattern in $EXCL; do
165
+ local clean="${pattern#:!}"
166
+ case "$file_path" in
167
+ *"$clean"*) return 0 ;;
168
+ esac
169
+ done
170
+ case "$file_path" in
171
+ *.claude/*|*.risk-reports/*|*RISK-POLICY.md) return 0 ;;
172
+ esac
173
+ return 1
174
+ }
@@ -16,7 +16,7 @@ check_review_gate() {
16
16
  local POLICY_FILE="$3" # e.g., "docs/STYLE-GUIDE.md"
17
17
  local MARKER="/tmp/${SYSTEM}-reviewed-${SESSION_ID}"
18
18
  local HASH_FILE="/tmp/${SYSTEM}-reviewed-${SESSION_ID}.hash"
19
- local TTL_SECONDS="${REVIEW_TTL:-600}"
19
+ local TTL_SECONDS="${REVIEW_TTL:-1800}"
20
20
 
21
21
  # 1. Marker must exist
22
22
  if [ ! -f "$MARKER" ]; then
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Tests for review-gate.sh (shared by voice-tone, style-guide, jtbd)
4
+
5
+ setup() {
6
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
7
+ source "$SCRIPT_DIR/lib/review-gate.sh"
8
+ TEST_SESSION="test-$$-$BATS_TEST_NUMBER"
9
+ TMPDIR_ORIG=$(mktemp -d)
10
+ }
11
+
12
+ teardown() {
13
+ rm -f "/tmp/voice-tone-reviewed-${TEST_SESSION}"
14
+ rm -f "/tmp/voice-tone-reviewed-${TEST_SESSION}.hash"
15
+ rm -rf "$TMPDIR_ORIG"
16
+ }
17
+
18
+ @test "gate denies when no marker exists" {
19
+ run check_review_gate "$TEST_SESSION" "voice-tone" "docs/VOICE-AND-TONE.md"
20
+ [ "$status" -ne 0 ]
21
+ }
22
+
23
+ @test "gate allows when marker exists and is fresh" {
24
+ touch "/tmp/voice-tone-reviewed-${TEST_SESSION}"
25
+ run check_review_gate "$TEST_SESSION" "voice-tone" "docs/VOICE-AND-TONE.md"
26
+ [ "$status" -eq 0 ]
27
+ }
28
+
29
+ @test "gate denies when marker is expired" {
30
+ touch "/tmp/voice-tone-reviewed-${TEST_SESSION}"
31
+ REVIEW_TTL=0 run check_review_gate "$TEST_SESSION" "voice-tone" "docs/VOICE-AND-TONE.md"
32
+ [ "$status" -ne 0 ]
33
+ }
34
+
35
+ @test "store_review_hash creates hash file" {
36
+ store_review_hash "$TEST_SESSION" "voice-tone" "docs/VOICE-AND-TONE.md"
37
+ [ -f "/tmp/voice-tone-reviewed-${TEST_SESSION}.hash" ]
38
+ }
39
+
40
+ @test "_mtime returns a number" {
41
+ touch "/tmp/voice-tone-reviewed-${TEST_SESSION}"
42
+ result=$(_mtime "/tmp/voice-tone-reviewed-${TEST_SESSION}")
43
+ [[ "$result" =~ ^[0-9]+$ ]]
44
+ }
45
+
46
+ @test "_hashcmd produces output" {
47
+ result=$(echo "test" | _hashcmd)
48
+ [ -n "$result" ]
49
+ }
@@ -45,7 +45,7 @@ BASENAME=$(basename "$FILE_PATH")
45
45
 
46
46
  # If no policy file exists, block and direct to create skill
47
47
  if [ ! -f "docs/VOICE-AND-TONE.md" ]; then
48
- review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/VOICE-AND-TONE.md does not exist. Run /wr-voice-tone:create to generate a voice and tone guide for this project, then delegate to wr-voice-tone:agent for review."
48
+ review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/VOICE-AND-TONE.md does not exist. Run /wr-voice-tone:update-guide to generate a voice and tone guide for this project, then delegate to wr-voice-tone:agent for review."
49
49
  exit 0
50
50
  fi
51
51
 
@@ -28,7 +28,7 @@ else
28
28
  cat <<'HOOK_OUTPUT'
29
29
  NOTE: This project has UI files but no docs/VOICE-AND-TONE.md.
30
30
  If the user's task involves editing user-facing copy, the edit will be blocked
31
- until a voice and tone guide exists. Run /wr-voice-tone:create to generate one.
31
+ until a voice and tone guide exists. Run /wr-voice-tone:update-guide to generate one.
32
32
  HOOK_OUTPUT
33
33
  fi
34
34
  fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/voice-tone",
3
- "version": "0.1.2",
3
+ "version": "0.1.3-preview.27",
4
4
  "description": "Voice and tone enforcement for user-facing copy",
5
5
  "bin": {
6
6
  "windyroad-voice-tone": "./bin/install.mjs"
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: wr:voice-tone
2
+ name: wr-voice-tone:update-guide
3
3
  description: Create or update the project's docs/VOICE-AND-TONE.md by examining existing content and asking the user about brand voice, audience, and tone preferences.
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
5
5
  ---