clud-bug 0.6.34 → 0.7.0-rc.11

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 (153) hide show
  1. package/README.md +20 -0
  2. package/bin/clud-bug.js +10 -1353
  3. package/data/canonical-v1.json +37 -0
  4. package/dist/cli/agents-md.d.ts +16 -0
  5. package/dist/cli/agents-md.d.ts.map +1 -0
  6. package/dist/cli/agents-md.js +226 -0
  7. package/dist/cli/agents-md.js.map +1 -0
  8. package/dist/cli/audit.d.ts +13 -0
  9. package/dist/cli/audit.d.ts.map +1 -0
  10. package/dist/cli/audit.js +90 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/branch-protection.d.ts +57 -0
  13. package/dist/cli/branch-protection.d.ts.map +1 -0
  14. package/dist/cli/branch-protection.js +118 -0
  15. package/dist/cli/branch-protection.js.map +1 -0
  16. package/dist/cli/configure-github.d.ts +58 -0
  17. package/dist/cli/configure-github.d.ts.map +1 -0
  18. package/dist/cli/configure-github.js +242 -0
  19. package/dist/cli/configure-github.js.map +1 -0
  20. package/dist/cli/edit-workflow.d.ts +18 -0
  21. package/dist/cli/edit-workflow.d.ts.map +1 -0
  22. package/dist/cli/edit-workflow.js +43 -0
  23. package/dist/cli/edit-workflow.js.map +1 -0
  24. package/dist/cli/index.d.ts +9 -0
  25. package/dist/cli/index.d.ts.map +1 -0
  26. package/dist/cli/index.js +19 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/cli/main.d.ts +3 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +2067 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/skill-usage.d.ts +109 -0
  33. package/dist/cli/skill-usage.d.ts.map +1 -0
  34. package/dist/cli/skill-usage.js +380 -0
  35. package/dist/cli/skill-usage.js.map +1 -0
  36. package/dist/cli/skills.d.ts +56 -0
  37. package/dist/cli/skills.d.ts.map +1 -0
  38. package/dist/cli/skills.js +292 -0
  39. package/dist/cli/skills.js.map +1 -0
  40. package/dist/cli/update.d.ts +29 -0
  41. package/dist/cli/update.d.ts.map +1 -0
  42. package/dist/cli/update.js +205 -0
  43. package/dist/cli/update.js.map +1 -0
  44. package/dist/cli/usage.d.ts +142 -0
  45. package/dist/cli/usage.d.ts.map +1 -0
  46. package/dist/cli/usage.js +348 -0
  47. package/dist/cli/usage.js.map +1 -0
  48. package/dist/core/audit.d.ts +8 -0
  49. package/dist/core/audit.d.ts.map +1 -0
  50. package/dist/core/audit.js +47 -0
  51. package/dist/core/audit.js.map +1 -0
  52. package/dist/core/auto-resolve.d.ts +235 -0
  53. package/dist/core/auto-resolve.d.ts.map +1 -0
  54. package/dist/core/auto-resolve.js +293 -0
  55. package/dist/core/auto-resolve.js.map +1 -0
  56. package/dist/core/configure-github.d.ts +179 -0
  57. package/dist/core/configure-github.d.ts.map +1 -0
  58. package/dist/core/configure-github.js +289 -0
  59. package/dist/core/configure-github.js.map +1 -0
  60. package/dist/core/detect.d.ts +77 -0
  61. package/dist/core/detect.d.ts.map +1 -0
  62. package/dist/core/detect.js +262 -0
  63. package/dist/core/detect.js.map +1 -0
  64. package/dist/core/diff-findings.d.ts +96 -0
  65. package/dist/core/diff-findings.d.ts.map +1 -0
  66. package/dist/core/diff-findings.js +261 -0
  67. package/dist/core/diff-findings.js.map +1 -0
  68. package/dist/core/formal-review.d.ts +79 -0
  69. package/dist/core/formal-review.d.ts.map +1 -0
  70. package/dist/core/formal-review.js +71 -0
  71. package/dist/core/formal-review.js.map +1 -0
  72. package/dist/core/index.d.ts +17 -0
  73. package/dist/core/index.d.ts.map +1 -0
  74. package/dist/core/index.js +66 -0
  75. package/dist/core/index.js.map +1 -0
  76. package/dist/core/inline-threads.d.ts +160 -0
  77. package/dist/core/inline-threads.d.ts.map +1 -0
  78. package/dist/core/inline-threads.js +369 -0
  79. package/dist/core/inline-threads.js.map +1 -0
  80. package/dist/core/prompt-builder.d.ts +164 -0
  81. package/dist/core/prompt-builder.d.ts.map +1 -0
  82. package/dist/core/prompt-builder.js +419 -0
  83. package/dist/core/prompt-builder.js.map +1 -0
  84. package/dist/core/prompts.d.ts +9 -0
  85. package/dist/core/prompts.d.ts.map +1 -0
  86. package/dist/core/prompts.js +401 -0
  87. package/dist/core/prompts.js.map +1 -0
  88. package/dist/core/render-review.d.ts +6 -0
  89. package/dist/core/render-review.d.ts.map +1 -0
  90. package/dist/core/render-review.js +219 -0
  91. package/dist/core/render-review.js.map +1 -0
  92. package/dist/core/render.d.ts +13 -0
  93. package/dist/core/render.d.ts.map +1 -0
  94. package/dist/core/render.js +62 -0
  95. package/dist/core/render.js.map +1 -0
  96. package/dist/core/resolve-verifier.d.ts +46 -0
  97. package/dist/core/resolve-verifier.d.ts.map +1 -0
  98. package/dist/core/resolve-verifier.js +187 -0
  99. package/dist/core/resolve-verifier.js.map +1 -0
  100. package/dist/core/review-schema-zod.d.ts +240 -0
  101. package/dist/core/review-schema-zod.d.ts.map +1 -0
  102. package/dist/core/review-schema-zod.js +218 -0
  103. package/dist/core/review-schema-zod.js.map +1 -0
  104. package/dist/core/review-schema.d.ts +42 -0
  105. package/dist/core/review-schema.d.ts.map +1 -0
  106. package/dist/core/review-schema.js +156 -0
  107. package/dist/core/review-schema.js.map +1 -0
  108. package/dist/core/review-writeback.d.ts +193 -0
  109. package/dist/core/review-writeback.d.ts.map +1 -0
  110. package/dist/core/review-writeback.js +364 -0
  111. package/dist/core/review-writeback.js.map +1 -0
  112. package/dist/core/skills.d.ts +147 -0
  113. package/dist/core/skills.d.ts.map +1 -0
  114. package/dist/core/skills.js +726 -0
  115. package/dist/core/skills.js.map +1 -0
  116. package/dist/core/version.d.ts +2 -0
  117. package/dist/core/version.d.ts.map +1 -0
  118. package/dist/core/version.js +4 -0
  119. package/dist/core/version.js.map +1 -0
  120. package/package.json +37 -5
  121. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  122. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  123. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  124. package/src/cli/configure-github.ts +329 -0
  125. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  126. package/src/cli/index.ts +107 -0
  127. package/src/cli/main.ts +2204 -0
  128. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  129. package/src/cli/skills.ts +386 -0
  130. package/{lib/update.js → src/cli/update.ts} +87 -27
  131. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  132. package/src/core/audit.ts +53 -0
  133. package/src/core/auto-resolve.ts +488 -0
  134. package/src/core/configure-github.ts +497 -0
  135. package/{lib/detect.js → src/core/detect.ts} +100 -47
  136. package/src/core/diff-findings.ts +323 -0
  137. package/src/core/formal-review.ts +168 -0
  138. package/src/core/index.ts +250 -0
  139. package/src/core/inline-threads.ts +471 -0
  140. package/src/core/prompt-builder.ts +561 -0
  141. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  142. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  143. package/{lib/render.js → src/core/render.ts} +37 -17
  144. package/src/core/resolve-verifier.ts +228 -0
  145. package/src/core/review-schema-zod.ts +262 -0
  146. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  147. package/src/core/review-writeback.ts +565 -0
  148. package/{lib/skills.js → src/core/skills.ts} +456 -342
  149. package/src/core/version.ts +3 -0
  150. package/templates/clud-bug-review.md.tmpl +115 -0
  151. package/templates/workflow-py.yml.tmpl +80 -3
  152. package/templates/workflow-ts.yml.tmpl +80 -3
  153. package/templates/workflow.yml.tmpl +175 -8
