@thedecipherist/claude-packs 1.0.0 → 1.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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +275 -18
  3. package/bin/claude-packs.mjs +18 -10
  4. package/configurations/base/config.json +1 -1
  5. package/configurations/cpp/config.json +2 -1
  6. package/configurations/full-stack/config.json +2 -1
  7. package/configurations/go/config.json +3 -1
  8. package/configurations/node/config.json +3 -1
  9. package/configurations/python/config.json +3 -1
  10. package/configurations/rust/config.json +106 -0
  11. package/configurations/synth-creator/config.json +1 -1
  12. package/global/hooks/lib/shell-writes.mjs +2 -293
  13. package/lib/build-queue.mjs +22 -0
  14. package/lib/claude-md.mjs +4 -2
  15. package/lib/config-cli.mjs +1 -1
  16. package/lib/coverage.mjs +10 -2
  17. package/lib/doctor.mjs +14 -3
  18. package/lib/fix-plan.mjs +2 -1
  19. package/lib/found-folders.mjs +25 -0
  20. package/lib/global.mjs +3 -3
  21. package/lib/hook-proof.mjs +24 -7
  22. package/lib/kit-command.mjs +14 -0
  23. package/lib/layout.mjs +27 -3
  24. package/lib/logs.mjs +45 -5
  25. package/lib/new.mjs +1 -1
  26. package/lib/packs.mjs +4 -2
  27. package/lib/rule-tests.mjs +42 -0
  28. package/lib/scan-detect.mjs +41 -5
  29. package/lib/scan.mjs +5 -2
  30. package/lib/suggest.mjs +16 -3
  31. package/lib/sync.mjs +5 -2
  32. package/lib/workorder.mjs +26 -0
  33. package/package.json +1 -1
  34. package/packs/audio-core/hooks/param-id-lock.mjs +59 -16
  35. package/packs/audio-core/pack.json +5 -0
  36. package/packs/audio-core/rules/a-control-is-proved-through-its-input.md +26 -0
  37. package/packs/audio-core/rules/cpu-worst-case-per-block.md +3 -1
  38. package/packs/audio-core/rules/house-control-behaviours.md +1 -1
  39. package/packs/audio-core/rules/scoped-no-denormals.md +1 -1
  40. package/packs/audio-core/rules/straight-on-and-aligned.md +1 -1
  41. package/packs/audio-core/rules/ui-matches-reference-look.md +2 -0
  42. package/packs/audio-core/skills/analyse-reference-look/SKILL.md +25 -11
  43. package/packs/audio-core/skills/prove-control/SKILL.md +2 -0
  44. package/packs/base/checks/one-walk-per-template.json +1 -1
  45. package/packs/base/hooks/code-shape.mjs +10 -6
  46. package/packs/base/hooks/dispatch-once.mjs +58 -21
  47. package/packs/base/hooks/folder-law.mjs +3 -0
  48. package/packs/base/hooks/git-guard.mjs +22 -109
  49. package/packs/base/hooks/lesson-checks.mjs +9 -1
  50. package/packs/base/hooks/lib/code-shapes.mjs +11 -6
  51. package/packs/base/hooks/lib/crash-policy.mjs +47 -0
  52. package/packs/base/hooks/lib/dup-index.mjs +6 -1
  53. package/packs/base/hooks/lib/dup.mjs +2 -2
  54. package/packs/base/hooks/lib/event-log.mjs +4 -3
  55. package/packs/base/hooks/lib/git-adds.mjs +2 -1
  56. package/packs/base/hooks/lib/git-auth.mjs +44 -0
  57. package/packs/base/hooks/lib/git-guard-selftest.mjs +93 -0
  58. package/packs/base/hooks/lib/languages.mjs +5 -2
  59. package/packs/base/hooks/lib/layout.mjs +6 -2
  60. package/packs/base/hooks/lib/making-check-selftest.mjs +3 -0
  61. package/packs/base/hooks/lib/near-copies.mjs +1 -1
  62. package/packs/base/hooks/lib/program-read.mjs +6 -3
  63. package/packs/base/hooks/lib/rust-scan.mjs +139 -0
  64. package/packs/base/hooks/lib/shape-lists.mjs +31 -0
  65. package/packs/base/hooks/lib/shape-prose.mjs +78 -0
  66. package/packs/base/hooks/lib/shape.mjs +18 -39
  67. package/packs/base/hooks/lib/turn.mjs +6 -1
  68. package/packs/base/hooks/lib/unread-writes.mjs +24 -0
  69. package/packs/base/hooks/making-check.mjs +12 -7
  70. package/packs/base/hooks/run-hooks.mjs +15 -7
  71. package/packs/base/hooks/shape-law.mjs +11 -4
  72. package/packs/base/hooks/suite-once.mjs +22 -6
  73. package/packs/base/rules/a-control-is-seen-to-fire.md +1 -1
  74. package/packs/base/rules/behaviour-lives-with-its-kind.md +1 -1
  75. package/packs/base/rules/bug-fix-starts-red.md +1 -1
  76. package/packs/base/rules/field-map-over-if-runs.md +1 -1
  77. package/packs/base/rules/instances-by-index-identities-by-id.md +2 -2
  78. package/packs/base/rules/lookup-over-if-chains.md +1 -1
  79. package/packs/base/rules/plan-and-logs.md +1 -1
  80. package/packs/base/rules/refusals-are-judged.md +1 -0
  81. package/packs/base/rules/test-the-template-once.md +2 -1
  82. package/packs/base/rules/unattended-runs.md +15 -0
  83. package/packs/base/skills/new-project/SKILL.md +23 -0
  84. package/packs/base/skills/write-a-workorder/SKILL.md +41 -0
  85. package/packs/cpp/checks/cpp-find-with-the-library.json +2 -2
  86. package/packs/go/pack.json +5 -0
  87. package/packs/hook-proof/rules/a-refusal-is-exit-2.md +2 -2
  88. package/packs/instrument-shell/rules/render-the-extremes.md +2 -2
  89. package/packs/midi/checks/position-not-integrated.json +5 -1
  90. package/packs/postgres/checks/pg-bind-values.json +33 -0
  91. package/packs/postgres/checks/pg-concurrently-in-txn.json +18 -0
  92. package/packs/postgres/checks/pg-fk-not-valid.json +18 -0
  93. package/packs/postgres/checks/pg-index-concurrently.json +18 -0
  94. package/packs/postgres/checks/pg-no-float-money.json +19 -0
  95. package/packs/postgres/checks/pg-no-money.json +18 -0
  96. package/packs/postgres/checks/pg-no-required-column.json +18 -0
  97. package/packs/postgres/checks/pg-no-volatile-default.json +18 -0
  98. package/packs/postgres/checks/pg-not-exists.json +18 -0
  99. package/packs/postgres/checks/pg-timestamptz.json +18 -0
  100. package/packs/postgres/hooks/db-wipe-guard.mjs +95 -0
  101. package/packs/postgres/pack.json +48 -0
  102. package/packs/postgres/rules/postgres-migrations-never-stop-traffic.md +28 -0
  103. package/packs/postgres/rules/postgres-one-pool-per-process.md +21 -0
  104. package/packs/postgres/rules/postgres-queries-are-null-safe-and-bounded.md +26 -0
  105. package/packs/postgres/rules/postgres-types-say-what-they-hold.md +22 -0
  106. package/packs/postgres/rules/postgres-values-are-bound.md +130 -0
  107. package/packs/postgres/skills/postgres-safe-migration/SKILL.md +45 -0
  108. package/packs/reverse-engineering/rules/addresses-name-their-space.md +1 -0
  109. package/packs/reverse-engineering/rules/reproduction-or-original.md +1 -1
  110. package/packs/reverse-engineering/skills/gather-sources/SKILL.md +1 -0
  111. package/packs/rust/checks/rust-allow-needs-reason.json +16 -0
  112. package/packs/rust/checks/rust-blocking-in-async.json +32 -0
  113. package/packs/rust/checks/rust-if-chain-is-a-match.json +32 -0
  114. package/packs/rust/checks/rust-static-mut.json +32 -0
  115. package/packs/rust/checks/rust-string-as-error.json +34 -0
  116. package/packs/rust/checks/rust-stub-left-in.json +32 -0
  117. package/packs/rust/checks/rust-unsafe-needs-safety.json +31 -0
  118. package/packs/rust/checks/rust-unwrap-in-library.json +34 -0
  119. package/packs/rust/checks/rust-wildcard-dependency.json +16 -0
  120. package/packs/rust/clippy.toml +5 -0
  121. package/packs/rust/pack.json +27 -0
  122. package/packs/rust/rules/rust-async-never-blocks.md +20 -0
  123. package/packs/rust/rules/rust-borrow-before-you-clone.md +19 -0
  124. package/packs/rust/rules/rust-errors-are-returned.md +22 -0
  125. package/packs/rust/rules/rust-lints-are-the-gate.md +23 -0
  126. package/packs/rust/rules/rust-repeats-become-templates.md +28 -0
  127. package/packs/rust/rules/rust-unsafe-is-proved.md +21 -0
  128. package/packs/rust/skills/rust-extract-template/SKILL.md +50 -0
  129. package/packs/rust/skills/rust-port/SKILL.md +22 -0
  130. package/packs/rust/skills/rust-review/SKILL.md +24 -0
  131. package/packs/rust/tools/rust-lints.toml +19 -0
  132. package/packs/sound-design/skills/bounce-a-plugin/SKILL.md +40 -0
  133. package/packs/sound-design/tools/vst3host/build.sh +73 -0
  134. package/packs/sound-design/tools/vst3host/host_events.h +101 -0
  135. package/packs/sound-design/tools/vst3host/options.h +172 -0
  136. package/packs/sound-design/tools/vst3host/render.cpp +245 -0
  137. package/packs/sound-design/tools/vst3host/session.h +241 -0
  138. package/packs/synth-creator/hooks/build-guard.mjs +7 -7
  139. package/packs/synth-creator/hooks/protect-vendor.mjs +2 -1
  140. package/packs/synth-creator/hooks/stage-guard.mjs +1 -1
  141. package/packs/synth-creator/rules/timing-from-synthcore.md +1 -1
  142. package/packs/synth-creator/skills/build-through-the-queue/SKILL.md +4 -2
  143. package/packs/synth-creator/tools/buildq.mjs +74 -0
  144. package/prompts/workorder/PROMPT.md +53 -0
  145. package/prompts/workorder/run.sh +36 -0
  146. package/prompts/workorder/workorder.md +59 -0
  147. package/global/hooks/lib/program-read.mjs +0 -196
  148. package/global/hooks/lib/program-writes.mjs +0 -177
