@windyroad/jtbd 0.2.0 → 0.2.1-preview.34
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 +62 -0
- package/bin/install.mjs +3 -2
- package/hooks/jtbd-enforce-edit.sh +33 -7
- package/hooks/jtbd-eval.sh +10 -13
- package/hooks/jtbd-mark-reviewed.sh +2 -2
- package/hooks/lib/gate-helpers.sh +174 -0
- package/hooks/lib/review-gate.sh +1 -1
- package/hooks/test/jtbd-enforce-scope.bats +66 -0
- package/hooks/test/jtbd-eval.bats +46 -0
- package/hooks/test/jtbd-mark-reviewed.bats +20 -0
- package/lib/install-utils.mjs +143 -0
- package/package.json +3 -2
- package/skills/{wr:jtbd → update-guide}/SKILL.md +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @windyroad/jtbd
|
|
2
|
+
|
|
3
|
+
**Jobs-to-be-done enforcement for Claude Code.** Reviews UI changes against your documented user jobs, personas, and desired outcomes before they ship.
|
|
4
|
+
|
|
5
|
+
Part of [Windy Road Agent Plugins](../../README.md).
|
|
6
|
+
|
|
7
|
+
## What It Does
|
|
8
|
+
|
|
9
|
+
An AI agent building a feature doesn't know *why* the feature exists or *who* it's for. It builds what you describe, but can't validate whether the result actually serves the user's job-to-be-done.
|
|
10
|
+
|
|
11
|
+
The JTBD plugin:
|
|
12
|
+
|
|
13
|
+
1. **Detects** when an edit touches user-facing UI files
|
|
14
|
+
2. **Blocks** the edit until the JTBD agent has reviewed it
|
|
15
|
+
3. **Reviews** changes against your `docs/JOBS_TO_BE_DONE.md` and `docs/PRODUCT_DISCOVERY.md`
|
|
16
|
+
4. **Reports** alignment gaps -- features that don't map to a documented job, or that conflict with persona constraints
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx @windyroad/jtbd
|
|
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 JTBD document, it blocks edits and directs you to create one:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
/wr-jtbd:update-guide
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This examines your existing features and asks about your user jobs, personas, and desired outcomes to generate a `docs/JOBS_TO_BE_DONE.md`.
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
| Hook | Trigger | What it does |
|
|
39
|
+
|------|---------|-------------|
|
|
40
|
+
| `jtbd-eval.sh` | Every prompt | Evaluates whether the task involves user-facing UI |
|
|
41
|
+
| `jtbd-enforce-edit.sh` | Edit or Write | Blocks edits until the JTBD agent has reviewed |
|
|
42
|
+
| `jtbd-mark-reviewed.sh` | Agent completes | Marks the review as done |
|
|
43
|
+
| `jtbd-reset-marker.sh` | Session end | Cleans up review markers |
|
|
44
|
+
|
|
45
|
+
## Agent
|
|
46
|
+
|
|
47
|
+
The `wr-jtbd:agent` reads your `docs/JOBS_TO_BE_DONE.md` and reviews proposed UI changes against:
|
|
48
|
+
|
|
49
|
+
- Documented user jobs and their success criteria
|
|
50
|
+
- Persona definitions and constraints
|
|
51
|
+
- Screen-to-job mappings
|
|
52
|
+
|
|
53
|
+
## Updating and Uninstalling
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @windyroad/jtbd --update
|
|
57
|
+
npx @windyroad/jtbd --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-jtbd";
|
|
10
10
|
const DEPS = [];
|
|
@@ -20,6 +20,7 @@ Jobs-to-be-done enforcement for UI changes
|
|
|
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
|
`);
|
|
@@ -38,5 +39,5 @@ if (flags.uninstall) {
|
|
|
38
39
|
} else if (flags.update) {
|
|
39
40
|
utils.updatePackage(PLUGIN);
|
|
40
41
|
} else {
|
|
41
|
-
utils.installPackage(PLUGIN, { deps: DEPS });
|
|
42
|
+
utils.installPackage(PLUGIN, { deps: DEPS, scope: flags.scope });
|
|
42
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# JTBD - PreToolUse enforcement hook
|
|
3
|
-
# BLOCKS Edit/Write to
|
|
3
|
+
# BLOCKS Edit/Write to project files until jtbd-lead is consulted.
|
|
4
4
|
# Uses shared review-gate.sh for TTL, drift detection, and fail-closed.
|
|
5
5
|
|
|
6
6
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -35,17 +35,43 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
35
35
|
exit 0
|
|
36
36
|
fi
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
BASENAME=$(basename "$FILE_PATH")
|
|
39
|
+
|
|
40
|
+
# Exclude non-JTBD files (matches architect gate exclusions)
|
|
39
41
|
case "$FILE_PATH" in
|
|
40
|
-
*.
|
|
41
|
-
|
|
42
|
+
*.css|*.scss|*.sass|*.less)
|
|
43
|
+
exit 0 ;;
|
|
44
|
+
*.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.webp)
|
|
45
|
+
exit 0 ;;
|
|
46
|
+
*.woff|*.woff2|*.ttf|*.eot)
|
|
47
|
+
exit 0 ;;
|
|
48
|
+
*package-lock.json|*yarn.lock|*pnpm-lock.yaml)
|
|
49
|
+
exit 0 ;;
|
|
50
|
+
*.map)
|
|
51
|
+
exit 0 ;;
|
|
52
|
+
*.changeset/*.md|*/.changeset/*.md)
|
|
53
|
+
exit 0 ;;
|
|
54
|
+
*/MEMORY.md|*/.claude/projects/*/memory/*)
|
|
55
|
+
exit 0 ;;
|
|
56
|
+
*/.claude/plans/*.md|*.claude/plans/*.md)
|
|
57
|
+
exit 0 ;;
|
|
58
|
+
*/RISK-POLICY.md)
|
|
59
|
+
exit 0 ;;
|
|
60
|
+
*/.risk-reports/*)
|
|
61
|
+
exit 0 ;;
|
|
62
|
+
*/docs/BRIEFING.md|docs/BRIEFING.md)
|
|
63
|
+
exit 0 ;;
|
|
64
|
+
*/docs/problems/*.md|docs/problems/*.md)
|
|
65
|
+
exit 0 ;;
|
|
66
|
+
*/docs/JOBS_TO_BE_DONE.md|docs/JOBS_TO_BE_DONE.md)
|
|
67
|
+
exit 0 ;;
|
|
68
|
+
*/docs/PRODUCT_DISCOVERY.md|docs/PRODUCT_DISCOVERY.md)
|
|
69
|
+
exit 0 ;;
|
|
42
70
|
esac
|
|
43
71
|
|
|
44
|
-
BASENAME=$(basename "$FILE_PATH")
|
|
45
|
-
|
|
46
72
|
# If no JTBD doc exists, block and direct to create skill
|
|
47
73
|
if [ ! -f "docs/JOBS_TO_BE_DONE.md" ]; then
|
|
48
|
-
review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/JOBS_TO_BE_DONE.md does not exist. Run /wr-jtbd:
|
|
74
|
+
review_gate_deny "BLOCKED: Cannot edit '${BASENAME}' because docs/JOBS_TO_BE_DONE.md does not exist. Run /wr-jtbd:update-guide to generate a JTBD document for this project, then delegate to wr-jtbd:agent for review."
|
|
49
75
|
exit 0
|
|
50
76
|
fi
|
|
51
77
|
|
package/hooks/jtbd-eval.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# JTBD - UserPromptSubmit hook
|
|
3
3
|
# Detects JOBS_TO_BE_DONE.md in the project and injects delegation instruction.
|
|
4
|
-
# If the file doesn't exist, instructs Claude to create it via the
|
|
4
|
+
# If the file doesn't exist, instructs Claude to create it via the update-guide skill.
|
|
5
5
|
|
|
6
6
|
if [ -f "docs/JOBS_TO_BE_DONE.md" ]; then
|
|
7
7
|
cat <<'HOOK_OUTPUT'
|
|
@@ -9,27 +9,24 @@ INSTRUCTION: MANDATORY JTBD CHECK. YOU MUST FOLLOW THIS.
|
|
|
9
9
|
DETECTED: docs/JOBS_TO_BE_DONE.md exists in this project.
|
|
10
10
|
|
|
11
11
|
This is a NON-OPTIONAL instruction. You MUST use the jtbd-lead agent
|
|
12
|
-
before editing any
|
|
13
|
-
|
|
12
|
+
before editing any project file. This is proactive. Do not wait for the
|
|
13
|
+
user to ask.
|
|
14
14
|
|
|
15
15
|
REQUIRED ACTIONS:
|
|
16
16
|
1. Use the Agent tool to delegate to wr-jtbd:agent
|
|
17
17
|
(subagent_type: "wr-jtbd:agent")
|
|
18
18
|
2. The jtbd-lead will review proposed changes against docs/JOBS_TO_BE_DONE.md
|
|
19
19
|
and PRODUCT_DISCOVERY.md persona definitions
|
|
20
|
-
3. Do NOT write or edit
|
|
20
|
+
3. Do NOT write or edit project files without jtbd-lead review FIRST
|
|
21
21
|
4. Do NOT skip this step even if you think you can handle it yourself
|
|
22
22
|
|
|
23
|
-
SCOPE:
|
|
24
|
-
Does NOT apply to:
|
|
23
|
+
SCOPE: All project files.
|
|
24
|
+
Does NOT apply to: CSS, images, fonts, lockfiles, changesets, memory files, plan files.
|
|
25
25
|
HOOK_OUTPUT
|
|
26
26
|
else
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
If the user's task involves editing UI files, the edit will be blocked
|
|
32
|
-
until a JTBD document exists. Run /wr-jtbd:create to generate one.
|
|
27
|
+
cat <<'HOOK_OUTPUT'
|
|
28
|
+
NOTE: This project has no docs/JOBS_TO_BE_DONE.md.
|
|
29
|
+
Edits to project files will be blocked until a JTBD document exists.
|
|
30
|
+
Run /wr-jtbd:update-guide to generate one.
|
|
33
31
|
HOOK_OUTPUT
|
|
34
|
-
fi
|
|
35
32
|
fi
|
|
@@ -28,7 +28,7 @@ case "$SUBAGENT" in
|
|
|
28
28
|
case "$VERDICT" in
|
|
29
29
|
PASS)
|
|
30
30
|
touch "/tmp/jtbd-reviewed-${SESSION_ID}"
|
|
31
|
-
store_review_hash "$SESSION_ID" "jtbd" "docs/
|
|
31
|
+
store_review_hash "$SESSION_ID" "jtbd" "docs/JOBS_TO_BE_DONE.md"
|
|
32
32
|
;;
|
|
33
33
|
FAIL)
|
|
34
34
|
# Do NOT create marker — review found issues
|
|
@@ -36,7 +36,7 @@ case "$SUBAGENT" in
|
|
|
36
36
|
*)
|
|
37
37
|
# No verdict file — backward compat, allow with marker
|
|
38
38
|
touch "/tmp/jtbd-reviewed-${SESSION_ID}"
|
|
39
|
-
store_review_hash "$SESSION_ID" "jtbd" "docs/
|
|
39
|
+
store_review_hash "$SESSION_ID" "jtbd" "docs/JOBS_TO_BE_DONE.md"
|
|
40
40
|
;;
|
|
41
41
|
esac
|
|
42
42
|
|
|
@@ -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,66 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Tests for jtbd-enforce-edit.sh — verifies broadened scope with exclusions
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
HOOK="$SCRIPT_DIR/jtbd-enforce-edit.sh"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# Helper: check if a file extension is in the exclusion list by grepping the hook
|
|
11
|
+
file_is_excluded() {
|
|
12
|
+
local pattern="$1"
|
|
13
|
+
# The hook should have a case statement that exits 0 for excluded files
|
|
14
|
+
grep -q "$pattern" "$HOOK"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@test "enforce: excludes CSS files" {
|
|
18
|
+
file_is_excluded '\.css'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@test "enforce: excludes image files" {
|
|
22
|
+
file_is_excluded '\.png'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@test "enforce: excludes font files" {
|
|
26
|
+
file_is_excluded '\.woff'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@test "enforce: excludes lockfiles" {
|
|
30
|
+
file_is_excluded 'package-lock.json'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@test "enforce: excludes changeset files" {
|
|
34
|
+
file_is_excluded '\.changeset'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@test "enforce: excludes memory files" {
|
|
38
|
+
file_is_excluded 'MEMORY.md'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@test "enforce: excludes plan files" {
|
|
42
|
+
file_is_excluded '\.claude/plans'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@test "enforce: excludes risk reports" {
|
|
46
|
+
file_is_excluded '\.risk-reports'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@test "enforce: excludes RISK-POLICY.md" {
|
|
50
|
+
file_is_excluded 'RISK-POLICY.md'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@test "enforce: excludes JOBS_TO_BE_DONE.md (P002 chicken-and-egg fix)" {
|
|
54
|
+
# Must have a case pattern that exempts the policy file itself
|
|
55
|
+
grep -q 'JOBS_TO_BE_DONE.md)' "$HOOK"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@test "enforce: excludes PRODUCT_DISCOVERY.md" {
|
|
59
|
+
file_is_excluded 'PRODUCT_DISCOVERY.md'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@test "enforce: does NOT have UI-only case guard" {
|
|
63
|
+
# The old guard matched only UI extensions then exited for everything else.
|
|
64
|
+
# The new hook should NOT exit 0 for all non-UI files.
|
|
65
|
+
! grep -q '\*) exit 0 ;;' "$HOOK"
|
|
66
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Tests for jtbd-eval.sh — verifies JTBD suggestion fires for any project
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
HOOK="$SCRIPT_DIR/jtbd-eval.sh"
|
|
8
|
+
ORIG_DIR="$PWD"
|
|
9
|
+
TEST_DIR=$(mktemp -d)
|
|
10
|
+
cd "$TEST_DIR"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
teardown() {
|
|
14
|
+
cd "$ORIG_DIR"
|
|
15
|
+
rm -rf "$TEST_DIR"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@test "eval: suggests update-guide when JOBS_TO_BE_DONE.md missing (no UI files)" {
|
|
19
|
+
# No UI files, no docs — should still suggest
|
|
20
|
+
run bash "$HOOK"
|
|
21
|
+
[ "$status" -eq 0 ]
|
|
22
|
+
[[ "$output" == *"wr-jtbd:update-guide"* ]]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@test "eval: suggests update-guide when JOBS_TO_BE_DONE.md missing (with UI files)" {
|
|
26
|
+
mkdir -p src
|
|
27
|
+
touch src/App.tsx
|
|
28
|
+
run bash "$HOOK"
|
|
29
|
+
[ "$status" -eq 0 ]
|
|
30
|
+
[[ "$output" == *"wr-jtbd:update-guide"* ]]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@test "eval: injects enforcement instruction when JOBS_TO_BE_DONE.md exists" {
|
|
34
|
+
mkdir -p docs
|
|
35
|
+
echo "# Jobs" > docs/JOBS_TO_BE_DONE.md
|
|
36
|
+
run bash "$HOOK"
|
|
37
|
+
[ "$status" -eq 0 ]
|
|
38
|
+
[[ "$output" == *"MANDATORY JTBD CHECK"* ]]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@test "eval: does not reference UI-only scoping in output" {
|
|
42
|
+
run bash "$HOOK"
|
|
43
|
+
[ "$status" -eq 0 ]
|
|
44
|
+
# Should not contain the old UI-only messaging
|
|
45
|
+
[[ "$output" != *"UI files"* ]]
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Tests for JTBD mark-reviewed hook — verifies hash path matches enforce hook
|
|
4
|
+
|
|
5
|
+
@test "mark-reviewed uses docs/JOBS_TO_BE_DONE.md path" {
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
7
|
+
grep -q 'docs/JOBS_TO_BE_DONE.md' "$SCRIPT_DIR/jtbd-mark-reviewed.sh"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@test "mark-reviewed does NOT use docs/jtbd path" {
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
12
|
+
! grep -q '"docs/jtbd"' "$SCRIPT_DIR/jtbd-mark-reviewed.sh"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@test "enforce-edit and mark-reviewed use same policy path" {
|
|
16
|
+
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
|
|
17
|
+
ENFORCE_PATH=$(grep -o 'docs/JOBS_TO_BE_DONE.md' "$SCRIPT_DIR/jtbd-enforce-edit.sh" | head -1)
|
|
18
|
+
MARK_PATH=$(grep -o 'docs/JOBS_TO_BE_DONE.md' "$SCRIPT_DIR/jtbd-mark-reviewed.sh" | head -1)
|
|
19
|
+
[ "$ENFORCE_PATH" = "$MARK_PATH" ]
|
|
20
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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) {
|
|
66
|
+
return run(`claude plugin update ${pluginName}`, pluginName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function uninstallPlugin(pluginName) {
|
|
70
|
+
return run(`claude plugin uninstall ${pluginName}`, `Removing ${pluginName}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Install a single package: marketplace add + plugin install.
|
|
75
|
+
*/
|
|
76
|
+
export function installPackage(pluginName, { deps = [], scope = "project" } = {}) {
|
|
77
|
+
console.log(`\nInstalling @windyroad/${pluginName.replace("wr-", "")} (${scope} scope)...\n`);
|
|
78
|
+
|
|
79
|
+
addMarketplace();
|
|
80
|
+
installPlugin(pluginName, { scope });
|
|
81
|
+
|
|
82
|
+
if (deps.length > 0) {
|
|
83
|
+
console.log(`\nNote: This plugin works best with:`);
|
|
84
|
+
for (const dep of deps) {
|
|
85
|
+
console.log(` - @windyroad/${dep.replace("wr-", "")} (npx @windyroad/${dep.replace("wr-", "")})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
`\nDone! Restart Claude Code to activate.\n`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update a single package.
|
|
96
|
+
*/
|
|
97
|
+
export function updatePackage(pluginName) {
|
|
98
|
+
console.log(`\nUpdating @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
99
|
+
|
|
100
|
+
run(
|
|
101
|
+
`claude plugin marketplace update ${MARKETPLACE_NAME}`,
|
|
102
|
+
"Updating marketplace"
|
|
103
|
+
);
|
|
104
|
+
updatePlugin(pluginName);
|
|
105
|
+
|
|
106
|
+
console.log("\nDone! Restart Claude Code to apply updates.\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Uninstall a single package.
|
|
111
|
+
*/
|
|
112
|
+
export function uninstallPackage(pluginName) {
|
|
113
|
+
console.log(`\nUninstalling @windyroad/${pluginName.replace("wr-", "")}...\n`);
|
|
114
|
+
|
|
115
|
+
uninstallPlugin(pluginName);
|
|
116
|
+
|
|
117
|
+
console.log("\nDone. Restart Claude Code to apply changes.\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse standard flags used by all per-plugin installers.
|
|
122
|
+
*/
|
|
123
|
+
export function parseStandardArgs(argv) {
|
|
124
|
+
const args = argv.slice(2);
|
|
125
|
+
const flags = {
|
|
126
|
+
help: args.includes("--help") || args.includes("-h"),
|
|
127
|
+
uninstall: args.includes("--uninstall"),
|
|
128
|
+
update: args.includes("--update"),
|
|
129
|
+
dryRun: args.includes("--dry-run"),
|
|
130
|
+
scope: "project",
|
|
131
|
+
};
|
|
132
|
+
const scopeIdx = args.indexOf("--scope");
|
|
133
|
+
if (scopeIdx !== -1 && args[scopeIdx + 1]) {
|
|
134
|
+
const val = args[scopeIdx + 1];
|
|
135
|
+
if (["project", "user", "local"].includes(val)) {
|
|
136
|
+
flags.scope = val;
|
|
137
|
+
} else {
|
|
138
|
+
console.error("--scope requires: project, user, or local");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return flags;
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windyroad/jtbd",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-preview.34",
|
|
4
4
|
"description": "Jobs-to-be-done enforcement for UI changes",
|
|
5
5
|
"bin": {
|
|
6
6
|
"windyroad-jtbd": "./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-jtbd:update-guide
|
|
3
3
|
description: Create or update the project's docs/JOBS_TO_BE_DONE.md by examining existing features and asking the user about user jobs, personas, and desired outcomes.
|
|
4
4
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
|
|
5
5
|
---
|