@lemoncode/lemony 0.1.0
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/LICENSE +21 -0
- package/PRIVACY.md +147 -0
- package/README.md +189 -0
- package/catalog/VERSION +1 -0
- package/catalog/agents/README.md +29 -0
- package/catalog/agents/architect.md +81 -0
- package/catalog/agents/fit-assessment.md +94 -0
- package/catalog/agents/implementer.md +67 -0
- package/catalog/agents/orchestrator.md +627 -0
- package/catalog/agents/reviewer.md +124 -0
- package/catalog/agents/spec-author.md +69 -0
- package/catalog/agents/ui-designer.md +25 -0
- package/catalog/commands/add-capability.md +69 -0
- package/catalog/commands/bypass.md +40 -0
- package/catalog/commands/define.md +24 -0
- package/catalog/commands/hotfix.md +47 -0
- package/catalog/commands/pause.md +52 -0
- package/catalog/commands/resume.md +56 -0
- package/catalog/commands/spinoff.md +59 -0
- package/catalog/commands/triage.md +24 -0
- package/catalog/harness.config.schema.json +116 -0
- package/catalog/hooks/README.md +56 -0
- package/catalog/hooks/init.sh +281 -0
- package/catalog/hooks/lib/lemony.sh +41 -0
- package/catalog/hooks/lib/playbook-scan.sh +394 -0
- package/catalog/hooks/lib/transcript-grep.sh +56 -0
- package/catalog/hooks/require-playbook.sh +97 -0
- package/catalog/hooks/session-close.sh +232 -0
- package/catalog/hooks/suggest-playbook.sh +72 -0
- package/catalog/playbook-format.md +198 -0
- package/catalog/schemas/README.md +13 -0
- package/catalog/schemas/tier2-events-history.md +104 -0
- package/catalog/schemas/tier2-events.md +286 -0
- package/catalog/skills/README.md +62 -0
- package/catalog/skills/bootstrap-architecture/SKILL.md +78 -0
- package/catalog/skills/code-explorer/SKILL.md +76 -0
- package/catalog/skills/grill-with-docs/ADR-FORMAT.md +49 -0
- package/catalog/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
- package/catalog/skills/grill-with-docs/SKILL.md +270 -0
- package/catalog/skills/grill-with-docs/reference.md +236 -0
- package/catalog/skills/mutation-testing/SKILL.md +84 -0
- package/catalog/skills/note-side-finding/SKILL.md +89 -0
- package/catalog/skills/playbook-iterate/SKILL.md +78 -0
- package/catalog/skills/prd-to-spec/SKILL.md +181 -0
- package/catalog/skills/raise-discovery/SKILL.md +112 -0
- package/catalog/skills/resolve-discovery/SKILL.md +123 -0
- package/catalog/skills/review-pr/SKILL.md +106 -0
- package/catalog/skills/review-pr/reference.md +105 -0
- package/catalog/skills/security-review/SKILL.md +90 -0
- package/catalog/skills/senior-review/SKILL.md +99 -0
- package/catalog/skills/silent-failure-hunter/SKILL.md +76 -0
- package/catalog/skills/spec-compliance-check/SKILL.md +74 -0
- package/catalog/skills/spec-to-issue/SKILL.md +88 -0
- package/catalog/skills/task-closeout/SKILL.md +229 -0
- package/catalog/skills/tdd/SKILL.md +171 -0
- package/catalog/skills/test-gap-report/SKILL.md +71 -0
- package/catalog/skills/triage-issue/SKILL.md +102 -0
- package/catalog/skills/update-architecture/SKILL.md +69 -0
- package/catalog/skills/verify/SKILL.md +90 -0
- package/catalog/skills/write-adr/SKILL.md +77 -0
- package/catalog/templates/README.md +32 -0
- package/catalog/templates/claude-code/.claude/settings.json.tpl +34 -0
- package/catalog/templates/claude-code/agents.md.tpl +109 -0
- package/catalog/templates/claude-code/docs/playbooks/README.md.tpl +96 -0
- package/catalog/templates/claude-code/harness.config.yml.tpl +59 -0
- package/catalog/templates/claude-code/state/history.md.tpl +6 -0
- package/dist/cli.mjs +5691 -0
- package/package.json +80 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Discover playbooks from two layers and expose lookup helpers used by the
|
|
3
|
+
# require-playbook and suggest-playbook hooks.
|
|
4
|
+
#
|
|
5
|
+
# Layers (decision #11):
|
|
6
|
+
# - Local: <repo_root>/docs/playbooks/<topic>.md (committed)
|
|
7
|
+
# - Global: <home>/.claude/playbooks/<topic>.md (per-developer)
|
|
8
|
+
# A local file shadows the global one for the same topic (filename stem).
|
|
9
|
+
#
|
|
10
|
+
# Frontmatter (see catalog/playbook-format.md):
|
|
11
|
+
# applies_to: list of bash globs → file paths that REQUIRE this playbook
|
|
12
|
+
# keywords: list of regex strings → prompts that SUGGEST this playbook
|
|
13
|
+
# Both lists are optional and independent; missing/empty lists are no-ops.
|
|
14
|
+
#
|
|
15
|
+
# Frontmatter is read with a SINGLE awk pass over every discovered playbook
|
|
16
|
+
# (`_scan_frontmatter_tsv`), not one `yq` fork per playbook (decision #29 — the
|
|
17
|
+
# per-file fork dominated hook latency, blowing the <50ms p99 target past ~5
|
|
18
|
+
# playbooks). awk is also the only YAML reader now: the keyword format is
|
|
19
|
+
# constrained to the awk-parseable subset (decision #35 — no `\b`/`\d` escape
|
|
20
|
+
# sequences), so `yq`'s extra fidelity was unused while its ~10-30ms cold fork
|
|
21
|
+
# was pure cost. jq remains the keyword regex engine (Oniguruma, case-insensitive),
|
|
22
|
+
# batched into one invocation across all keywords. Without jq, suggestion lookups
|
|
23
|
+
# return zero matches (fail open) — init.sh prints a doctor warning when it is missing.
|
|
24
|
+
#
|
|
25
|
+
# Bash 3.2 compatibility (intentional design — do not revert): macOS still
|
|
26
|
+
# ships `/bin/bash` 3.2 (no associative arrays, no `globstar` shopt, no `**`
|
|
27
|
+
# in case patterns). The helpers below avoid all three by (a) tracking
|
|
28
|
+
# shadowed topics in a `|topic|topic|` string and (b) converting each
|
|
29
|
+
# applies_to glob into a POSIX-ERE regex matched with `[[ =~ ]]`, which works
|
|
30
|
+
# identically on 3.2 and 4+. Switching to `shopt -s globstar` would silently
|
|
31
|
+
# regress on stock macOS — keep the regex path even when bash 4+ is present.
|
|
32
|
+
|
|
33
|
+
# Resolve the two playbook lookup dirs into PLAYBOOKS_LOCAL_DIR / PLAYBOOKS_GLOBAL_DIR
|
|
34
|
+
# from `harness.config.yml`'s `paths` block (decision #28 — config-driven):
|
|
35
|
+
# paths.playbooks → local layer, repo-relative (default docs/playbooks)
|
|
36
|
+
# paths.playbooks_global → global layer, ~ expanded (default ~/.claude/playbooks)
|
|
37
|
+
#
|
|
38
|
+
# Read directly with ONE awk pass — ~2ms, far under a node CLI shell-out (~40ms,
|
|
39
|
+
# which would breach the per-fire budget) and with no materialized state file to
|
|
40
|
+
# go stale before an `update` command exists (P7). Falls back to the baked
|
|
41
|
+
# defaults when the config, its `paths` block, or a key is absent or commented
|
|
42
|
+
# out (fresh clone, the vendor repo dogfooding itself, an un-customized install).
|
|
43
|
+
_resolve_playbook_dirs() {
|
|
44
|
+
local repo_root="$1"
|
|
45
|
+
local home_dir="$2"
|
|
46
|
+
PLAYBOOKS_LOCAL_DIR="$repo_root/docs/playbooks"
|
|
47
|
+
PLAYBOOKS_GLOBAL_DIR="$home_dir/.claude/playbooks"
|
|
48
|
+
|
|
49
|
+
local config="$repo_root/harness.config.yml"
|
|
50
|
+
[ -r "$config" ] || return 0
|
|
51
|
+
|
|
52
|
+
# One awk pass: within the top-level `paths:` block, emit `<key>\t<value>` for
|
|
53
|
+
# the (uncommented) `playbooks` / `playbooks_global` entries — inline comments
|
|
54
|
+
# and surrounding quotes stripped. Any column-0 line ends the block.
|
|
55
|
+
local key value local_rel="" global_raw=""
|
|
56
|
+
while IFS=$'\t' read -r key value; do
|
|
57
|
+
case "$key" in
|
|
58
|
+
playbooks) local_rel="$value" ;;
|
|
59
|
+
playbooks_global) global_raw="$value" ;;
|
|
60
|
+
esac
|
|
61
|
+
done < <(awk -v sq="'" '
|
|
62
|
+
function unquote(v, q) {
|
|
63
|
+
q = substr(v, 1, 1)
|
|
64
|
+
if (length(v) >= 2 && (q == "\"" || q == sq) && substr(v, length(v), 1) == q) {
|
|
65
|
+
return substr(v, 2, length(v) - 2)
|
|
66
|
+
}
|
|
67
|
+
return v
|
|
68
|
+
}
|
|
69
|
+
/^[^[:space:]]/ { in_paths = ($0 ~ /^paths:[[:space:]]*$/) ? 1 : 0; next }
|
|
70
|
+
in_paths != 1 { next }
|
|
71
|
+
{
|
|
72
|
+
line = $0
|
|
73
|
+
sub(/#.*$/, "", line)
|
|
74
|
+
if (match(line, /^[[:space:]]+(playbooks|playbooks_global)[[:space:]]*:/)) {
|
|
75
|
+
key = line; sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]*:.*$/, "", key)
|
|
76
|
+
val = line; sub(/^[[:space:]]+[A-Za-z_]+[[:space:]]*:[[:space:]]*/, "", val)
|
|
77
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
|
|
78
|
+
val = unquote(val)
|
|
79
|
+
if (val != "") print key "\t" val
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
' "$config" 2>/dev/null)
|
|
83
|
+
|
|
84
|
+
[ -n "$local_rel" ] && PLAYBOOKS_LOCAL_DIR="$repo_root/$local_rel"
|
|
85
|
+
if [ -n "$global_raw" ]; then
|
|
86
|
+
# Expand a leading `~`/`~/`; honor an absolute path; treat anything else as
|
|
87
|
+
# repo-relative (matches the local layer's contract).
|
|
88
|
+
case "$global_raw" in
|
|
89
|
+
"~") PLAYBOOKS_GLOBAL_DIR="$home_dir" ;;
|
|
90
|
+
"~/"*) PLAYBOOKS_GLOBAL_DIR="$home_dir/${global_raw#\~/}" ;;
|
|
91
|
+
/*) PLAYBOOKS_GLOBAL_DIR="$global_raw" ;;
|
|
92
|
+
*) PLAYBOOKS_GLOBAL_DIR="$repo_root/$global_raw" ;;
|
|
93
|
+
esac
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Populate ALL_PLAYBOOKS as an array of absolute playbook paths (one per topic,
|
|
98
|
+
# local shadowing global). `README.md` is filtered out — `docs/playbooks/README.md`
|
|
99
|
+
# is the convention document, not a playbook.
|
|
100
|
+
discover_playbooks() {
|
|
101
|
+
ALL_PLAYBOOKS=()
|
|
102
|
+
local repo_root="$1"
|
|
103
|
+
local home_dir="$2"
|
|
104
|
+
_resolve_playbook_dirs "$repo_root" "$home_dir"
|
|
105
|
+
local local_dir="$PLAYBOOKS_LOCAL_DIR"
|
|
106
|
+
local global_dir="$PLAYBOOKS_GLOBAL_DIR"
|
|
107
|
+
local seen_topics="|"
|
|
108
|
+
|
|
109
|
+
shopt -s nullglob
|
|
110
|
+
|
|
111
|
+
local f base topic
|
|
112
|
+
if [ -d "$local_dir" ]; then
|
|
113
|
+
for f in "$local_dir"/*.md; do
|
|
114
|
+
[ -f "$f" ] || continue
|
|
115
|
+
base="${f##*/}"
|
|
116
|
+
topic="${base%.md}"
|
|
117
|
+
[ "$topic" = "README" ] && continue
|
|
118
|
+
seen_topics="${seen_topics}${topic}|"
|
|
119
|
+
ALL_PLAYBOOKS+=("$f")
|
|
120
|
+
done
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
if [ -d "$global_dir" ]; then
|
|
124
|
+
for f in "$global_dir"/*.md; do
|
|
125
|
+
[ -f "$f" ] || continue
|
|
126
|
+
base="${f##*/}"
|
|
127
|
+
topic="${base%.md}"
|
|
128
|
+
[ "$topic" = "README" ] && continue
|
|
129
|
+
case "$seen_topics" in
|
|
130
|
+
*"|${topic}|"*) continue ;;
|
|
131
|
+
esac
|
|
132
|
+
ALL_PLAYBOOKS+=("$f")
|
|
133
|
+
done
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Emit a TSV stream of `applies_to` and `keywords` list items for EVERY given
|
|
138
|
+
# playbook path in a single awk pass: one line per item, `<file>\t<field>\t<value>`,
|
|
139
|
+
# with surrounding single or double quotes stripped. This is the batched replacement for
|
|
140
|
+
# the old per-file/per-field reader: at K playbooks the hooks went from K (or
|
|
141
|
+
# 2K) subprocess forks to exactly one (decision #29).
|
|
142
|
+
#
|
|
143
|
+
# awk reads the leading `---` frontmatter block of each file (resetting on
|
|
144
|
+
# `FNR==1`, stopping at the second `---`). Supports the documented subset —
|
|
145
|
+
# block lists (`field:\n - item`) and inline lists (`field: [a, b]`) of scalar
|
|
146
|
+
# strings. It does NOT process YAML escape sequences inside double-quoted
|
|
147
|
+
# strings; keywords are constrained to that subset by decision #35 (no `\b`/`\d`
|
|
148
|
+
# word boundaries). A file with no frontmatter contributes nothing.
|
|
149
|
+
#
|
|
150
|
+
# `FILENAME` is the path exactly as passed (ALL_PLAYBOOKS holds absolute paths),
|
|
151
|
+
# so callers can key matches straight back to the playbook file. `nextfile` is
|
|
152
|
+
# avoided — it is a GNU awk extension absent from the BSD awk macOS ships.
|
|
153
|
+
_scan_frontmatter_tsv() {
|
|
154
|
+
[ "$#" -gt 0 ] || return 0
|
|
155
|
+
# `sq` carries a literal single quote into the awk program without breaking the
|
|
156
|
+
# surrounding bash single-quotes — used by `unquote` to strip either quote style
|
|
157
|
+
# (YAML allows both; the reader honors single AND double).
|
|
158
|
+
awk -v sq="'" '
|
|
159
|
+
function unquote(v, q) {
|
|
160
|
+
q = substr(v, 1, 1)
|
|
161
|
+
if (length(v) >= 2 && (q == "\"" || q == sq) && substr(v, length(v), 1) == q) {
|
|
162
|
+
return substr(v, 2, length(v) - 2)
|
|
163
|
+
}
|
|
164
|
+
return v
|
|
165
|
+
}
|
|
166
|
+
FNR == 1 { block = 0; done = 0; in_field = "" }
|
|
167
|
+
done { next }
|
|
168
|
+
/^---[[:space:]]*$/ {
|
|
169
|
+
block++
|
|
170
|
+
if (block >= 2) { done = 1 }
|
|
171
|
+
next
|
|
172
|
+
}
|
|
173
|
+
block != 1 { next }
|
|
174
|
+
{
|
|
175
|
+
# Inline list: `applies_to: [a, b]` / `keywords: [a, b]`
|
|
176
|
+
if (match($0, /^(applies_to|keywords)[[:space:]]*:[[:space:]]*\[/)) {
|
|
177
|
+
fname = $0; sub(/[[:space:]]*:.*$/, "", fname)
|
|
178
|
+
s = $0; sub(/^[^[]*\[/, "", s); sub(/\][[:space:]]*$/, "", s)
|
|
179
|
+
n = split(s, arr, ",")
|
|
180
|
+
for (i = 1; i <= n; i++) {
|
|
181
|
+
v = arr[i]
|
|
182
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
|
|
183
|
+
v = unquote(v)
|
|
184
|
+
if (v != "") print FILENAME "\t" fname "\t" v
|
|
185
|
+
}
|
|
186
|
+
in_field = ""
|
|
187
|
+
next
|
|
188
|
+
}
|
|
189
|
+
# Block list header: `applies_to:` / `keywords:` (no value on the line)
|
|
190
|
+
if (match($0, /^(applies_to|keywords)[[:space:]]*:[[:space:]]*$/)) {
|
|
191
|
+
in_field = $0; sub(/[[:space:]]*:.*$/, "", in_field)
|
|
192
|
+
next
|
|
193
|
+
}
|
|
194
|
+
# A different top-level key ends the active block field.
|
|
195
|
+
if (match($0, /^[[:alpha:]_][[:alnum:]_-]*[[:space:]]*:/)) { in_field = ""; next }
|
|
196
|
+
# List item under the active field.
|
|
197
|
+
if (in_field != "" && match($0, /^[[:space:]]*-[[:space:]]+/)) {
|
|
198
|
+
v = substr($0, RSTART + RLENGTH)
|
|
199
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
|
|
200
|
+
v = unquote(v)
|
|
201
|
+
if (v != "") print FILENAME "\t" in_field "\t" v
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
' "$@" 2>/dev/null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Convert a bash glob into an anchored POSIX-ERE regex. Supported subset (kept
|
|
208
|
+
# in sync with catalog/playbook-format.md):
|
|
209
|
+
# `**/` — zero or more path components (so `**/x` matches `x` at root
|
|
210
|
+
# too; this is what users intuitively expect even though bash
|
|
211
|
+
# globstar would require at least one component)
|
|
212
|
+
# `**` — any sequence including `/`
|
|
213
|
+
# `*` — any sequence within a single path component (`[^/]*`)
|
|
214
|
+
# `?` — any single non-separator character
|
|
215
|
+
# `[abc]` — character class, passthrough
|
|
216
|
+
# `[!abc]` — negated class, translated to `[^abc]` (bash syntax → ERE)
|
|
217
|
+
# `{a,b,c}` — alternation, becomes `(a|b|c)`. Empty alternatives
|
|
218
|
+
# (`{,X}`, `{X,}`, `{,}`) emit a regex BSD ERE rejects, so
|
|
219
|
+
# the pattern silently never matches — pick a different
|
|
220
|
+
# glob (e.g. `Dockerfile*` instead of `Dockerfile{,.}*`).
|
|
221
|
+
# `\X` — literal X (escape)
|
|
222
|
+
# Regex metacharacters that aren't glob operators (`.`, `(`, `)`, `|`, `+`,
|
|
223
|
+
# `^`, `$`) are escaped so the user's pattern is interpreted as a glob, not a
|
|
224
|
+
# regex. Nested or unbalanced braces produce an undefined pattern that
|
|
225
|
+
# `[[ =~ ]]` rejects silently (the playbook is treated as non-matching) —
|
|
226
|
+
# acceptable per fail-open. For richer matching, extend the translator.
|
|
227
|
+
#
|
|
228
|
+
# Result is returned in the global `GLOB_RE`, NOT printed: callers run this once
|
|
229
|
+
# per applies_to glob (5×K times at K playbooks), and a `regex="$(glob_to_regex)"`
|
|
230
|
+
# command substitution would fork a subshell each time — the dominant cost of
|
|
231
|
+
# the require hook at scale (decision #29). Assigning a global keeps it fork-free.
|
|
232
|
+
glob_to_regex() {
|
|
233
|
+
local glob="$1"
|
|
234
|
+
local re=""
|
|
235
|
+
local len=${#glob}
|
|
236
|
+
local i=0
|
|
237
|
+
local in_class=0
|
|
238
|
+
local in_brace=0
|
|
239
|
+
local ch next nextnext
|
|
240
|
+
while [ $i -lt $len ]; do
|
|
241
|
+
ch="${glob:$i:1}"
|
|
242
|
+
next="${glob:$((i + 1)):1}"
|
|
243
|
+
nextnext="${glob:$((i + 2)):1}"
|
|
244
|
+
if [ $in_class -eq 1 ]; then
|
|
245
|
+
re+="$ch"
|
|
246
|
+
[ "$ch" = "]" ] && in_class=0
|
|
247
|
+
i=$((i + 1))
|
|
248
|
+
continue
|
|
249
|
+
fi
|
|
250
|
+
case "$ch" in
|
|
251
|
+
'*')
|
|
252
|
+
if [ "$next" = "*" ]; then
|
|
253
|
+
# `**/` should match zero or more leading path components so
|
|
254
|
+
# `**/Dockerfile` matches a top-level `Dockerfile` too. Consuming
|
|
255
|
+
# the trailing slash here emits `(.*/)?`; a bare `**` (no slash)
|
|
256
|
+
# falls back to `.*`.
|
|
257
|
+
if [ "$nextnext" = "/" ]; then
|
|
258
|
+
re+="(.*/)?"
|
|
259
|
+
i=$((i + 3))
|
|
260
|
+
else
|
|
261
|
+
re+=".*"
|
|
262
|
+
i=$((i + 2))
|
|
263
|
+
fi
|
|
264
|
+
else
|
|
265
|
+
re+="[^/]*"
|
|
266
|
+
i=$((i + 1))
|
|
267
|
+
fi
|
|
268
|
+
;;
|
|
269
|
+
'?')
|
|
270
|
+
re+="[^/]"
|
|
271
|
+
i=$((i + 1))
|
|
272
|
+
;;
|
|
273
|
+
'.')
|
|
274
|
+
re+="\\."
|
|
275
|
+
i=$((i + 1))
|
|
276
|
+
;;
|
|
277
|
+
'[')
|
|
278
|
+
in_class=1
|
|
279
|
+
# Bash glob negation `[!…]` → ERE `[^…]`. Consume the `!` as the
|
|
280
|
+
# leading char of the class so the rest passes through verbatim.
|
|
281
|
+
if [ "$next" = "!" ]; then
|
|
282
|
+
re+="[^"
|
|
283
|
+
i=$((i + 2))
|
|
284
|
+
else
|
|
285
|
+
re+="["
|
|
286
|
+
i=$((i + 1))
|
|
287
|
+
fi
|
|
288
|
+
;;
|
|
289
|
+
'{')
|
|
290
|
+
in_brace=1
|
|
291
|
+
re+="("
|
|
292
|
+
i=$((i + 1))
|
|
293
|
+
;;
|
|
294
|
+
'}')
|
|
295
|
+
if [ $in_brace -eq 1 ]; then
|
|
296
|
+
re+=")"
|
|
297
|
+
in_brace=0
|
|
298
|
+
else
|
|
299
|
+
re+="\\}"
|
|
300
|
+
fi
|
|
301
|
+
i=$((i + 1))
|
|
302
|
+
;;
|
|
303
|
+
',')
|
|
304
|
+
if [ $in_brace -eq 1 ]; then
|
|
305
|
+
re+="|"
|
|
306
|
+
else
|
|
307
|
+
re+=","
|
|
308
|
+
fi
|
|
309
|
+
i=$((i + 1))
|
|
310
|
+
;;
|
|
311
|
+
'\')
|
|
312
|
+
re+="\\$next"
|
|
313
|
+
i=$((i + 2))
|
|
314
|
+
;;
|
|
315
|
+
'('|')'|'|'|'+'|'^'|'$')
|
|
316
|
+
re+="\\$ch"
|
|
317
|
+
i=$((i + 1))
|
|
318
|
+
;;
|
|
319
|
+
*)
|
|
320
|
+
re+="$ch"
|
|
321
|
+
i=$((i + 1))
|
|
322
|
+
;;
|
|
323
|
+
esac
|
|
324
|
+
done
|
|
325
|
+
GLOB_RE="^${re}$"
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Populate MATCHED_PLAYBOOKS with playbooks whose `applies_to` list contains a
|
|
329
|
+
# glob that matches the given file path. One awk fork reads every playbook's
|
|
330
|
+
# frontmatter; glob→regex translation and matching are in-process (no fork).
|
|
331
|
+
playbook_scan_for_path() {
|
|
332
|
+
local repo_root="$1"
|
|
333
|
+
local home_dir="$2"
|
|
334
|
+
local filepath="$3"
|
|
335
|
+
MATCHED_PLAYBOOKS=()
|
|
336
|
+
|
|
337
|
+
discover_playbooks "$repo_root" "$home_dir"
|
|
338
|
+
# Guard the iteration: macOS bash 3.2 errors on `"${arr[@]}"` when arr is
|
|
339
|
+
# empty under `set -u`, even though arr was explicitly initialized to `()`.
|
|
340
|
+
[ ${#ALL_PLAYBOOKS[@]} -gt 0 ] || return 0
|
|
341
|
+
|
|
342
|
+
local file field value seen="|"
|
|
343
|
+
GLOB_RE=""
|
|
344
|
+
while IFS=$'\t' read -r file field value; do
|
|
345
|
+
[ "$field" = "applies_to" ] || continue
|
|
346
|
+
[ -n "$value" ] || continue
|
|
347
|
+
# A playbook with two matching globs must appear once (the old per-file
|
|
348
|
+
# loop `break`ed on first match) — track which files already matched.
|
|
349
|
+
case "$seen" in *"|${file}|"*) continue ;; esac
|
|
350
|
+
glob_to_regex "$value"
|
|
351
|
+
if [[ "$filepath" =~ $GLOB_RE ]]; then
|
|
352
|
+
MATCHED_PLAYBOOKS+=("$file")
|
|
353
|
+
seen="${seen}${file}|"
|
|
354
|
+
fi
|
|
355
|
+
done < <(_scan_frontmatter_tsv "${ALL_PLAYBOOKS[@]}")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Populate MATCHED_KEYWORD_PLAYBOOKS with playbooks whose `keywords` list
|
|
359
|
+
# contains a regex matching the prompt. A single jq invocation tests the prompt
|
|
360
|
+
# (passed as a JSON-safe --arg, newlines intact) against every keyword regex
|
|
361
|
+
# with jq's Oniguruma engine (case-insensitive), avoiding macOS BSD grep regex
|
|
362
|
+
# limits and the old one-fork-per-keyword cost (decision #29). Distinct matching
|
|
363
|
+
# files are emitted in first-seen (discovery) order.
|
|
364
|
+
playbook_scan_for_prompt() {
|
|
365
|
+
local repo_root="$1"
|
|
366
|
+
local home_dir="$2"
|
|
367
|
+
local prompt="$3"
|
|
368
|
+
MATCHED_KEYWORD_PLAYBOOKS=()
|
|
369
|
+
|
|
370
|
+
discover_playbooks "$repo_root" "$home_dir"
|
|
371
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
372
|
+
[ ${#ALL_PLAYBOOKS[@]} -gt 0 ] || return 0
|
|
373
|
+
|
|
374
|
+
local file
|
|
375
|
+
while IFS= read -r file; do
|
|
376
|
+
[ -n "$file" ] || continue
|
|
377
|
+
MATCHED_KEYWORD_PLAYBOOKS+=("$file")
|
|
378
|
+
done < <(
|
|
379
|
+
_scan_frontmatter_tsv "${ALL_PLAYBOOKS[@]}" \
|
|
380
|
+
| jq -Rn --arg prompt "$prompt" -r '
|
|
381
|
+
reduce ( inputs
|
|
382
|
+
| split("\t")
|
|
383
|
+
| select(length >= 3 and .[1] == "keywords")
|
|
384
|
+
| .[2] as $re | .[0] as $file
|
|
385
|
+
# try/catch keeps a single malformed regex from aborting the
|
|
386
|
+
# whole batch — it just fails to match, preserving the
|
|
387
|
+
# per-keyword fail-open the one-fork-per-keyword loop had.
|
|
388
|
+
| select(try ($prompt | test($re; "i")) catch false)
|
|
389
|
+
| $file
|
|
390
|
+
) as $hit ([]; if any(.[]; . == $hit) then . else . + [$hit] end)
|
|
391
|
+
| .[]
|
|
392
|
+
' 2>/dev/null
|
|
393
|
+
)
|
|
394
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Inspect the Claude Code conversation transcript (JSONL) for evidence that a
|
|
3
|
+
# specific playbook was read in this conversation via the `Read` tool.
|
|
4
|
+
#
|
|
5
|
+
# A "read" hit is one JSONL line that contains BOTH `"name":"Read"` (the
|
|
6
|
+
# tool_use marker, unique to tool invocations of the Read tool) AND the
|
|
7
|
+
# playbook's absolute path. The same-line requirement avoids false positives
|
|
8
|
+
# from a README that lists every playbook by name, or from user prose mentioning
|
|
9
|
+
# them — the only thing that counts is Claude having actually called Read.
|
|
10
|
+
#
|
|
11
|
+
# Forensic-format dependency: the marker assumes canonical-form JSONL with no
|
|
12
|
+
# spaces around the colon (`"name":"Read"`, not `"name": "Read"`), which is
|
|
13
|
+
# what Claude Code emits today. If a future Claude Code release pretty-prints
|
|
14
|
+
# transcripts, the gates silently fail open ("no Read found" → suggest stays
|
|
15
|
+
# silent; require warns + allows since the transcript becomes effectively
|
|
16
|
+
# unreadable). Swap to a `jq`-driven parse if that day comes.
|
|
17
|
+
#
|
|
18
|
+
# Sub-agent transcripts: a sub-agent's PreToolUse payload carries the PARENT
|
|
19
|
+
# session's transcript_path, but the sub-agent's own Read calls are logged in
|
|
20
|
+
# `<transcript>/subagents/agent-*.jsonl`. Grepping only the parent would
|
|
21
|
+
# wrongly block every sub-agent's gated write (Implementer/Reviewer always run
|
|
22
|
+
# as sub-agents). The candidates list includes those sibling files from day 1.
|
|
23
|
+
|
|
24
|
+
transcript_contains_playbook() {
|
|
25
|
+
local transcript_path="$1"
|
|
26
|
+
local playbook_path="$2"
|
|
27
|
+
|
|
28
|
+
if [ -z "$transcript_path" ] || [ ! -r "$transcript_path" ]; then
|
|
29
|
+
return 1
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
local -a candidates=("$transcript_path")
|
|
33
|
+
local subagents_dir="${transcript_path%.jsonl}/subagents"
|
|
34
|
+
if [ -d "$subagents_dir" ]; then
|
|
35
|
+
local f
|
|
36
|
+
for f in "$subagents_dir"/*.jsonl; do
|
|
37
|
+
[ -r "$f" ] && candidates+=("$f")
|
|
38
|
+
done
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Subshell + `set +o pipefail` is required: callers enable pipefail, and
|
|
42
|
+
# `grep -q` exits early on first match, SIGPIPE'ing the upstream grep. Under
|
|
43
|
+
# pipefail, the SIGPIPE-killed upstream wins and turns a real match into a
|
|
44
|
+
# false negative.
|
|
45
|
+
local t
|
|
46
|
+
for t in "${candidates[@]}"; do
|
|
47
|
+
if (
|
|
48
|
+
set +o pipefail
|
|
49
|
+
grep -F '"name":"Read"' "$t" 2>/dev/null \
|
|
50
|
+
| grep -F -q "$playbook_path"
|
|
51
|
+
); then
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
done
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Lemony — Claude Code PreToolUse hook (playbook enforcement).
|
|
3
|
+
#
|
|
4
|
+
# On every Write/Edit, scan the project's playbooks (local docs/playbooks/ +
|
|
5
|
+
# global ~/.claude/playbooks/, local shadowing global) and find those whose
|
|
6
|
+
# `applies_to` glob matches the target file path. If any matching playbook has
|
|
7
|
+
# NOT been read in this conversation, block the call (exit 2) with a message
|
|
8
|
+
# telling the agent exactly which playbook(s) to read first.
|
|
9
|
+
#
|
|
10
|
+
# Fails open (exit 0) when jq is missing, no playbooks exist, the transcript is
|
|
11
|
+
# unreadable, or the tool is not Write/Edit — the hook is an enforcement layer,
|
|
12
|
+
# not a gate of last resort. The doctor warnings in init.sh cover missing tooling.
|
|
13
|
+
|
|
14
|
+
set -uo pipefail
|
|
15
|
+
|
|
16
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
17
|
+
|
|
18
|
+
# shellcheck source=lib/playbook-scan.sh
|
|
19
|
+
source "$SCRIPT_DIR/lib/playbook-scan.sh"
|
|
20
|
+
# shellcheck source=lib/transcript-grep.sh
|
|
21
|
+
source "$SCRIPT_DIR/lib/transcript-grep.sh"
|
|
22
|
+
|
|
23
|
+
warn() {
|
|
24
|
+
printf '[require-playbook] %s\n' "$*" >&2
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Read stdin only when Claude Code is piping a payload; if a developer runs the
|
|
28
|
+
# hook interactively (no pipe), default to an empty payload so the script
|
|
29
|
+
# doesn't hang on `cat`.
|
|
30
|
+
if [ -t 0 ]; then
|
|
31
|
+
PAYLOAD=""
|
|
32
|
+
else
|
|
33
|
+
PAYLOAD="$(cat 2>/dev/null || true)"
|
|
34
|
+
fi
|
|
35
|
+
[ -n "$PAYLOAD" ] || exit 0
|
|
36
|
+
|
|
37
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
38
|
+
warn "jq not found, skipping check"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
TOOL="$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty')"
|
|
43
|
+
case "$TOOL" in
|
|
44
|
+
Write|Edit) ;;
|
|
45
|
+
*) exit 0 ;;
|
|
46
|
+
esac
|
|
47
|
+
|
|
48
|
+
FILEPATH="$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.file_path // empty')"
|
|
49
|
+
[ -n "$FILEPATH" ] || exit 0
|
|
50
|
+
|
|
51
|
+
REPO_ROOT="$(pwd)"
|
|
52
|
+
HOME_DIR="${HOME:-$REPO_ROOT}"
|
|
53
|
+
|
|
54
|
+
playbook_scan_for_path "$REPO_ROOT" "$HOME_DIR" "$FILEPATH"
|
|
55
|
+
if [ ${#MATCHED_PLAYBOOKS[@]} -eq 0 ]; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
TRANSCRIPT_PATH="$(printf '%s' "$PAYLOAD" | jq -r '.transcript_path // empty')"
|
|
60
|
+
|
|
61
|
+
# Fail open if the transcript is not accessible — better to allow than wrongly
|
|
62
|
+
# block a legitimate workflow.
|
|
63
|
+
if [ -z "$TRANSCRIPT_PATH" ] || [ ! -r "$TRANSCRIPT_PATH" ]; then
|
|
64
|
+
warn "transcript not accessible, allowing $FILEPATH"
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
MISSING=()
|
|
69
|
+
for pb in "${MATCHED_PLAYBOOKS[@]}"; do
|
|
70
|
+
if ! transcript_contains_playbook "$TRANSCRIPT_PATH" "$pb"; then
|
|
71
|
+
MISSING+=("$pb")
|
|
72
|
+
fi
|
|
73
|
+
done
|
|
74
|
+
|
|
75
|
+
if [ ${#MISSING[@]} -eq 0 ]; then
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
printf 'Before writing %s, you MUST first read the relevant playbook(s):\n\n' "$FILEPATH"
|
|
81
|
+
for pb in "${MISSING[@]}"; do
|
|
82
|
+
printf ' - %s\n' "$pb"
|
|
83
|
+
done
|
|
84
|
+
printf '\nCall the Read tool for each missing playbook:\n\n'
|
|
85
|
+
for pb in "${MISSING[@]}"; do
|
|
86
|
+
printf ' Read(file_path="%s")\n' "$pb"
|
|
87
|
+
done
|
|
88
|
+
printf '\nPlaybooks capture this project'\''s canonical patterns for this kind of file.\n'
|
|
89
|
+
printf 'Reading them first avoids re-discovering already-solved problems.\n'
|
|
90
|
+
printf '\nAfter reading, retry this Write/Edit.\n'
|
|
91
|
+
# Name the actual file the agent / human reader can inspect — works both at
|
|
92
|
+
# an installed `.claude/hooks/` location and when the hook runs from the
|
|
93
|
+
# vendor repo (dogfood, CI, manual debug).
|
|
94
|
+
printf '\n(Hook: %s)\n' "${BASH_SOURCE[0]}"
|
|
95
|
+
} >&2
|
|
96
|
+
|
|
97
|
+
exit 2
|