@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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/PRIVACY.md +147 -0
  3. package/README.md +189 -0
  4. package/catalog/VERSION +1 -0
  5. package/catalog/agents/README.md +29 -0
  6. package/catalog/agents/architect.md +81 -0
  7. package/catalog/agents/fit-assessment.md +94 -0
  8. package/catalog/agents/implementer.md +67 -0
  9. package/catalog/agents/orchestrator.md +627 -0
  10. package/catalog/agents/reviewer.md +124 -0
  11. package/catalog/agents/spec-author.md +69 -0
  12. package/catalog/agents/ui-designer.md +25 -0
  13. package/catalog/commands/add-capability.md +69 -0
  14. package/catalog/commands/bypass.md +40 -0
  15. package/catalog/commands/define.md +24 -0
  16. package/catalog/commands/hotfix.md +47 -0
  17. package/catalog/commands/pause.md +52 -0
  18. package/catalog/commands/resume.md +56 -0
  19. package/catalog/commands/spinoff.md +59 -0
  20. package/catalog/commands/triage.md +24 -0
  21. package/catalog/harness.config.schema.json +116 -0
  22. package/catalog/hooks/README.md +56 -0
  23. package/catalog/hooks/init.sh +281 -0
  24. package/catalog/hooks/lib/lemony.sh +41 -0
  25. package/catalog/hooks/lib/playbook-scan.sh +394 -0
  26. package/catalog/hooks/lib/transcript-grep.sh +56 -0
  27. package/catalog/hooks/require-playbook.sh +97 -0
  28. package/catalog/hooks/session-close.sh +232 -0
  29. package/catalog/hooks/suggest-playbook.sh +72 -0
  30. package/catalog/playbook-format.md +198 -0
  31. package/catalog/schemas/README.md +13 -0
  32. package/catalog/schemas/tier2-events-history.md +104 -0
  33. package/catalog/schemas/tier2-events.md +286 -0
  34. package/catalog/skills/README.md +62 -0
  35. package/catalog/skills/bootstrap-architecture/SKILL.md +78 -0
  36. package/catalog/skills/code-explorer/SKILL.md +76 -0
  37. package/catalog/skills/grill-with-docs/ADR-FORMAT.md +49 -0
  38. package/catalog/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
  39. package/catalog/skills/grill-with-docs/SKILL.md +270 -0
  40. package/catalog/skills/grill-with-docs/reference.md +236 -0
  41. package/catalog/skills/mutation-testing/SKILL.md +84 -0
  42. package/catalog/skills/note-side-finding/SKILL.md +89 -0
  43. package/catalog/skills/playbook-iterate/SKILL.md +78 -0
  44. package/catalog/skills/prd-to-spec/SKILL.md +181 -0
  45. package/catalog/skills/raise-discovery/SKILL.md +112 -0
  46. package/catalog/skills/resolve-discovery/SKILL.md +123 -0
  47. package/catalog/skills/review-pr/SKILL.md +106 -0
  48. package/catalog/skills/review-pr/reference.md +105 -0
  49. package/catalog/skills/security-review/SKILL.md +90 -0
  50. package/catalog/skills/senior-review/SKILL.md +99 -0
  51. package/catalog/skills/silent-failure-hunter/SKILL.md +76 -0
  52. package/catalog/skills/spec-compliance-check/SKILL.md +74 -0
  53. package/catalog/skills/spec-to-issue/SKILL.md +88 -0
  54. package/catalog/skills/task-closeout/SKILL.md +229 -0
  55. package/catalog/skills/tdd/SKILL.md +171 -0
  56. package/catalog/skills/test-gap-report/SKILL.md +71 -0
  57. package/catalog/skills/triage-issue/SKILL.md +102 -0
  58. package/catalog/skills/update-architecture/SKILL.md +69 -0
  59. package/catalog/skills/verify/SKILL.md +90 -0
  60. package/catalog/skills/write-adr/SKILL.md +77 -0
  61. package/catalog/templates/README.md +32 -0
  62. package/catalog/templates/claude-code/.claude/settings.json.tpl +34 -0
  63. package/catalog/templates/claude-code/agents.md.tpl +109 -0
  64. package/catalog/templates/claude-code/docs/playbooks/README.md.tpl +96 -0
  65. package/catalog/templates/claude-code/harness.config.yml.tpl +59 -0
  66. package/catalog/templates/claude-code/state/history.md.tpl +6 -0
  67. package/dist/cli.mjs +5691 -0
  68. 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