@windyroad/voice-tone 0.2.0 → 0.2.1-preview.104
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/.claude-plugin/plugin.json +2 -2
- package/README.md +62 -0
- package/bin/install.mjs +4 -3
- package/hooks/hooks.json +0 -3
- package/hooks/lib/gate-helpers.sh +174 -0
- package/hooks/lib/review-gate.sh +1 -1
- package/hooks/test/review-gate.bats +49 -0
- package/hooks/test/voice-tone-no-stop-hook.bats +15 -0
- package/hooks/test/voice-tone-project-root.bats +20 -0
- package/hooks/voice-tone-enforce-edit.sh +11 -1
- package/hooks/voice-tone-eval.sh +1 -1
- package/lib/install-utils.mjs +146 -0
- package/package.json +3 -2
- package/skills/{wr:voice-tone → update-guide}/SKILL.md +1 -1
- package/hooks/voice-tone-reset-marker.sh +0 -22
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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 (TTL: 1800s) |
|
|
43
|
+
|
|
44
|
+
## Agent
|
|
45
|
+
|
|
46
|
+
The `wr-voice-tone:agent` reads your `docs/VOICE-AND-TONE.md` and reviews proposed copy changes against:
|
|
47
|
+
|
|
48
|
+
- Voice principles and personality traits
|
|
49
|
+
- Tone guidance for different contexts
|
|
50
|
+
- Banned words and patterns
|
|
51
|
+
- Preferred terminology
|
|
52
|
+
|
|
53
|
+
## Updating and Uninstalling
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @windyroad/voice-tone --update
|
|
57
|
+
npx @windyroad/voice-tone --uninstall
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Licence
|
|
61
|
+
|
|
62
|
+
[MIT](../../LICENSE)
|
package/bin/install.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { resolve, dirname } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const utils = await import(resolve(__dirname, "
|
|
7
|
+
const utils = await import(resolve(__dirname, "../lib/install-utils.mjs"));
|
|
8
8
|
|
|
9
9
|
const PLUGIN = "wr-voice-tone";
|
|
10
10
|
const DEPS = [];
|
|
@@ -20,6 +20,7 @@ Voice and tone enforcement for user-facing copy
|
|
|
20
20
|
Options:
|
|
21
21
|
--update Update this plugin and its skills
|
|
22
22
|
--uninstall Remove this plugin
|
|
23
|
+
--scope Installation scope: project (default) or user
|
|
23
24
|
--dry-run Show what would be done without executing
|
|
24
25
|
--help, -h Show this help
|
|
25
26
|
`);
|
|
@@ -36,7 +37,7 @@ utils.checkPrerequisites();
|
|
|
36
37
|
if (flags.uninstall) {
|
|
37
38
|
utils.uninstallPackage(PLUGIN);
|
|
38
39
|
} else if (flags.update) {
|
|
39
|
-
utils.updatePackage(PLUGIN);
|
|
40
|
+
utils.updatePackage(PLUGIN, { scope: flags.scope });
|
|
40
41
|
} else {
|
|
41
|
-
utils.installPackage(PLUGIN, { deps: DEPS });
|
|
42
|
+
utils.installPackage(PLUGIN, { deps: DEPS, scope: flags.scope });
|
|
42
43
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
],
|
|
9
9
|
"PostToolUse": [
|
|
10
10
|
{ "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-mark-reviewed.sh" }] }
|
|
11
|
-
],
|
|
12
|
-
"Stop": [
|
|
13
|
-
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/voice-tone-reset-marker.sh" }] }
|
|
14
11
|
]
|
|
15
12
|
}
|
|
16
13
|
}
|
|
@@ -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
|
+
}
|
package/hooks/lib/review-gate.sh
CHANGED
|
@@ -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:-
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P001 / ADR-009: Stop-hook marker reset removed.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
PLUGIN_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
@test "voice-tone: hooks.json has no Stop hook entry (ADR-009)" {
|
|
10
|
+
! grep -q '"Stop"' "$PLUGIN_DIR/hooks/hooks.json"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@test "voice-tone: voice-tone-reset-marker.sh has been removed" {
|
|
14
|
+
[ ! -f "$PLUGIN_DIR/hooks/voice-tone-reset-marker.sh" ]
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# P004: voice-tone-enforce-edit.sh project-root check.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
HOOK="$SCRIPT_DIR/voice-tone-enforce-edit.sh"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
run_hook_with_file() {
|
|
11
|
+
local file_path="$1"
|
|
12
|
+
local json="{\"tool_input\":{\"file_path\":\"${file_path}\"},\"session_id\":\"test-$$\"}"
|
|
13
|
+
echo "$json" | bash "$HOOK"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@test "voice-tone project-root: absolute .tsx outside project exits 0" {
|
|
17
|
+
run run_hook_with_file "/Users/other/project/src/foo.tsx"
|
|
18
|
+
[ "$status" -eq 0 ]
|
|
19
|
+
[[ "$output" != *"BLOCKED"* ]]
|
|
20
|
+
}
|
|
@@ -35,6 +35,16 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
35
35
|
exit 0
|
|
36
36
|
fi
|
|
37
37
|
|
|
38
|
+
# P004: Only gate files inside the project root.
|
|
39
|
+
case "$FILE_PATH" in
|
|
40
|
+
/*)
|
|
41
|
+
case "$FILE_PATH" in
|
|
42
|
+
"$PWD"/*) ;;
|
|
43
|
+
*) exit 0 ;;
|
|
44
|
+
esac
|
|
45
|
+
;;
|
|
46
|
+
esac
|
|
47
|
+
|
|
38
48
|
# Gate copy-bearing files
|
|
39
49
|
case "$FILE_PATH" in
|
|
40
50
|
*.html|*.jsx|*.tsx|*.vue|*.svelte|*.ejs|*.hbs) ;;
|
|
@@ -45,7 +55,7 @@ BASENAME=$(basename "$FILE_PATH")
|
|
|
45
55
|
|
|
46
56
|
# If no policy file exists, block and direct to create skill
|
|
47
57
|
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:
|
|
58
|
+
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
59
|
exit 0
|
|
50
60
|
fi
|
|
51
61
|
|
package/hooks/voice-tone-eval.sh
CHANGED
|
@@ -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:
|
|
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
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install utilities for @windyroad/* packages.
|
|
3
|
+
* Used by both per-plugin installers and the meta-installer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const MARKETPLACE_REPO = "windyroad/agent-plugins";
|
|
9
|
+
const MARKETPLACE_NAME = "windyroad";
|
|
10
|
+
|
|
11
|
+
let _dryRun = false;
|
|
12
|
+
|
|
13
|
+
export { MARKETPLACE_REPO, MARKETPLACE_NAME };
|
|
14
|
+
|
|
15
|
+
export function setDryRun(value) {
|
|
16
|
+
_dryRun = value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isDryRun() {
|
|
20
|
+
return _dryRun;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function run(cmd, label) {
|
|
24
|
+
console.log(` ${label}...`);
|
|
25
|
+
if (_dryRun) {
|
|
26
|
+
console.log(` [dry-run] ${cmd}`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
execSync(cmd, { stdio: "inherit" });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
console.error(` FAILED: ${label}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkPrerequisites() {
|
|
39
|
+
if (_dryRun) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
execSync("claude --version", { stdio: "pipe" });
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(
|
|
45
|
+
"Error: 'claude' CLI not found. Install Claude Code first:\n https://docs.anthropic.com/en/docs/claude-code\n"
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function addMarketplace() {
|
|
52
|
+
return run(
|
|
53
|
+
`claude plugin marketplace add ${MARKETPLACE_REPO}`,
|
|
54
|
+
`Marketplace: ${MARKETPLACE_NAME}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function installPlugin(pluginName, { scope = "project" } = {}) {
|
|
59
|
+
return run(
|
|
60
|
+
`claude plugin install ${pluginName}@${MARKETPLACE_NAME} --scope ${scope}`,
|
|
61
|
+
pluginName
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function updatePlugin(pluginName, { scope = "project" } = {}) {
|
|
66
|
+
return run(
|
|
67
|
+
`claude plugin update "${pluginName}@${MARKETPLACE_NAME}" --scope ${scope}`,
|
|
68
|
+
pluginName
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function uninstallPlugin(pluginName) {
|
|
73
|
+
return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Install a single package: marketplace add + plugin install.
|
|
78
|
+
*/
|
|
79
|
+
export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
|
|
80
|
+
console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
|
|
81
|
+
|
|
82
|
+
addMarketplace();
|
|
83
|
+
installPlugin(pluginName, { scope });
|
|
84
|
+
|
|
85
|
+
if (deps.length > 0) {
|
|
86
|
+
console.log(`\nNote: This plugin works best with:`);
|
|
87
|
+
for (const dep of deps) {
|
|
88
|
+
console.log(` - @windyroad/${dep.replace("wr-", "")} (npx @windyroad/${dep.replace("wr-", "")})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(
|
|
93
|
+
`\nDone! Restart Claude Code to activate.\n`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update a single package.
|
|
99
|
+
*/
|
|
100
|
+
export function updatePackage(pluginName, { scope = "project" } = {}) {
|
|
101
|
+
console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
102
|
+
|
|
103
|
+
run(
|
|
104
|
+
`claude plugin marketplace update ${MARKETPLACE_NAME}`,
|
|
105
|
+
"Updating marketplace"
|
|
106
|
+
);
|
|
107
|
+
updatePlugin(pluginName, { scope });
|
|
108
|
+
|
|
109
|
+
console.log("\nDone! Restart Claude Code to apply updates.\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Uninstall a single package.
|
|
114
|
+
*/
|
|
115
|
+
export function uninstallPackage(pluginName) {
|
|
116
|
+
console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
117
|
+
|
|
118
|
+
uninstallPlugin(pluginName);
|
|
119
|
+
|
|
120
|
+
console.log("\nDone. Restart Claude Code to apply changes.\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse standard flags used by all per-plugin installers.
|
|
125
|
+
*/
|
|
126
|
+
export function parseStandardArgs(argv) {
|
|
127
|
+
const args = argv.slice(2);
|
|
128
|
+
const flags = {
|
|
129
|
+
help: args.includes("--help") || args.includes("-h"),
|
|
130
|
+
uninstall: args.includes("--uninstall"),
|
|
131
|
+
update: args.includes("--update"),
|
|
132
|
+
dryRun: args.includes("--dry-run"),
|
|
133
|
+
scope: "project",
|
|
134
|
+
};
|
|
135
|
+
const scopeIdx = args.indexOf("--scope");
|
|
136
|
+
if (scopeIdx !== -1 && args[scopeIdx + 1]) {
|
|
137
|
+
const val = args[scopeIdx + 1];
|
|
138
|
+
if (["project", "user", "local"].includes(val)) {
|
|
139
|
+
flags.scope = val;
|
|
140
|
+
} else {
|
|
141
|
+
console.error("--scope requires: project, user, or local");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return flags;
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windyroad/voice-tone",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-preview.104",
|
|
4
4
|
"description": "Voice and tone enforcement for user-facing copy",
|
|
5
5
|
"bin": {
|
|
6
6
|
"windyroad-voice-tone": "./bin/install.mjs"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"agents/",
|
|
24
24
|
"hooks/",
|
|
25
25
|
"skills/",
|
|
26
|
-
".claude-plugin/"
|
|
26
|
+
".claude-plugin/",
|
|
27
|
+
"lib/"
|
|
27
28
|
]
|
|
28
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: wr
|
|
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
|
---
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Voice & Tone - Stop hook
|
|
3
|
-
# Removes the session marker so the next prompt requires a fresh voice review.
|
|
4
|
-
# This tightens the gate from per-session to per-turn.
|
|
5
|
-
|
|
6
|
-
INPUT=$(cat)
|
|
7
|
-
|
|
8
|
-
SESSION_ID=$(echo "$INPUT" | python3 -c "
|
|
9
|
-
import sys, json
|
|
10
|
-
data = json.load(sys.stdin)
|
|
11
|
-
print(data.get('session_id', ''))
|
|
12
|
-
" 2>/dev/null)
|
|
13
|
-
|
|
14
|
-
if [ -n "$SESSION_ID" ]; then
|
|
15
|
-
rm -f "/tmp/voice-tone-reviewed-${SESSION_ID}" \
|
|
16
|
-
"/tmp/voice-tone-reviewed-${SESSION_ID}.hash" \
|
|
17
|
-
"/tmp/voice-tone-verdict" \
|
|
18
|
-
"/tmp/voice-tone-plan-reviewed-${SESSION_ID}" \
|
|
19
|
-
"/tmp/voice-tone-plan-verdict"
|
|
20
|
-
fi
|
|
21
|
-
|
|
22
|
-
exit 0
|