@@ -0,0 +1,726 @@
1
+ // Pure skill helpers — no FS, only the network via injectable fetch.
2
+ //
3
+ // Split from lib/skills.js during the v0.7.0 TS migration. The App
4
+ // (clud-bug-app) consumes these for review-time skill routing without
5
+ // pulling node:fs into the serverless bundle. CLI-only install/update
6
+ // helpers live in src/cli/skills.ts.
7
+ //
8
+ // The `_internal` debug-export pattern from lib/skills.js is removed
9
+ // here: every helper that needed test access has been promoted to a
10
+ // direct named export. Constants (`MAX_SKILLS`, `API_BASE`) and the
11
+ // shape normaliser (`normalizeList`) are now first-class core exports.
12
+ export const API_BASE = 'https://skills.sh/api/v1';
13
+ export const MAX_SKILLS = 8;
14
+ export function normalizeList(data) {
15
+ // Tolerate either { skills: [...] } or a bare array.
16
+ const list = Array.isArray(data)
17
+ ? data
18
+ : (data?.skills
19
+ || data?.results
20
+ || []);
21
+ return list
22
+ .map((item) => ({
23
+ source: item.source || item.repo || '',
24
+ name: item.name || item.slug || '',
25
+ description: item.description || item.summary || '',
26
+ installs: item.installs || item.installCount || 0,
27
+ }))
28
+ .filter((s) => s.source && s.name);
29
+ }
30
+ export class SkillsClient {
31
+ fetch;
32
+ base;
33
+ userAgent;
34
+ constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' } = {}) {
35
+ this.fetch = fetch;
36
+ this.base = base ?? process.env['CLUD_BUG_SKILLS_SH_BASE'] ?? API_BASE;
37
+ this.userAgent = userAgent;
38
+ }
39
+ async #json(path) {
40
+ const res = await this.fetch(`${this.base}${path}`, {
41
+ headers: { 'User-Agent': this.userAgent, accept: 'application/json' },
42
+ });
43
+ if (!res.ok) {
44
+ throw new Error(`skills.sh ${path} → ${res.status}`);
45
+ }
46
+ return res.json();
47
+ }
48
+ async search(terms) {
49
+ const q = terms.filter(Boolean).join(' ').trim();
50
+ if (!q)
51
+ return [];
52
+ const data = await this.#json(`/skills/search?q=${encodeURIComponent(q)}`);
53
+ return normalizeList(data);
54
+ }
55
+ async curated() {
56
+ const data = await this.#json('/skills/curated');
57
+ return normalizeList(data);
58
+ }
59
+ async getContent(source, name) {
60
+ const data = (await this.#json(`/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`));
61
+ // The API may return content as `body`, `content`, or under `files[0].content`.
62
+ // Try the documented shapes in order; fail loudly if none match so we know
63
+ // the API contract changed.
64
+ if (typeof data?.content === 'string')
65
+ return data.content;
66
+ if (typeof data?.body === 'string')
67
+ return data.body;
68
+ const first = data?.files?.[0]?.content;
69
+ if (typeof first === 'string')
70
+ return first;
71
+ throw new Error(`skills.sh response for ${source}/${name} had no content field`);
72
+ }
73
+ }
74
+ // Deduplicates by source/name and caps at MAX_SKILLS, preferring curated then by install count.
75
+ export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
76
+ const seen = new Set(baseline.map((b) => `local:${b.name}`));
77
+ const out = [...baseline];
78
+ const remaining = cap - baseline.length;
79
+ if (remaining <= 0)
80
+ return out.slice(0, cap);
81
+ const curatedSorted = [...curated].sort((a, b) => b.installs - a.installs);
82
+ const searchedSorted = [...searched].sort((a, b) => b.installs - a.installs);
83
+ for (const skill of [...curatedSorted, ...searchedSorted]) {
84
+ if (out.length >= cap)
85
+ break;
86
+ const key = `${skill.source}/${skill.name}`;
87
+ if (seen.has(key))
88
+ continue;
89
+ seen.add(key);
90
+ out.push({ ...skill, kind: skill.kind || 'remote' });
91
+ }
92
+ return out;
93
+ }
94
+ // Extract the `review_mode` field from a SKILL.md's frontmatter.
95
+ //
96
+ // Contract (from the v0.6 plan, option D-unified):
97
+ // - `shared` → the skill loads alongside other shared skills in ONE
98
+ // Claude call. Bug-finding baselines + most skills.sh
99
+ // contributions live here; they benefit from cross-
100
+ // correlation (an evidence-based finding flagged for
101
+ // critical-issues-only also gets the convention check).
102
+ // - `dedicated` → the skill gets its OWN focused Claude call. Reserved
103
+ // for domain-specific skills (brand voice, compliance,
104
+ // API-contract) where attention dilution at high skill
105
+ // counts is the real failure mode.
106
+ // - Missing field → default to `shared`. Conservative: the skill loads,
107
+ // no surprise per-skill API cost. Users opt skills INTO
108
+ // `dedicated` by authoring the field.
109
+ //
110
+ // The CLI runtime (v0.5.9) honors this via prompt restructuring inside a
111
+ // single claude-code-action call. The v0.6 GitHub App will use the same
112
+ // field to route to literal parallel API calls. Single source of truth.
113
+ export function readReviewMode(skillContent) {
114
+ if (typeof skillContent !== 'string')
115
+ return 'shared';
116
+ // Scope to the YAML frontmatter block (between the first two `---` lines).
117
+ // A `review_mode:` line in the body is documentation, not configuration.
118
+ const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
119
+ if (!fm)
120
+ return 'shared';
121
+ const m = fm[1].match(/^review_mode:\s*(\S+)\s*$/m);
122
+ if (!m)
123
+ return 'shared';
124
+ // Strip optional YAML string-quotes — `review_mode: "dedicated"` and
125
+ // `review_mode: 'dedicated'` are both valid YAML, but the (\S+) capture
126
+ // grabs the quotes too. Without this, quoted forms silently fell back
127
+ // to `shared` even though the author clearly meant dedicated.
128
+ const value = m[1].toLowerCase().replace(/^["']|["']$/g, '');
129
+ return value === 'dedicated' ? 'dedicated' : 'shared';
130
+ }
131
+ // 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
132
+ //
133
+ // Schema:
134
+ // applies_to:
135
+ // paths:
136
+ // - "src/ui/**"
137
+ // - "lib/components/**"
138
+ // extensions: [".tsx", ".jsx"]
139
+ //
140
+ // Returns `{paths: string[], extensions: string[]}` if the field is
141
+ // present (either sub-list optional, both default to empty array), or
142
+ // `null` if absent. Skills without applies_to are scope-universal —
143
+ // the caller should treat null as "load unconditionally."
144
+ //
145
+ // Hand-rolled YAML parser scoped to this exact shape. The frontmatter
146
+ // is otherwise opaque (review_mode is parsed elsewhere with a similar
147
+ // single-key regex), so pulling in a YAML dep would be overkill.
148
+ export function readAppliesTo(skillContent) {
149
+ if (typeof skillContent !== 'string')
150
+ return null;
151
+ const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
152
+ if (!fm)
153
+ return null;
154
+ const block = fm[1];
155
+ // Anchor on `applies_to:` at start of line (the body of a SKILL.md
156
+ // could mention the term in prose; only the frontmatter key fires).
157
+ const head = block.match(/^applies_to:\s*$/m);
158
+ if (!head)
159
+ return null;
160
+ // Slice from after the `applies_to:` line; the block ends at the
161
+ // next top-level key (a line starting with a word character + `:`)
162
+ // OR end-of-block.
163
+ // `head.index` is defined here because String.prototype.match returns
164
+ // a RegExpMatchArray with `index` set when the regex is non-global.
165
+ const startIdx = head.index + head[0].length;
166
+ const rest = block.slice(startIdx);
167
+ const stop = rest.search(/^\w[\w-]*:/m);
168
+ const scoped = stop === -1 ? rest : rest.slice(0, stop);
169
+ const paths = parseYamlList(scoped, 'paths');
170
+ const extensions = parseYamlList(scoped, 'extensions');
171
+ if (paths.length === 0 && extensions.length === 0)
172
+ return null;
173
+ return { paths, extensions };
174
+ }
175
+ // Parse a YAML list under `<key>:`, handling both the inline-array
176
+ // form (`extensions: [".tsx", ".jsx"]`) and the block form
177
+ // (`paths:` followed by ` - "src/ui/**"` lines).
178
+ function parseYamlList(block, key) {
179
+ const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
180
+ if (inline) {
181
+ return inline[1]
182
+ .split(',')
183
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
184
+ .filter(Boolean);
185
+ }
186
+ const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
187
+ const head = block.match(headerRe);
188
+ if (!head)
189
+ return [];
190
+ const after = block.slice(head.index + head[0].length);
191
+ const items = [];
192
+ for (const line of after.split('\n')) {
193
+ const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
194
+ if (item) {
195
+ items.push(item[1].replace(/^["']|["']$/g, ''));
196
+ continue;
197
+ }
198
+ // Anything that isn't a list item (or blank) ends the list.
199
+ if (line.trim() !== '' && !item)
200
+ break;
201
+ }
202
+ return items;
203
+ }
204
+ // 0.0.K: does `prPaths` contain at least one file matching the skill's
205
+ // applies_to? Skills without applies_to ALWAYS apply (back-compat).
206
+ //
207
+ // `prPaths` is the list of changed files in the PR (e.g. from
208
+ // `gh pr diff --name-only`). Match semantics:
209
+ // - paths: any glob in `paths` matches any of `prPaths`
210
+ // - extensions: any extension in `extensions` matches any of `prPaths`
211
+ // - paths OR extensions (NOT AND) — a single hit is enough
212
+ //
213
+ // Skill `paths` use the minimal glob set logmind already uses
214
+ // (`*` matches non-slash, `**` matches across slashes, `?` single
215
+ // char). Anything fancier would need a real glob lib.
216
+ export function appliesToPr(skillContent, prPaths) {
217
+ const rule = readAppliesTo(skillContent);
218
+ if (rule === null)
219
+ return true; // back-compat: no rule → applies
220
+ if (!Array.isArray(prPaths))
221
+ return true; // be permissive on bad input
222
+ for (const path of prPaths) {
223
+ if (typeof path !== 'string')
224
+ continue;
225
+ for (const ext of rule.extensions) {
226
+ if (path.endsWith(ext))
227
+ return true;
228
+ }
229
+ for (const glob of rule.paths) {
230
+ if (globMatch(glob, path))
231
+ return true;
232
+ }
233
+ }
234
+ return false;
235
+ }
236
+ // v0.7.0-rc.6 (SPEC §1.10.1 v0.5.1+) — does this skill apply to a PR
237
+ // opened by `prAuthor`? Reads `applies_to.author` from the raw SKILL.md
238
+ // frontmatter. Returns:
239
+ // - `true` if `applies_to.author` is absent (no author filter set —
240
+ // skill loads regardless of author; v0.5.0 backward-compat default).
241
+ // - `true` if `applies_to.author === prAuthor` (case-sensitive match
242
+ // against the PR author's GitHub login).
243
+ // - `false` if `applies_to.author` is set to a different login.
244
+ //
245
+ // Designed to mirror `appliesToPr`'s signature (raw skill content +
246
+ // runtime PR context) so consumers can call both filters in a single
247
+ // loader pass. Skills with NO `applies_to` block at all also return
248
+ // `true` (the existing v0.5.0 unconditional-load behavior).
249
+ //
250
+ // Strict-AND composition per SPEC §1.10.1: when a skill sets BOTH
251
+ // `applies_to.author` AND `paths`/`extensions`, callers MUST call BOTH
252
+ // filters and AND the results. This helper only checks the author leg;
253
+ // the caller composes.
254
+ export function appliesToAuthor(skillContent, prAuthor) {
255
+ if (typeof skillContent !== 'string')
256
+ return true;
257
+ const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
258
+ if (!fm)
259
+ return true;
260
+ const block = fm[1];
261
+ // Look for `applies_to:` block; if absent, no author constraint.
262
+ const head = block.match(/^applies_to:\s*$/m);
263
+ if (!head)
264
+ return true;
265
+ const startIdx = head.index + head[0].length;
266
+ const rest = block.slice(startIdx);
267
+ // The block ends at the next top-level key (line starting with a
268
+ // word char + `:`) OR end-of-block. Mirrors `readAppliesTo`.
269
+ const stop = rest.search(/^\w[\w-]*:/m);
270
+ const scoped = stop === -1 ? rest : rest.slice(0, stop);
271
+ // Match ` author: <value>` — single string scalar; strip optional
272
+ // quotes. `author: [a, b]` form is intentionally NOT supported
273
+ // (SPEC: "single GitHub login string, no list").
274
+ const authorMatch = scoped.match(/^\s{2}author:\s*(.+?)\s*$/m);
275
+ if (!authorMatch)
276
+ return true;
277
+ const declared = authorMatch[1]
278
+ .trim()
279
+ .replace(/^["']|["']$/g, '');
280
+ if (!declared)
281
+ return true; // empty value treated as unset
282
+ if (typeof prAuthor !== 'string' || !prAuthor)
283
+ return false;
284
+ // GitHub logins are case-insensitive (GitHub rejects creating two
285
+ // accounts that differ only in case; `pull_request.user.login` is
286
+ // case-preserved but routing is not case-sensitive). Compare
287
+ // case-insensitively so a skill author writing `Thrillmot` still
288
+ // matches a webhook delivering `thrillmot` (or vice versa).
289
+ // Reviewer-flagged Important on PR #179.
290
+ return declared.toLowerCase() === prAuthor.toLowerCase();
291
+ }
292
+ // Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
293
+ // everything else escaped. Anchored full-string match.
294
+ function globMatch(glob, path) {
295
+ const escaped = glob
296
+ .replace(/([.+^${}()|[\]\\])/g, '\\$1')
297
+ .replace(/\*\*/g, '__DOUBLESTAR__')
298
+ .replace(/\*/g, '[^/]*')
299
+ .replace(/__DOUBLESTAR__/g, '.*')
300
+ .replace(/\?/g, '.');
301
+ return new RegExp(`^${escaped}$`).test(path);
302
+ }
303
+ // Partition a set of loaded skills into {shared, dedicated} buckets per
304
+ // each skill's review_mode frontmatter. Expects skills with a `content`
305
+ // field (SKILL.md text). Skills without content default to `shared`.
306
+ //
307
+ // Shape: input is the same skill objects produced by loadBaseline /
308
+ // writeSkills / listInstalled. Output is two arrays of the same shape;
309
+ // caller decides what to do with each bucket.
310
+ export function partitionByReviewMode(skills) {
311
+ const shared = [];
312
+ const dedicated = [];
313
+ for (const skill of skills) {
314
+ const mode = readReviewMode(skill?.content);
315
+ (mode === 'dedicated' ? dedicated : shared).push(skill);
316
+ }
317
+ return { shared, dedicated };
318
+ }
319
+ // Pull the line for `skillName` from a clud-bug review's `### Per-skill scan`
320
+ // block. The block format (set by the v3+ prompt) is one line per loaded skill:
321
+ //
322
+ // ### Per-skill scan
323
+ // - [critical-issues-only]: scanned all paths. 2 critical findings below.
324
+ // - [brand-voice-review]: scanned 3 microcopy changes. 1 finding (below).
325
+ // - [pii-and-compliance]: scanned analytics + logging. 0 findings.
326
+ //
327
+ // Returns the OUTCOME portion (everything after the `- [name]: ` prefix), with
328
+ // trailing whitespace stripped. Returns null if the skill isn't mentioned, the
329
+ // comment has no Per-skill scan block, or `comment` is empty.
330
+ //
331
+ // The brackets in the line prefix anchor the match so a partial-name collision
332
+ // (e.g. `brand-voice` finding `brand-voice-review`) is impossible.
333
+ export function extractPerSkillLine(comment, skillName) {
334
+ if (typeof comment !== 'string' || !comment)
335
+ return null;
336
+ if (typeof skillName !== 'string' || !skillName)
337
+ return null;
338
+ // Escape regex metacharacters in the skill name. A skill name with a `.` or
339
+ // `+` would otherwise alter the match. Skills are conventionally kebab-case,
340
+ // but defense in depth is cheap.
341
+ const escaped = skillName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
342
+ // Anchor on the bracket-prefix; tolerate optional leading whitespace and
343
+ // dash. The OUTCOME is everything from after `]:` to end-of-line.
344
+ const re = new RegExp(`^\\s*-\\s*\\[${escaped}\\]:\\s*(.+?)\\s*$`, 'm');
345
+ const m = comment.match(re);
346
+ return m ? m[1] : null;
347
+ }
348
+ // Find the latest clud-bug review header line from a list of PR comments.
349
+ // Source of truth for the v0.5.x strict-mode-gate header selection — the
350
+ // composite action shells out to node + this helper rather than parsing
351
+ // in bash, so the gate has unit-test coverage and the v0.6 App can reuse
352
+ // the same logic.
353
+ //
354
+ // Contract (called by .github/actions/strict-mode-gate/action.yml):
355
+ // - Walk `comments` (newest-first per gh api ?sort=created&direction=desc).
356
+ // - Skip comments not authored by `botLogin`.
357
+ // - For each remaining comment, find the FIRST line starting with the
358
+ // H2 sentinel `## 🐛 Clud Bug review`. If present, return that line.
359
+ // - Return null if no matching comment exists.
360
+ //
361
+ // Why this isn't `comments.find(c => c.body.startsWith("## 🐛 Clud Bug review"))`:
362
+ // claude-code-action prepends a `**Claude finished @user's task in Nm Ns**`
363
+ // preamble to every bot comment, so the H2 review header never appears at
364
+ // body position 0. The pre-v0.5.12 composite used `.body | startswith(...)`
365
+ // in jq and matched ZERO comments in practice — silently disabling strict
366
+ // mode on every install with strictMode: true. Caught when this repo
367
+ // dogfooded BB.3 on PR #60: bot wrote "— critical findings" header, gate
368
+ // passed anyway.
369
+ //
370
+ // The line-anchored extraction preserves the original "don't trip on
371
+ // quoted sentinels in body text" property: a comment that mentions the
372
+ // strict-mode header in prose (inline-code, blockquote) won't match
373
+ // because the quoted version isn't at start-of-line.
374
+ export function selectReviewHeader(comments, botLogin) {
375
+ if (!Array.isArray(comments))
376
+ return null;
377
+ if (typeof botLogin !== 'string' || !botLogin)
378
+ return null;
379
+ // Sort newest-first by created_at. The composite passes the result of
380
+ // `gh api .../comments?sort=created&direction=desc` — but GitHub's
381
+ // REST issue-comments endpoint ignores `direction=desc` and returns
382
+ // ascending (oldest first) regardless. PR #64 caught this: the gate
383
+ // was selecting the OLDER "— critical findings" comment instead of
384
+ // the newer "— clean" follow-up, so fix-push reviews that resolved
385
+ // critical findings still failed the gate. Explicit sort here makes
386
+ // selection deterministic regardless of upstream API quirks.
387
+ const sorted = [...comments].sort((a, b) => {
388
+ const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
389
+ const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
390
+ return tb - ta; // newest first
391
+ });
392
+ for (const c of sorted) {
393
+ if (!c || typeof c !== 'object')
394
+ continue;
395
+ const author = c.user?.login;
396
+ const body = c.body;
397
+ if (author !== botLogin || typeof body !== 'string')
398
+ continue;
399
+ const headerLine = extractFirstReviewHeaderLine(body);
400
+ if (headerLine)
401
+ return headerLine;
402
+ }
403
+ return null;
404
+ }
405
+ // Pull the FIRST line of `body` that starts with the H2 sentinel.
406
+ // Exported separately so callers can extract a header from a known body
407
+ // without re-running the comment filter (useful in tests + the v0.6 App).
408
+ export function extractFirstReviewHeaderLine(body) {
409
+ if (typeof body !== 'string')
410
+ return null;
411
+ const m = body.match(/^## 🐛 Clud Bug review[^\n]*/m);
412
+ return m ? m[0] : null;
413
+ }
414
+ // Companion to selectReviewHeader: returns the FULL BODY of the latest
415
+ // clud-bug review comment from `botLogin`, not just its header line.
416
+ // Same filter contract (line-anchored H2 sentinel, claude-code-action
417
+ // preamble tolerated). Used by the BB.3 per-skill check-runs step,
418
+ // which needs the body to extract per-skill outcome lines from the
419
+ // "### Per-skill scan" block — the header alone isn't enough.
420
+ //
421
+ // Returns null if no matching comment exists. The composite action
422
+ // treats null as "no review yet; emit no check-runs" (the same posture
423
+ // that pre-v0.5.12 bash code intended via the `[ -z "$LATEST" ]` branch,
424
+ // only now actually reachable instead of always-fires-due-to-bug).
425
+ //
426
+ // Same-bug fix as selectReviewHeader: PR #61 caught that BB.3 step 2
427
+ // of the composite still used the broken `.body | startswith(...)` jq
428
+ // filter even after step 1 was refactored, leaving per-skill check-runs
429
+ // silently disabled on every install with strictSkills since v0.5.10.
430
+ export function selectReviewBody(comments, botLogin) {
431
+ if (!Array.isArray(comments))
432
+ return null;
433
+ if (typeof botLogin !== 'string' || !botLogin)
434
+ return null;
435
+ // Same explicit newest-first sort as selectReviewHeader — gh api
436
+ // ignores direction=desc on issue-comments and returns ascending,
437
+ // so without this BB.3 was parsing per-skill outcomes from the
438
+ // OLDEST review comment, not the latest. See selectReviewHeader.
439
+ const sorted = [...comments].sort((a, b) => {
440
+ const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
441
+ const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
442
+ return tb - ta;
443
+ });
444
+ for (const c of sorted) {
445
+ if (!c || typeof c !== 'object')
446
+ continue;
447
+ const author = c.user?.login;
448
+ const body = c.body;
449
+ if (author !== botLogin || typeof body !== 'string')
450
+ continue;
451
+ if (extractFirstReviewHeaderLine(body))
452
+ return body;
453
+ }
454
+ return null;
455
+ }
456
+ // Extract the v0.6.5+ stats header line "Found: N 🔴 / N 🟡 / N 🟣"
457
+ // from a review comment body. Returns {important, nit, preExisting} when
458
+ // found, null otherwise. The header lets agents reading the comment on a
459
+ // re-review triage at a glance — on the common zero-findings case, the
460
+ // header IS the entire substantive payload, so an ingest can short-circuit
461
+ // without parsing the body.
462
+ //
463
+ // The match is intentionally permissive on whitespace around the slashes
464
+ // and tolerates 1+ digits for each count. Severity emoji are matched
465
+ // literally — a future bot revision that changes the emoji would break
466
+ // this parser loudly, which is the intended behavior (catches drift).
467
+ export function extractStatsHeader(comment) {
468
+ if (typeof comment !== 'string' || !comment)
469
+ return null;
470
+ const re = /Found:\s*(\d+)\s*🔴\s*\/\s*(\d+)\s*🟡\s*\/\s*(\d+)\s*🟣/u;
471
+ const m = comment.match(re);
472
+ if (!m)
473
+ return null;
474
+ return {
475
+ important: parseInt(m[1], 10),
476
+ nit: parseInt(m[2], 10),
477
+ preExisting: parseInt(m[3], 10),
478
+ };
479
+ }
480
+ // Decide whether a review-header line is the strict-mode "critical findings"
481
+ // verdict that should fail the gate. Mirrors the v0.5.x bash predicate
482
+ // `grep -q "Clud Bug review — critical findings"`.
483
+ //
484
+ // Returns false for null/non-string input so a "no header found" path
485
+ // (selectReviewHeader returning null) safely falls through to the gate
486
+ // passing — which is the right posture: if the bot didn't post a review
487
+ // with the strict-mode header, there's nothing for the gate to fail on.
488
+ // "Loud failure for missing manifest" is handled upstream in the composite.
489
+ export function isCriticalReviewHeader(headerLine) {
490
+ if (typeof headerLine !== 'string')
491
+ return false;
492
+ return /Clud Bug review — critical findings/.test(headerLine);
493
+ }
494
+ // Classify a Per-skill scan outcome line into the check-run conclusion the
495
+ // composite action will emit for that skill. Source of truth for the BB.3
496
+ // gate decision — the v0.5.10 composite shells out to node + this helper
497
+ // rather than parsing in bash, so the gate has unit-test coverage and the
498
+ // v0.6 App can reuse the same classification when it routes its own
499
+ // parallel calls.
500
+ //
501
+ // Contract:
502
+ // - `null` (skill not mentioned in the review) → 'failure'
503
+ // - line contains "0 findings" / "0 finding" as a STANDALONE TOKEN → 'success'
504
+ // - line contains "n/a" as a standalone token → 'success'
505
+ // - empty line (bot emitted "- [name]:" with no outcome) → 'failure'
506
+ // - otherwise (typically "N finding" / "N findings" with N>0) → 'failure'
507
+ //
508
+ // Why null → failure (not neutral): GitHub's branch-protection contract
509
+ // treats `conclusion: neutral` as PASSING for required status checks —
510
+ // only `failure`, `cancelled`, `timed_out`, `action_required` block merge.
511
+ // A strictSkills entry that doesn't appear in the per-skill scan block
512
+ // (typo, prompt regression, mid-review race) emitting `neutral` would
513
+ // silently pass branch protection, defeating the gate the user opted into.
514
+ // Failing loud is the right posture for a gate that ships with "strict" in
515
+ // its name; the cost is a re-run if a bot mid-review somehow drops a skill.
516
+ //
517
+ // The "0 findings" match is anchored on a leading word boundary so "10
518
+ // findings" / "100 findings" don't substring-match to success — the exact
519
+ // bug that v0.5.10's first revision had, caught by clud-bug-review + claude-
520
+ // review on PR #57.
521
+ export function classifyPerSkillOutcome(outcomeLine) {
522
+ if (outcomeLine == null)
523
+ return 'failure';
524
+ const text = String(outcomeLine);
525
+ // HARD-FAILURE OVERRIDE: any positive finding count → failure.
526
+ // `\b[1-9]\d*\s+(?:\w+\s+){0,3}finding` matches "1 finding",
527
+ // "2 critical findings", "10 findings", "100 minor findings below".
528
+ // Up to 3 intermediate words allow modifiers like "critical"/"minor".
529
+ // The `\b[1-9]` anchor (vs `[0-9]`) excludes `0` — so this never
530
+ // shadows the "0 findings" success case below. Also: "10 findings"
531
+ // is correctly classified failure because `\b1` matches at the
532
+ // word boundary before `1`, then `\d*` consumes `0`.
533
+ if (/\b[1-9]\d*\s+(?:\w+\s+){0,3}finding/i.test(text))
534
+ return 'failure';
535
+ // SUCCESS PATTERNS — broadened in v0.5.16 to handle natural bot
536
+ // phrasings without enumerating every synonym. The bot's review
537
+ // prompt encourages canonical "0 findings" wording but variance
538
+ // is real (e.g. "no findings to anchor", "0 critical findings").
539
+ // (1) Zero-finding count, optionally with up to 3 modifier words.
540
+ // Matches: "0 findings", "0 critical findings", "no findings",
541
+ // "zero performance findings", "no findings to anchor".
542
+ // Does NOT match: "10 findings" (handled above), "all findings",
543
+ // "applied to all findings".
544
+ if (/\b(?:0|no|zero)\s+(?:\S+\s+){0,3}finding/i.test(text))
545
+ return 'success';
546
+ // (2) n/a — word-bounded. Matches "n/a.", "n/a — no surface here",
547
+ // " n/a " surrounded by anything. Excludes "diagnostics" etc.
548
+ if (/\bn\/a\b/i.test(text))
549
+ return 'success';
550
+ // (3) "not applicable" — explicit phrase.
551
+ if (/\bnot\s+applicable\b/i.test(text))
552
+ return 'success';
553
+ // (4) Checkmark (✓) as the bot's universal clean signal. Anchored on
554
+ // whitespace/punctuation on both sides so accidental ✓ inside
555
+ // other content (e.g. quoting a checkbox list) doesn't trip it.
556
+ // Matches "applied to all findings. ✓ all anchored." but not
557
+ // "see ✓item-marker in unicode" or similar.
558
+ if (/(?:^|\s)✓(?:\s|$|[.,;:])/.test(text))
559
+ return 'success';
560
+ // Skill-specific vocabulary like "0 pattern fights" (no `finding` word)
561
+ // falls through to failure here. Skill authors should prefer the
562
+ // canonical "0 findings" wording in their per-skill scan lines so
563
+ // this classifier doesn't need per-skill vocabulary knowledge.
564
+ return 'failure';
565
+ }
566
+ /**
567
+ * Minimal YAML frontmatter parser. Handles:
568
+ * - `key: value` (scalar)
569
+ * - `key: [a, b, c]` (inline list)
570
+ * - `key:\n subkey: value` (one-level nesting — applies_to)
571
+ *
572
+ * Throws on malformed input; the App's `loadSkillsFromBaseRef` catches
573
+ * and skips the skill (a bad SKILL.md doesn't take down the whole review).
574
+ *
575
+ * Deliberately NOT a general-purpose YAML parser — SPEC §1.10 fixes the
576
+ * frontmatter schema to a handful of fields. If the schema grows beyond
577
+ * what this hand-rolled parser handles, swap to `js-yaml` — the boundary
578
+ * is this function.
579
+ */
580
+ export function parseFrontmatter(raw) {
581
+ // Frontmatter MUST be the literal `---\n...\n---\n` at the file head.
582
+ // We tolerate a leading BOM and trailing whitespace.
583
+ const trimmed = raw.replace(/^/, '');
584
+ const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
585
+ if (!match) {
586
+ throw new Error('missing YAML frontmatter');
587
+ }
588
+ const body = match[1] ?? '';
589
+ const lines = body.split(/\r?\n/);
590
+ const out = {};
591
+ let currentNested = null;
592
+ for (const line of lines) {
593
+ if (!line.trim())
594
+ continue;
595
+ // Comment line; YAML allows '#' as a comment marker at column 0.
596
+ if (line.trim().startsWith('#'))
597
+ continue;
598
+ // Nested-block lines start with whitespace (e.g. " paths: [...]").
599
+ const isIndented = /^\s/.test(line);
600
+ if (isIndented && currentNested) {
601
+ const nested = out[currentNested];
602
+ if (!nested)
603
+ continue;
604
+ const trimmedLine = line.trim();
605
+ const colon = trimmedLine.indexOf(':');
606
+ if (colon === -1)
607
+ continue;
608
+ const key = trimmedLine.slice(0, colon).trim();
609
+ const value = trimmedLine.slice(colon + 1).trim();
610
+ nested[key] = parseScalarOrList(value);
611
+ continue;
612
+ }
613
+ // Top-level key.
614
+ currentNested = null;
615
+ const colon = line.indexOf(':');
616
+ if (colon === -1) {
617
+ throw new Error(`malformed frontmatter line: ${line}`);
618
+ }
619
+ const key = line.slice(0, colon).trim();
620
+ const value = line.slice(colon + 1).trim();
621
+ if (value === '') {
622
+ // Block — next indented lines populate this key as a nested map.
623
+ out[key] = {};
624
+ currentNested = key;
625
+ continue;
626
+ }
627
+ out[key] = parseScalarOrList(value);
628
+ }
629
+ // Validate the SPEC-required fields are present and apply documented
630
+ // defaults for optional ones.
631
+ const name = String(out['name'] ?? '').trim();
632
+ if (!name)
633
+ throw new Error('frontmatter.name is required');
634
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
635
+ throw new Error(`frontmatter.name is not a valid kebab-case slug: ${name}`);
636
+ }
637
+ const description = String(out['description'] ?? '').trim();
638
+ if (!description)
639
+ throw new Error('frontmatter.description is required');
640
+ const source = String(out['source'] ?? 'manual').trim();
641
+ const reviewMode = out['review_mode'] === 'dedicated' ? 'dedicated' : 'shared';
642
+ // v0.7.0-rc.6 — surface SPEC §1.10.1 v0.5.0+ kind + voice_scope on
643
+ // the parsed frontmatter. v0.7.0-rc.5 silently dropped these fields
644
+ // (the Wave 4d reviewer-flagged silent-drop). Validation is lenient:
645
+ // unknown values are surfaced as undefined so downstream code sees a
646
+ // clean type rather than a malformed value. SPEC enforcement (e.g.,
647
+ // "voice_scope REQUIRED when kind: voice") is the caller's job.
648
+ const kindRaw = out['kind'];
649
+ const kind = kindRaw === 'voice' ? 'voice' : kindRaw === 'rule' ? 'rule' : undefined;
650
+ const voiceScopeRaw = out['voice_scope'];
651
+ const voiceScope = voiceScopeRaw === 'personal' ||
652
+ voiceScopeRaw === 'team' ||
653
+ voiceScopeRaw === 'org' ||
654
+ voiceScopeRaw === 'community'
655
+ ? voiceScopeRaw
656
+ : undefined;
657
+ const appliesToRaw = out['applies_to'];
658
+ let appliesTo;
659
+ if (appliesToRaw) {
660
+ const paths = Array.isArray(appliesToRaw.paths)
661
+ ? appliesToRaw.paths.map(String)
662
+ : undefined;
663
+ const extensions = Array.isArray(appliesToRaw.extensions)
664
+ ? appliesToRaw.extensions.map(String)
665
+ : undefined;
666
+ // v0.7.0-rc.6 — SPEC §1.10.1 v0.5.1+ author filter. Single string
667
+ // only; reject lists + falsy values to keep the filter contract
668
+ // unambiguous (a future SPEC could broaden to lists if customer
669
+ // demand argues for it).
670
+ const authorRaw = appliesToRaw.author;
671
+ const author = typeof authorRaw === 'string' && authorRaw.trim().length > 0
672
+ ? authorRaw.trim()
673
+ : undefined;
674
+ // Reviewer-flagged Important on PR #179: an empty `applies_to:`
675
+ // block (no sub-keys) was emitting `applies_to: {}` on the
676
+ // returned frontmatter, which leaked into the prompt builder as
677
+ // JSON noise + semantically diverged from readAppliesTo's null
678
+ // return for the same input. Only construct appliesTo when at
679
+ // least one sub-field is present.
680
+ if (paths !== undefined || extensions !== undefined || author !== undefined) {
681
+ appliesTo = {
682
+ ...(paths !== undefined ? { paths } : {}),
683
+ ...(extensions !== undefined ? { extensions } : {}),
684
+ ...(author !== undefined ? { author } : {}),
685
+ };
686
+ }
687
+ }
688
+ return {
689
+ name,
690
+ description,
691
+ source,
692
+ review_mode: reviewMode,
693
+ ...(kind !== undefined ? { kind } : {}),
694
+ ...(voiceScope !== undefined ? { voice_scope: voiceScope } : {}),
695
+ ...(appliesTo !== undefined ? { applies_to: appliesTo } : {}),
696
+ };
697
+ }
698
+ function parseScalarOrList(value) {
699
+ if (value.startsWith('[') && value.endsWith(']')) {
700
+ // Inline list: [a, "b", 'c'] → ['a', 'b', 'c']
701
+ const inner = value.slice(1, -1).trim();
702
+ if (!inner)
703
+ return [];
704
+ return inner
705
+ .split(',')
706
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
707
+ .filter(Boolean);
708
+ }
709
+ // Strip surrounding quotes; YAML allows both ' and ".
710
+ return value.replace(/^['"]|['"]$/g, '');
711
+ }
712
+ /**
713
+ * Strip the leading `---\n...\n---\n` from a SKILL.md file. Returns the
714
+ * markdown body (the part the LLM actually reads).
715
+ *
716
+ * Ported from `clud-bug-app/lib/skills-loader.ts:269` so callers don't have
717
+ * to re-implement the regex.
718
+ */
719
+ export function stripFrontmatter(raw) {
720
+ const trimmed = raw.replace(/^/, '');
721
+ const match = trimmed.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
722
+ if (!match)
723
+ return trimmed;
724
+ return trimmed.slice(match[0].length);
725
+ }
726
+ //# sourceMappingURL=skills.js.map