@@ -104,6 +104,9 @@ export function selftest(hookUrl) {
104
104
  ['a brief in a notes folder naming a concept (KWB-19)', turn([W('notes/brief.md', '# Brief: the mixer, every slot\n')]), 0],
105
105
  ['source using a concept the making line does not name (KWB-19)', turn([W('src/mix/level.cpp', mixerCode)]), 2],
106
106
  ['mixer code with a making line naming the mixer', turn([W('src/mix/level.cpp', mixerCode)], { making: 'making: the mixer level; concepts: mixer (one); reuse: none' }), 0],
107
+ ['what a turn landed is over (KF-34)', turn([W('src/mix/level.cpp', mixerCode), use('Bash', { command: 'git merge --ff-only work/s1' })]), 0],
108
+ ['a write after the landing is judged (KF-34)', turn([use('Bash', { command: 'git merge --ff-only work/s1' }), W('src/mix/level.cpp', mixerCode)]), 2],
109
+ ['the shape word the rule asks for is no concept (KF-24)', turn([W('src/mix/level.cpp', mixerCode)], { making: 'making: x; concepts: slot, many; mixer, one; reuse: none' }), 0],
107
110
  ];
108
111
  return verdicts('making-check', cases);
109
112
  }
@@ -24,7 +24,7 @@ export function fingerprint(text, ext) {
24
24
  export function definesOf(text, ext) {
25
25
  const lang = languageOf(ext);
26
26
  if (!lang?.define) return [];
27
- const code = lang.blank(text);
27
+ const code = (lang.ownDefinitions ?? ((c) => c))(lang.blank(text));
28
28
  return [...code.matchAll(lang.define)].map((m) => [m.slice(1).find(Boolean), code.slice(0, m.index).split('\n').length]).filter(([name]) => name);
29
29
  }
30
30
 
@@ -114,6 +114,9 @@ function chain(expr) {
114
114
 
115
115
  const str = (v) => (v && (v.t === 'str' || v.t === 'path') ? v.v : null);
116
116
  const joinPaths = (parts) => (parts.some((p) => p === null) ? null : parts.reduce((a, b) => (b.startsWith('/') ? b : a ? `${a.replace(/\/$/, '')}/${b}` : b), ''));
117
+ // A path made of a part the reader cannot work out (a helper's own parameter) is unknown, never a path of null (KF-40: read_text on one
118
+ // crashed sixteen hooks).
119
+ const pathOf = (v) => (v === null ? null : { t: 'path', v });
117
120
  const PATH_FNS = /^(?:(?:pathlib\.)?(?:Pure)?(?:Posix)?Path|os\.path\.join|path\.join|path\.resolve|join|resolve|File\.join|os\.path\.expanduser|os\.path\.abspath|path\.normalize)$/;
118
121
  const READS = /^(?:(?:fs|fsp|fs\.promises)\.)?readFileSync$|^File\.read$|^IO\.read$/;
119
122
 
@@ -129,7 +132,7 @@ export function evaluate(expr, ctx) {
129
132
  const slash = splitTop(e, '/');
130
133
  if (slash.length > 1) {
131
134
  const vs = slash.map((p) => evaluate(p, ctx));
132
- return vs[0]?.t === 'path' ? { t: 'path', v: joinPaths(vs.map(str)) } : null;
135
+ return vs[0]?.t === 'path' ? pathOf(joinPaths(vs.map(str))) : null;
133
136
  }
134
137
  }
135
138
  const c = chain(e);
@@ -166,7 +169,7 @@ function callFn(name, args, ctx) {
166
169
  const vs = args.filter((a) => !/^\w+\s*=[^=]/.test(a)).map((a) => evaluate(a, ctx));
167
170
  if (/(?:^|\.)Path\.home$/.test(name)) return process.env.HOME ? { t: 'path', v: process.env.HOME } : null;
168
171
  if (/(?:^|\.)Path\.cwd$|^process\.cwd$|^os\.getcwd$/.test(name)) return { t: 'path', v: ctx.at('.') };
169
- if (PATH_FNS.test(name)) return vs.length ? { t: 'path', v: joinPaths(vs.map(str)) } : null;
172
+ if (PATH_FNS.test(name)) return vs.length ? pathOf(joinPaths(vs.map(str))) : null;
170
173
  if (READS.test(name) || /^(?:io\.|codecs\.)?open$/.test(name)) {
171
174
  const p = str(vs[0]);
172
175
  if (p === null) return null;
@@ -179,7 +182,7 @@ function callFn(name, args, ctx) {
179
182
  function callMethod(base, m, args, ctx) {
180
183
  const vs = args.map((a) => evaluate(a, ctx));
181
184
  if (['expanduser', 'resolve', 'absolute'].includes(m)) return base.t === 'path' ? base : null;
182
- if (m === 'joinpath') return { t: 'path', v: joinPaths([base.v, ...vs.map(str)]) };
185
+ if (m === 'joinpath') return pathOf(joinPaths([base.v, ...vs.map(str)]));
183
186
  if ((m === 'read_text' && base.t === 'path') || (m === 'read' && base.t === 'file')) {
184
187
  ctx.reads?.add(ctx.at(base.v));
185
188
  return { t: 'str', v: ctx.readText(ctx.at(base.v)) ?? '' };
@@ -0,0 +1,139 @@
1
+ // Rust's own reading (KWB-75). C's blanker takes a lifetime for a char literal and blanks the rest of its line, and a generic blanker takes '"' for a
2
+ // string opening. Nested block comments and raw strings have no form in either. Offsets are kept, so a match's line is the file's line.
3
+ // blankRust(src) comments go; a string or char literal keeps two quotes, so it stays one token
4
+ // withoutTests(code) on blanked code: what `#[cfg(test)]` or a `#[test]` attribute marks goes too, since Rust's unit tests live in the file they test
5
+ // ownDefinitions(code) on blanked code: tests, impls and traits go, leaving the free functions
6
+ // lookupMatches(text) every `match` that is a table of literals on one enum, for dispatch-once
7
+ const spaces = (s) => s.replace(/[^\n]/g, ' ');
8
+ const quoted = (s) => (s.length >= 2 ? `"${spaces(s.slice(1, -1))}"` : spaces(s));
9
+
10
+ function commentEnd(src, start) {
11
+ let depth = 1;
12
+ let i = start + 2;
13
+ while (i < src.length && depth > 0) {
14
+ if (src.startsWith('/*', i)) { depth += 1; i += 2; }
15
+ else if (src.startsWith('*/', i)) { depth -= 1; i += 2; }
16
+ else i += 1;
17
+ }
18
+ return i;
19
+ }
20
+
21
+ function stringEnd(src, start) {
22
+ let i = start + 1;
23
+ while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
24
+ return Math.min(i + 1, src.length);
25
+ }
26
+
27
+ // r"..", r#".."#, br#".."#, cr#".."#: nothing inside escapes, and the hashes close it. Returns where the opening quote is, and the end, or null.
28
+ function rawString(src, i) {
29
+ if (/\w/.test(src[i - 1] ?? '')) return null;
30
+ const open = src.slice(i, i + 80).match(/^(?:b|c)?r(#*)"/);
31
+ if (!open) return null;
32
+ const close = src.indexOf(`"${open[1]}`, i + open[0].length);
33
+ return { quote: i + open[0].length - 1, end: close === -1 ? src.length : close + 1 + open[1].length };
34
+ }
35
+
36
+ // A quote opens a char literal only when one character, or one escape, then closes it; otherwise it is a lifetime or a label.
37
+ function charEnd(src, i) {
38
+ if (src[i + 1] === '\\') { const e = src.indexOf("'", i + 3); return e === -1 ? null : e + 1; }
39
+ const one = String.fromCodePoint(src.codePointAt(i + 1) ?? 32);
40
+ return src[i + 1 + one.length] === "'" ? i + 2 + one.length : null;
41
+ }
42
+
43
+ export function blankRust(src) {
44
+ let out = '';
45
+ let i = 0;
46
+ const take = (end, shown) => { out += shown(src.slice(i, end)); i = end; };
47
+ while (i < src.length) {
48
+ const raw = src[i] === 'r' || src[i] === 'b' || src[i] === 'c' ? rawString(src, i) : null;
49
+ const char = src[i] === "'" ? charEnd(src, i) : null;
50
+ if (src.startsWith('//', i)) { const e = src.indexOf('\n', i); take(e === -1 ? src.length : e, spaces); }
51
+ else if (src.startsWith('/*', i)) take(commentEnd(src, i), spaces);
52
+ else if (raw) { out += spaces(src.slice(i, raw.quote)); i = raw.quote; take(raw.end, quoted); }
53
+ else if (src[i] === '"') take(stringEnd(src, i), quoted);
54
+ else if (char) take(char, quoted);
55
+ else { out += src[i]; i += 1; }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ const TEST_MARK = /#\[\s*(?:cfg\s*\(\s*(?:all\s*\(\s*)?test\b[^\]]*\)|(?:\w+::)*test\b[^\]]*)\s*\]/g;
61
+
62
+ // The end of the item an attribute marks: its closing brace, or the `;` of an item with no body.
63
+ function itemEnd(code, start) {
64
+ let round = 0;
65
+ for (let i = start; i < code.length; i += 1) {
66
+ const c = code[i];
67
+ if (c === '(' || c === '[') round += 1;
68
+ else if (c === ')' || c === ']') round -= 1;
69
+ else if (c === ';' && round === 0) return i + 1;
70
+ else if (c === '{' && round === 0) {
71
+ let depth = 0;
72
+ for (let j = i; j < code.length; j += 1) {
73
+ if (code[j] === '{') depth += 1;
74
+ else if (code[j] === '}' && --depth === 0) return j + 1;
75
+ }
76
+ return code.length;
77
+ }
78
+ }
79
+ return code.length;
80
+ }
81
+
82
+ function blankItems(code, mark) {
83
+ let out = '';
84
+ let at = 0;
85
+ for (const m of code.matchAll(mark)) {
86
+ if (m.index < at) continue;
87
+ const end = itemEnd(code, m.index + m[0].length);
88
+ out += code.slice(at, m.index) + spaces(code.slice(m.index, end));
89
+ at = end;
90
+ }
91
+ return out + code.slice(at);
92
+ }
93
+
94
+ export const withoutTests = (code) => blankItems(code, TEST_MARK);
95
+
96
+ // A method lives in its type's namespace: `from_str` on a second type is the trait doing its job, and `is_none` on two types is two methods (KWB-81).
97
+ // Only a free function can be made again by name. A method copied is caught by its statements, whatever it is called.
98
+ const TYPE_ITEM = /^[ \t]*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?(?:impl|trait)\b[^{;]*(?=\{)/gm;
99
+ export const ownDefinitions = (code) => blankItems(withoutTests(code), TYPE_ITEM);
100
+
101
+ // What a `match` body holds, arm by arm: the patterns, and what the arm gives.
102
+ function armsOf(body) {
103
+ const arms = [];
104
+ let depth = 0;
105
+ let from = 0;
106
+ let head = null;
107
+ for (let i = 0; i < body.length; i += 1) {
108
+ const c = body[i];
109
+ if ('({['.includes(c)) depth += 1;
110
+ else if (')}]'.includes(c)) depth -= 1;
111
+ else if (depth === 0 && head === null && body.startsWith('=>', i)) { head = body.slice(from, i); from = i + 2; i += 1; }
112
+ else if (depth === 0 && head !== null && c === ',') { arms.push({ head, value: body.slice(from, i).trim() }); head = null; from = i + 1; }
113
+ }
114
+ if (head !== null) arms.push({ head, value: body.slice(from).trim() });
115
+ return arms;
116
+ }
117
+
118
+ const LITERAL = /^(?:"\s*"|-?\d[\w.]*)$/; // a table of true and false is a predicate, not data about the kind
119
+ const MIN_VARIANTS = 3;
120
+ const NOT_A_KIND = new Set(['Self', 'Option', 'Result', 'Ordering']); // the standard library's own enums, and an enum inside its own impl
121
+
122
+ // Every `match` that is only a lookup table: each variant of one enum to a plain value. `owned`: this file declares the enum or has its impl.
123
+ // A match that does real work on an enum is good Rust, in any number of files: the compiler holds every one to every variant. A lookup table is
124
+ // data about the kind, and belongs with the kind. Measured on eight crates: a rule on every match would have refused 7 to 12 in 100 of them.
125
+ export function lookupMatches(text) {
126
+ const code = withoutTests(blankRust(text));
127
+ const owns = new Set([...code.matchAll(/\b(?:enum|impl(?:<[^>{]*>)?)\s+([A-Z]\w*)/g)].map((m) => m[1]));
128
+ const found = [];
129
+ for (const m of code.matchAll(/\bmatch\s[^{;]+?(?=\{)/g)) {
130
+ const open = m.index + m[0].length;
131
+ const arms = armsOf(code.slice(open + 1, itemEnd(code, open) - 1));
132
+ if (!arms.length || !arms.every((a) => LITERAL.test(a.value))) continue;
133
+ const paths = arms.flatMap((a) => [...a.head.matchAll(/\b([A-Z]\w*)::([A-Z]\w*)/g)]);
134
+ const kind = paths[0]?.[1];
135
+ const labels = new Set(paths.filter((p) => p[1] === kind).map((p) => p[2]));
136
+ if (kind && labels.size >= MIN_VARIANTS && !NOT_A_KIND.has(kind)) found.push({ kind, labels, owned: owns.has(kind) });
137
+ }
138
+ return found;
139
+ }
@@ -0,0 +1,31 @@
1
+ // shape-lists: consecutive lines (or ;/, separated pieces of one line) that differ only in one number running over every instance: a hand-listed set, one line
2
+ // per instance. Split out of shape.mjs at its line limit.
3
+ // handLists(lines, c, out, seen): adds a finding to out for each such set of the concept c
4
+ import { classify, isExampleLine, named, parts } from './shape.mjs';
5
+
6
+ const templatesOf = (t) => [...t.matchAll(/(?<![\w.])\d+(?![\w.])/g)].map((m) => ({ tpl: t.slice(0, m.index) + '#' + t.slice(m.index + m[0].length), v: Number(m[0]) }));
7
+
8
+ export function handLists(lines, c, out, seen) {
9
+ if (!c.count || c.count < 2 || c.shape !== 'many') return;
10
+ const mentions = (t) => parts(t).some((p) => c.words.includes(p)) || c.arrays.some((a) => named(t, a));
11
+ const runOver = (pieces, at, whole = null) => {
12
+ for (let i = 0; i + c.count <= pieces.length; i += 1) {
13
+ for (const first of templatesOf(pieces[i].trim())) {
14
+ if (first.v > 1 || !mentions(whole ?? first.tpl)) continue;
15
+ let ok = true;
16
+ for (let k = 1; k < c.count && ok; k += 1) ok = templatesOf(pieces[i + k].trim()).some((t) => t.tpl === first.tpl && t.v === first.v + k);
17
+ if (ok) return at(i);
18
+ }
19
+ }
20
+ return null;
21
+ };
22
+ const lineNo = runOver(lines, (i) => i);
23
+ if (lineNo !== null && !isExampleLine(lines[lineNo]) && !seen.has(`${lineNo}|${c.name}`)) {
24
+ out.push({ line: lineNo + 1, text: lines[lineNo].trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one line per ${c.name}: write one loop over every ${c.name}, or the generator` });
25
+ }
26
+ lines.forEach((l, i) => {
27
+ if (seen.has(`${i}|${c.name}`) || isExampleLine(l) || classify(l, c, null)) return;
28
+ const pieces = l.split(/[;,]/);
29
+ if (pieces.length >= c.count && runOver(pieces, () => true, l)) out.push({ line: i + 1, text: l.trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one piece per ${c.name}: write one loop over every ${c.name}, or the generator` });
30
+ });
31
+ }
@@ -0,0 +1,78 @@
1
+ // shape-prose: what in a line is English, arithmetic or a name, and so names no instance (KF-13, KF-17, KF-20, KF-28, KF-36: 14 of shape-law's 25 refusals on the
2
+ // MPX 500 were wrong, on words like these). Each test is narrow on purpose: the same line with the bare form stays refused (test/kf-refusals.test.mjs holds both).
3
+ // blankMessages(line, file) -> line with a sentence in a double-quoted string blanked, same length; a short key ("knob 1") stays
4
+ // isFirstMatch(line, m) "the first program with this name", "each bank's first program", the words "first program" in quotes
5
+ // isSourceRole(line, m) a comment quoting a role a source gives: "[MAN] Edit knob 1 selects"
6
+ // isKnownName(role, word, shape) "EDIT knob", when editKnob is a concept or a listed selector
7
+ // isLongerName(line, end) "MIDI Program Change": the word starts a longer proper name
8
+ // isSum(before) "x + 1 == program": the number is an operand, not what is compared
9
+ // isLibraryCall(line, at, shape) juce::MidiMessage::programChange (1, ...): a library's call, or one on a listed value type
10
+ // declaredAmount(root, name, text) a call whose declared first parameter is an amount: turnProgram (int steps, ...)
11
+ import { execFileSync } from 'node:child_process';
12
+ import { extname } from 'node:path';
13
+ import { languageOf } from './languages.mjs';
14
+
15
+ const SENTENCE_WORDS = 3;
16
+ const LIBRARIES = new Set(['juce', 'std', 'boost', 'absl', 'Qt']);
17
+ const AMOUNT = /^(?:steps?|detents?|clicks?|notches|ticks?|amount|delta|by|count|n|offset|increment|distance)$/i;
18
+ const SOURCE_FILES = ['*.h', '*.hh', '*.hpp', '*.c', '*.cc', '*.cpp', '*.mm', '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.go', '*.rs', '*.java', '*.cs', '*.swift', '*.kt'];
19
+
20
+ export function blankMessages(line, file) {
21
+ if (!languageOf(extname(file ?? ''))) return line;
22
+ return line.replace(/"(?:[^"\\]|\\.)*"/g, (s) => (s.trim().split(/\s+/).length >= SENTENCE_WORDS ? ' '.repeat(s.length) : s));
23
+ }
24
+
25
+ export function isFirstMatch(line, m) {
26
+ if (!/^first\b/i.test(m[0])) return false;
27
+ const before = line.slice(0, m.index);
28
+ const after = line.slice(m.index + m[0].length);
29
+ if (before.endsWith('"') && after.startsWith('"')) return true;
30
+ return /(?:'s|\b[Aa]|\b[Ee]ach|\b[Ee]very|\b[Aa]ny)\s+$/.test(before) || /^(?:'s)?\s+(?:with|that|which|whose|where|matching|found|a|an)\b/.test(after);
31
+ }
32
+
33
+ export function isSourceRole(line, m) {
34
+ const isComment = /^\s*(?:\/\/|\/\*|\*|#)/.test(line);
35
+ return isComment && /\[[A-Z]{2,6}\]/.test(line.slice(0, m.index)) && /^\s+[a-z]+s\b/.test(line.slice(m.index + m[0].length));
36
+ }
37
+
38
+ const named = new WeakMap();
39
+ function namesOf(shape) {
40
+ if (!named.has(shape)) {
41
+ const rows = Object.values(shape.concepts);
42
+ named.set(shape, new Set(rows.flatMap((c) => [c.name, ...c.selectors, ...c.constants]).map((n) => n.toLowerCase())));
43
+ }
44
+ return named.get(shape);
45
+ }
46
+ export const isKnownName = (role, word, shape) => namesOf(shape).has(`${role}${word}`.toLowerCase());
47
+
48
+ export const isLongerName = (line, end) => /^\s+[A-Z][a-z]/.test(line.slice(end));
49
+ export const isSum = (before) => /[+\-*/%]\s*$/.test(before);
50
+
51
+ export function isLibraryCall(line, at, shape) {
52
+ const path = line.slice(0, at).match(/([A-Za-z_]\w*)::(?:[A-Za-z_]\w*::)*$/);
53
+ if (!path) return false;
54
+ const last = line.slice(0, at).match(/([A-Za-z_]\w*)::$/)?.[1];
55
+ return LIBRARIES.has(path[1]) || shape.values?.has(last);
56
+ }
57
+
58
+ // The first parameter of each declaration of `name`, from the text being written and the project's files.
59
+ function firstParameters(root, name, text) {
60
+ const shape = new RegExp(`\\b${name}\\s*\\(\\s*(?:const\\s+)?(?:[\\w:<>]+[\\s&*]+)+(\\w+)\\s*[,)=]`, 'g');
61
+ let found = '';
62
+ try {
63
+ found = execFileSync('git', ['-C', root, 'grep', '--untracked', '-hE', `\\b${name}[[:space:]]*\\(`, '--', ...SOURCE_FILES], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
64
+ } catch {
65
+ found = ''; // no match, or no repository: nothing declares it, so the call stays an instance
66
+ }
67
+ return [...`${text}\n${found}`.matchAll(shape)].map((m) => m[1]);
68
+ }
69
+
70
+ const amounts = new Map();
71
+ export function declaredAmount(root, name, text) {
72
+ const key = `${root}\0${name}`;
73
+ if (!amounts.has(key)) {
74
+ const params = firstParameters(root, name, text);
75
+ amounts.set(key, params.length > 0 && params.every((p) => AMOUNT.test(p)));
76
+ }
77
+ return amounts.get(key);
78
+ }
@@ -22,6 +22,8 @@ import { logRefusal } from './hook-log.mjs';
22
22
  import { readFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { matchesAny } from './glob.mjs';
25
+ import { blankMessages, isFirstMatch, isKnownName, isLibraryCall, isLongerName, isSourceRole, isSum } from './shape-prose.mjs';
26
+ import { handLists } from './shape-lists.mjs';
25
27
 
26
28
  export const SHAPE_FILE = 'design/shape.json';
27
29
  export const ASK = 'example, rule, or constant?';
@@ -113,7 +115,7 @@ export function isExampleLine(line) {
113
115
  return OPENS.test(rest) || EXAMPLE_CELL.test(line);
114
116
  }
115
117
 
116
- const named = (line, name) => new RegExp(`(?<![\\w])${esc(name)}(?![\\w])`).test(line);
118
+ export const named = (line, name) => new RegExp(`(?<![\\w])${esc(name)}(?![\\w])`).test(line);
117
119
  export function classify(line, concept, shape) {
118
120
  const c = typeof concept === 'string' ? shape?.concepts?.[concept] : concept;
119
121
  if (isExampleLine(line)) return 'example';
@@ -165,15 +167,17 @@ const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seven
165
167
  const NOT_ROLES = new Set(['ALL', 'EACH', 'EVERY', 'ANY', 'ONE', 'NEW', 'THE', 'NO', 'THIS', 'THAT', 'SAME', 'OTHER', 'OWN', 'NEXT', 'MANY', 'NOT', 'AND', 'OR', 'TODO', 'NOTE', 'FIXME', 'IN', 'ON', 'OF', 'TO', 'A', 'AN', 'EMPTY', 'SELECTED', 'OPEN', 'CURRENT',
166
168
  'PASS', 'FAIL', 'FAILED', 'RED', 'GREEN', 'OK', 'SKIP', 'WARN', 'ERROR']); // a gate's verdict before the word ("FAIL slot 2"; KWB-15)
167
169
 
168
- function lineHits(line, c, shape, aliases, inSwitch) {
170
+ function lineHits(line, c, shape, aliases, inSwitch, file) {
169
171
  const hits = [];
170
- const add = (instance, why) => hits.push({ instance: String(instance), why });
172
+ const add = (instance, why, call) => hits.push({ instance: String(instance), why, call });
173
+ const words = blankMessages(line, file); // what is read as words: a sentence in a string is text (KF-17)
171
174
  for (const re of c.patterns) for (const m of line.matchAll(re)) add(m[1] ?? m[0], `matches the pattern ${re.source}`);
172
175
  const W = c.words.map(esc).sort((a, b) => b.length - a.length).join('|');
173
- const num = new RegExp(`(?<![A-Za-z0-9])(${W})(?:[ _#-]|\\s+(?:no\\.?|number|#)\\s*)?(\\d+)(?!\\d)((?:\\s*(?:-|–|\\.\\.|to|through)\\s*)(\\d+))?`, 'gi');
176
+ const num = new RegExp(`(?<![A-Za-z0-9])(${W})(?:[ _#-]|\\s+(?:no\\.?|number|#)\\s*)?(\\d+)(?![\\dxX])((?:\\s*(?:-|–|\\.\\.|to|through)\\s*)(\\d+))?`, 'gi'); // 0x18000 is an address (KF-13)
174
177
  // A multi-word all-caps label ("AIM EFFECTS", "NOTES MONO") names a control, not an instance (KWB-22): the caps word before it.
175
178
  const labelBefore = (at) => { const w = /([A-Z][A-Z0-9]+)\s+$/.exec(line.slice(0, at)); return w !== null && !NOT_ROLES.has(w[1]); };
176
- for (const m of line.matchAll(num)) {
179
+ for (const m of words.matchAll(num)) {
180
+ if (isSourceRole(line, m)) continue; // KF-28
177
181
  if (c.count !== null && !m[3] && Number(m[2]) > c.count) continue; // past the count: a value, not an instance (KWB-22)
178
182
  if (m[1] === m[1].toUpperCase() && labelBefore(m.index)) continue; // AIM EFFECTS 100: a control's value
179
183
  if (m[3] && c.count !== null) {
@@ -186,13 +190,15 @@ function lineHits(line, c, shape, aliases, inSwitch) {
186
190
  for (const m of line.matchAll(camel)) add(m[2], `names it by number in an identifier ("${m[0]}")`);
187
191
  const S = c.single.map(esc).join('|');
188
192
  const ord = new RegExp(`(?<![A-Za-z0-9])(${ORDINALS.join('|')}|\\d+(?:st|nd|rd|th))[\\s_-]*(${S})(?![a-z])`, 'gi');
189
- for (const m of line.matchAll(ord)) {
193
+ for (const m of words.matchAll(ord)) {
194
+ if (isFirstMatch(words, m)) continue; // KF-17
190
195
  const o = m[1].toLowerCase();
191
196
  add(ORDINALS.includes(o) ? ORDINALS.indexOf(o) + 1 : parseInt(o, 10), `names it by ordinal ("${m[0].trim()}")`);
192
197
  }
193
198
  if (c.shape === 'many') {
194
199
  const role = new RegExp(`(?<![A-Za-z0-9_])([A-Z][A-Z0-9]+)\\s+(${c.single.flatMap((w) => [esc(w), esc(cap(w))]).join('|')})(?![A-Za-z0-9_])`, 'g');
195
- for (const m of line.matchAll(role)) if (!NOT_ROLES.has(m[1]) && !c.words.includes(m[1].toLowerCase()) && !c.names.has(m[1]) && !labelBefore(m.index)) add(m[1], `names it by a role ("${m[0]}"): the instance is a job or a value, not a ${c.name}`);
200
+ const notARole = (m) => NOT_ROLES.has(m[1]) || c.words.includes(m[1].toLowerCase()) || c.names.has(m[1]) || labelBefore(m.index) || isKnownName(m[1], m[2], shape) || isLongerName(words, m.index + m[0].length);
201
+ for (const m of words.matchAll(role)) if (!notARole(m)) add(m[1], `names it by a role ("${m[0]}"): the instance is a job or a value, not a ${c.name}`);
196
202
  }
197
203
  for (const m of line.matchAll(/([A-Za-z_]\w*)\s*(?:\[\s*(\d+)\s*\]|\.at\(\s*(\d+)\s*\))/g)) {
198
204
  const lead = line.slice(0, m.index).trimEnd();
@@ -203,43 +209,16 @@ function lineHits(line, c, shape, aliases, inSwitch) {
203
209
  }
204
210
  const EXPR = '[A-Za-z_]\\w*(?:(?:\\.|->|::)[A-Za-z_]\\w*|\\(\\)|\\[\\w*\\])*';
205
211
  for (const m of line.matchAll(new RegExp(`(${EXPR})\\s*(?:===?|!==?)\\s*(\\d+)(?![\\w.])`, 'g'))) if (conceptOfExpr(m[1], shape, aliases) === c.name) add(m[2], `compares a ${c.name} with a number ("${m[0]}")`);
206
- for (const m of line.matchAll(new RegExp(`(?<![\\w.])(\\d+)\\s*(?:===?|!==?)\\s*(${EXPR})`, 'g'))) if (conceptOfExpr(m[2], shape, aliases) === c.name) add(m[1], `compares a ${c.name} with a number ("${m[0]}")`);
212
+ for (const m of line.matchAll(new RegExp(`(?<![\\w.])(\\d+)\\s*(?:===?|!==?)\\s*(${EXPR})`, 'g'))) if (conceptOfExpr(m[2], shape, aliases) === c.name && !isSum(line.slice(0, m.index))) add(m[1], `compares a ${c.name} with a number ("${m[0]}")`);
207
213
  if (inSwitch === c.name) for (const m of line.matchAll(/\bcase\s+(\d+)\s*:/g)) add(m[1], `a case for one ${c.name} ("${m[0]}")`);
208
214
  for (const m of line.matchAll(/(?<![\w])([A-Za-z_]\w*)\s*\(\s*(\d+)\s*[,)]/g)) {
209
215
  const ps = parts(m[1]);
210
- if (ps.some((p) => c.single.includes(p)) && !ps.some((p) => c.plural.has(p) || COUNTISH.has(p))) add(m[2], `drives one ${c.name} by number ("${m[0].replace(/[,)]$/, '')}")`);
216
+ if (isLibraryCall(line, m.index, shape)) continue; // KF-20
217
+ if (ps.some((p) => c.single.includes(p)) && !ps.some((p) => c.plural.has(p) || COUNTISH.has(p))) add(m[2], `drives one ${c.name} by number ("${m[0].replace(/[,)]$/, '')}")`, m[1]);
211
218
  }
212
219
  return hits;
213
220
  }
214
221
 
215
- // Consecutive lines (or ;/, separated pieces of one line) that differ only in one number running over every instance:
216
- // a hand-listed set, one line per instance.
217
- function handLists(lines, c, out, seen) {
218
- if (!c.count || c.count < 2 || c.shape !== 'many') return;
219
- const mentions = (t) => parts(t).some((p) => c.words.includes(p)) || c.arrays.some((a) => named(t, a));
220
- const templates = (t) => [...t.matchAll(/(?<![\w.])\d+(?![\w.])/g)].map((m) => ({ tpl: t.slice(0, m.index) + '#' + t.slice(m.index + m[0].length), v: Number(m[0]) }));
221
- const runOver = (pieces, at, whole = null) => {
222
- for (let i = 0; i + c.count <= pieces.length; i += 1) {
223
- for (const first of templates(pieces[i].trim())) {
224
- if (first.v > 1 || !mentions(whole ?? first.tpl)) continue;
225
- let ok = true;
226
- for (let k = 1; k < c.count && ok; k += 1) ok = templates(pieces[i + k].trim()).some((t) => t.tpl === first.tpl && t.v === first.v + k);
227
- if (ok) return at(i);
228
- }
229
- }
230
- return null;
231
- };
232
- const lineNo = runOver(lines, (i) => i);
233
- if (lineNo !== null && !isExampleLine(lines[lineNo]) && !seen.has(`${lineNo}|${c.name}`)) {
234
- out.push({ line: lineNo + 1, text: lines[lineNo].trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one line per ${c.name}: write one loop over every ${c.name}, or the generator` });
235
- }
236
- lines.forEach((l, i) => {
237
- if (seen.has(`${i}|${c.name}`) || isExampleLine(l) || classify(l, c, null)) return;
238
- const pieces = l.split(/[;,]/);
239
- if (pieces.length >= c.count && runOver(pieces, () => true, l)) out.push({ line: i + 1, text: l.trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one piece per ${c.name}: write one loop over every ${c.name}, or the generator` });
240
- });
241
- }
242
-
243
222
  export function findInstanceLiterals(text, file, shape, { only = null } = {}) {
244
223
  if (!shape || !text) return [];
245
224
  const concepts = Object.values(shape.concepts).filter((c) => c.shape === 'many' || (c.shape === 'one' && c.id));
@@ -259,11 +238,11 @@ export function findInstanceLiterals(text, file, shape, { only = null } = {}) {
259
238
  // only 'brief': an example or a declared constant read by name excuses it (the number rule's third form), a selector does not
260
239
  if (cls === 'example' || (cls === 'constant' && only === 'brief') || (cls && only !== 'example' && only !== 'brief')) continue;
261
240
  const once = new Set();
262
- for (const h of lineHits(line, c, shape, aliases, inSwitch)) {
241
+ for (const h of lineHits(line, c, shape, aliases, inSwitch, file)) {
263
242
  if (once.has(h.instance)) continue;
264
243
  once.add(h.instance);
265
244
  seen.add(`${i}|${c.name}`);
266
- out.push({ line: i + 1, text: shown[i].trim(), concept: c.name, instance: h.instance, why: h.why });
245
+ out.push({ line: i + 1, text: shown[i].trim(), concept: c.name, instance: h.instance, why: h.why, call: h.call });
267
246
  }
268
247
  }
269
248
  });
@@ -66,9 +66,14 @@ export function lastTurn(path) {
66
66
  return { text: text.join('\n'), uses: uses.filter((u) => !refused.has(u.id)) };
67
67
  }
68
68
 
69
+ // A landing ends a step. Compaction is no prompt, so one turn can hold a whole run, and its last step's making line says nothing of the steps before: the
70
+ // MPX 500 was sent back for files written hours earlier, at a landing (KF-34). What a turn landed is over; only the writes after its last landing are judged.
71
+ const LANDING = /(?:^|[;&|\n])\s*git\s+(?:-C\s+\S+\s+)?merge\s+(?:\S+\s+)*?--ff-only\b/;
72
+ const sinceLanding = (uses) => uses.slice(uses.findLastIndex((u) => u.name === 'Bash' && LANDING.test(u.input.command ?? '')) + 1);
73
+
69
74
  export function writesOfTurn(turn, cwd = process.cwd()) {
70
75
  const out = [];
71
- for (const u of turn.uses) {
76
+ for (const u of sinceLanding(turn.uses)) {
72
77
  const i = u.input;
73
78
  const at = (p) => resolve(u.cwd ?? cwd, p);
74
79
  if (u.name === 'Write' && i.file_path) out.push({ path: at(i.file_path), text: i.content ?? '', tool: u.name });
@@ -0,0 +1,24 @@
1
+ // unread-writes: an inline program can write a source file in a way no hook can read, and every content law lets an unknown text through.
2
+ // The MPX 500's edits through a local helper passed no law, and nothing said so (KF-40). When a command passed, this names what it wrote
3
+ // unread, where the author sees it, so a pass is never taken for a judgement.
4
+ // unreadWrites(ev, root) -> '' | JSON for the hook's stdout
5
+ import { extname, relative } from 'node:path';
6
+ import { languageOf } from './languages.mjs';
7
+ import { insideProject } from './project.mjs';
8
+ import { writesOf } from './writes.mjs';
9
+
10
+ const INLINE = /\b(?:python[\d.]*|pypy3?|node|nodejs|bun|perl|ruby)\b[^\n|;&]*(?:\s-[A-Za-z]*[ceE]\s|\s-\s|<<)/;
11
+ const CONTENT_OPS = new Set(['write', 'edit', 'append']);
12
+
13
+ export function unreadWrites(ev, root) {
14
+ const command = ev.tool_input?.command;
15
+ if (ev.tool_name !== 'Bash' || ev.hook_event_name !== 'PreToolUse' || !INLINE.test(command ?? '')) return '';
16
+ const cwd = ev.cwd ?? root;
17
+ const unread = writesOf(ev, cwd).filter((w) => w.after === null && CONTENT_OPS.has(w.op) && languageOf(extname(w.path)) && insideProject(w.path, cwd)).map((w) => relative(cwd, w.path));
18
+ if (!unread.length) return '';
19
+ const files = unread.join(', ');
20
+ return JSON.stringify({
21
+ systemMessage: `not judged: this command writes ${files} in a way the hooks could not read`,
22
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `The hooks could not read what this command writes to ${files}, so no content law judged it. Use the Edit or Write tool for source files, where every law applies.` },
23
+ });
24
+ }
@@ -22,12 +22,13 @@ import { copyGlobs, excludeGlobs, FIX, isGenerated, isSource, real } from './lib
22
22
  import { judgeWrites } from './lib/dup-index.mjs';
23
23
  import { matchesAny } from './lib/glob.mjs';
24
24
  import { readLayout } from './lib/layout.mjs';
25
- import { checkoutRoot, insideProject, ROOT } from './lib/project.mjs';
25
+ import { checkoutRoot, claudeRoot, insideProject, ROOT } from './lib/project.mjs';
26
26
  import { readShape } from './lib/shape.mjs';
27
27
  import { lastTurn, writesOfTurn } from './lib/turn.mjs';
28
28
  import { asHook, isMain } from './lib/hook-log.mjs';
29
29
 
30
30
  const MAX_SENDS = 3;
31
+ const NOT_CONCEPTS = new Set(['none', 'no', '-', 'n/a', 'one', 'many', 'ids']);
31
32
  // the folder kinds a layout marks as code; a layout that marks none: any source file outside its docs folders
32
33
  const CODE_KINDS = new Set(['code', 'engine', 'ui', 'params', 'tests']);
33
34
  const NOT_CODE = ['build/**', 'inbox/**', 'docs/**', '**/*.md', '**/*.txt', '**/*.json'];
@@ -54,7 +55,8 @@ const OVERRIDE = [
54
55
  ];
55
56
 
56
57
  const splitName = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
57
- const partsOf = (text) => new Set((text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).flatMap(splitName));
58
+ // each identifier's parts, and the identifier whole: a concept named editPage is the word editpage, not the words edit and page (KF-19, KF-39)
59
+ const partsOf = (text) => new Set((text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).flatMap((w) => [...splitName(w), w.toLowerCase()]));
58
60
  const rowOf = (c) => c?.row ?? c ?? {};
59
61
  const wordsOf = (name, c) => [name.toLowerCase(), ...(c?.words ?? rowOf(c).words ?? []).map((w) => String(w).toLowerCase())];
60
62
 
@@ -82,10 +84,11 @@ export function unknownConcepts(making, shape) {
82
84
  // bracketed text goes first, nested brackets too, so a ";" inside a shape ("slot (many, 4; slotId(i, field))") does not end the list (KWB-17)
83
85
  let flat = making;
84
86
  for (let prev; prev !== flat;) { prev = flat; flat = flat.replace(/\([^()]*\)/g, ' '); }
85
- for (const m of flat.matchAll(/\bconcepts?\s*:\s*([^;|]+)/gi)) {
86
- for (const item of m[1].split(',')) {
87
+ // the list runs to `reuse:` or the line's end, and a shape word after a concept ("editKnob, many") is what the rule asks for (KF-24)
88
+ for (const m of flat.matchAll(/\bconcepts?\s*:\s*(.*?)(?=\breuse\s*:|\||$)/gim)) {
89
+ for (const item of m[1].split(/[,;]/)) {
87
90
  const w = item.trim().split(/\s+/)[0]?.toLowerCase().replace(/[^a-z0-9_-]/g, '');
88
- if (w && !['none', 'no', '-', 'n/a'].includes(w) && !known.has(w)) out.push(w);
91
+ if (w && !NOT_CONCEPTS.has(w) && !known.has(w)) out.push(w);
89
92
  }
90
93
  }
91
94
  return out;
@@ -180,8 +183,10 @@ export function check(event, cwd = event.cwd ?? process.cwd()) {
180
183
  }
181
184
  }
182
185
 
183
- // 2. once-only on the source the turn wrote
184
- const sources = [...new Set(judged.filter((w) => isSource(w.path) && existsSync(w.path)).map((w) => w.path))]
186
+ // 2. once-only on the source the turn wrote. The tools' writes passed once-only itself when it is installed; running its detector again on them at
187
+ // every Stop cost over the kit's own 500 ms line (KF-33). The shell's writes stay: much of what they write is unknown at write time.
188
+ const atWrite = existsSync(join(claudeRoot(join(cwd, 'x')), '.claude/hooks/once-only.mjs'));
189
+ const sources = [...new Set(judged.filter((w) => isSource(w.path) && existsSync(w.path) && !(atWrite && w.tool !== 'Bash')).map((w) => w.path))]
185
190
  .filter((p) => !matchesAny(rel(p), copyGlobs(layout)) && !isGenerated(readFileSync(p, 'utf8')));
186
191
  if (sources.length) {
187
192
  // once-only's index finds the candidates, jscpd confirms on those files alone (lib/dup-index.mjs; KWB-24: no whole-repo scan)
@@ -4,8 +4,9 @@
4
4
  // names the runner and its hooks in one command: node ".../run-hooks.mjs" .claude/hooks/a.mjs .claude/hooks/b.mjs
5
5
  // Each hook module exports hook(text) (the event's JSON text, as stdin was) and returns its exit code; the runner calls
6
6
  // them in turn, catches each one's stdout and stderr, writes each one's timing line to .claude/hooks.log (lib/hook-log.mjs)
7
- // and its own (hook "run-hooks": the whole process, node's start-up included), and exits with the worst code: 2 if any
8
- // hook refused, else the first other non-zero. A hook that throws counts as exit 1 (a non-blocking error), as it would alone.
7
+ // and its own (hook "run-hooks": the whole process, node's start-up included), and exits 2 if any hook refused. A hook that
8
+ // throws, or exits with anything but 0 or 2, crashed, and lib/crash-policy.mjs decides what the call does (KF-18: a crash
9
+ // on a tool call refuses once, and says so). lib/unread-writes.mjs says when no hook could read what a command writes (KF-40).
9
10
  // Every hook still runs on its own too (node .claude/hooks/a.mjs, its selftest, a project's hand-made entry).
10
11
  // node run-hooks.mjs <hook path or name>... (stdin: the event)
11
12
  // node run-hooks.mjs --selftest
@@ -13,8 +14,10 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, writeSync
13
14
  import { tmpdir } from 'node:os';
14
15
  import { basename, dirname, isAbsolute, join } from 'node:path';
15
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { crashVerdict } from './lib/crash-policy.mjs';
16
18
  import { logCall } from './lib/event-log.mjs';
17
19
  import { appendRotated, isMain, logHook, projectRoot, readHookLog } from './lib/hook-log.mjs';
20
+ import { unreadWrites } from './lib/unread-writes.mjs';
18
21
 
19
22
  const HERE = dirname(fileURLToPath(import.meta.url));
20
23
  export const RUNNER = 'run-hooks';
@@ -77,9 +80,12 @@ export async function runAll(args, text, root = projectRoot()) {
77
80
  logHook(root, { event: ev.hook_event_name, hook: name, ms, code: c, tool: ev.tool_name ?? ev.source });
78
81
  judged.push({ hook: name, ms, code: c, reason: err.slice(before).join(''), out: out.join('') });
79
82
  if (c === 2) code = 2;
80
- else if (c !== 0 && code === 0) code = c;
81
83
  }
82
84
  logCall(root, text, judged, appendRotated, performance.now());
85
+ const crash = crashVerdict(root, ev, judged);
86
+ if (crash.stderr) err.push(crash.stderr);
87
+ if (crash.code === 2) code = 2;
88
+ outs.push(...[crash.stdout, code === 0 ? unreadWrites(ev, root) : ''].filter(Boolean));
83
89
  return { code, stdout: mergeOut(outs), stderr: err.join('') };
84
90
  }
85
91
 
@@ -100,15 +106,17 @@ async function selftest() {
100
106
  const r2 = await runAll([ok, no], JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Write' }), dir);
101
107
  cases.push(['no refusal: exit 0', r2.code === 0 && r2.stdout === '{"systemMessage":"a"}']);
102
108
  const r3 = await runAll([boom, ok], bash, dir);
103
- cases.push(['a hook that throws is exit 1 and the next still runs', r3.code === 1 && r3.stderr.includes('throws failed') && r3.stdout.includes('"a"')]);
109
+ cases.push(['a hook that throws refuses the call, says so, and the next still runs', r3.code === 2 && r3.stderr.includes('throws crashed') && r3.stdout.includes('"a"')]);
110
+ const r4 = await runAll([boom, ok], bash, dir);
111
+ cases.push(['the same call again goes through, and says so', r4.code === 0 && r4.stdout.includes('throws crashed')]);
104
112
  const rows = readHookLog(dir);
105
- cases.push(['one timing line per hook per call', rows.length === 7 && rows.filter((r) => r.hook === 'refuses').map((r) => r.code).join() === '2,0']);
113
+ cases.push(['one timing line per hook per call', rows.length === 9 && rows.filter((r) => r.hook === 'refuses').map((r) => r.code).join() === '2,0']);
106
114
  cases.push(['the line names the event and the tool', rows[0].event === 'PreToolUse' && rows[0].tool === 'Bash']);
107
115
  const lines = (f) => readFileSync(join(dir, '.claude/logs', f), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
108
116
  const refused = lines('refusals.jsonl');
109
- cases.push(['every call is a line of events.jsonl', lines('events.jsonl').length === 3]);
117
+ cases.push(['every call is a line of events.jsonl', lines('events.jsonl').length === 4]);
110
118
  cases.push(['a refusal keeps its reason and what was tried', refused[0].hook === 'refuses' && refused[0].reason.includes('BLOCKED by refuses') && refused[0].command === 'rm x']);
111
- cases.push(['a crash is kept too', refused.some((r) => r.hook === 'throws' && r.code === 1)]);
119
+ cases.push(['a crash is kept, in its own log', !refused.some((r) => r.hook === 'throws') && lines('crashes.jsonl').some((r) => r.hook === 'throws' && r.code === 1)]);
112
120
  rmSync(dir, { recursive: true, force: true });
113
121
  const bad = cases.filter(([, good]) => !good);
114
122
  for (const [label] of bad) console.error(`run-hooks selftest FAILED: ${label}`);