clud-bug 0.6.20 → 0.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/prompts.js CHANGED
@@ -148,7 +148,17 @@ Each SKILL.md frontmatter (first \`---\`-delimited block) has a
148
148
  API-contract, test-discipline). Each gets its own H3 section.
149
149
  - Missing → treat as \`shared\`.
150
150
 
151
- Read each capped: \`head -c "$MAX_SKILL_BYTES" .claude/skills/*/SKILL.md\`
151
+ Skill applies_to (v0.6.21 / 0.0.K):
152
+ Frontmatter may also have \`applies_to:\` with \`paths:\` (glob list)
153
+ and/or \`extensions:\` (extension list). Scan each skill's frontmatter
154
+ first (cheap — just the \`---\` block). If applies_to is present and
155
+ the PR's changed files (from \`gh pr diff --name-only\`) match NONE
156
+ of the declared paths or extensions, SKIP that skill's body — don't
157
+ read it, don't reference it. Skills without applies_to load
158
+ unconditionally (back-compat). Net effect: a UI-scoped skill stays
159
+ unread on a backend-only PR.
160
+
161
+ Read each applicable body capped: \`head -c "$MAX_SKILL_BYTES" .claude/skills/<name>/SKILL.md\`
152
162
 
153
163
  At review end, append a single-line footer:
154
164
  Skills referenced: [skill-name-1, skill-name-2, ...]
package/lib/skills.js CHANGED
@@ -383,6 +383,113 @@ export function readReviewMode(skillContent) {
383
383
  return value === 'dedicated' ? 'dedicated' : 'shared';
384
384
  }
385
385
 
386
+ // 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
387
+ //
388
+ // Schema:
389
+ // applies_to:
390
+ // paths:
391
+ // - "src/ui/**"
392
+ // - "lib/components/**"
393
+ // extensions: [".tsx", ".jsx"]
394
+ //
395
+ // Returns `{paths: string[], extensions: string[]}` if the field is
396
+ // present (either sub-list optional, both default to empty array), or
397
+ // `null` if absent. Skills without applies_to are scope-universal —
398
+ // the caller should treat null as "load unconditionally."
399
+ //
400
+ // Hand-rolled YAML parser scoped to this exact shape. The frontmatter
401
+ // is otherwise opaque (review_mode is parsed elsewhere with a similar
402
+ // single-key regex), so pulling in a YAML dep would be overkill.
403
+ export function readAppliesTo(skillContent) {
404
+ if (typeof skillContent !== 'string') return null;
405
+ const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
406
+ if (!fm) return null;
407
+ const block = fm[1];
408
+ // Anchor on `applies_to:` at start of line (the body of a SKILL.md
409
+ // could mention the term in prose; only the frontmatter key fires).
410
+ const head = block.match(/^applies_to:\s*$/m);
411
+ if (!head) return null;
412
+ // Slice from after the `applies_to:` line; the block ends at the
413
+ // next top-level key (a line starting with a word character + `:`)
414
+ // OR end-of-block.
415
+ const startIdx = head.index + head[0].length;
416
+ const rest = block.slice(startIdx);
417
+ const stop = rest.search(/^\w[\w-]*:/m);
418
+ const scoped = stop === -1 ? rest : rest.slice(0, stop);
419
+ const paths = parseYamlList(scoped, 'paths');
420
+ const extensions = parseYamlList(scoped, 'extensions');
421
+ if (paths.length === 0 && extensions.length === 0) return null;
422
+ return { paths, extensions };
423
+ }
424
+
425
+ // Parse a YAML list under `<key>:`, handling both the inline-array
426
+ // form (`extensions: [".tsx", ".jsx"]`) and the block form
427
+ // (`paths:` followed by ` - "src/ui/**"` lines).
428
+ function parseYamlList(block, key) {
429
+ const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
430
+ if (inline) {
431
+ return inline[1]
432
+ .split(',')
433
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
434
+ .filter(Boolean);
435
+ }
436
+ const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
437
+ const head = block.match(headerRe);
438
+ if (!head) return [];
439
+ const after = block.slice(head.index + head[0].length);
440
+ const items = [];
441
+ for (const line of after.split('\n')) {
442
+ const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
443
+ if (item) {
444
+ items.push(item[1].replace(/^["']|["']$/g, ''));
445
+ continue;
446
+ }
447
+ // Anything that isn't a list item (or blank) ends the list.
448
+ if (line.trim() !== '' && !item) break;
449
+ }
450
+ return items;
451
+ }
452
+
453
+ // 0.0.K: does `prPaths` contain at least one file matching the skill's
454
+ // applies_to? Skills without applies_to ALWAYS apply (back-compat).
455
+ //
456
+ // `prPaths` is the list of changed files in the PR (e.g. from
457
+ // `gh pr diff --name-only`). Match semantics:
458
+ // - paths: any glob in `paths` matches any of `prPaths`
459
+ // - extensions: any extension in `extensions` matches any of `prPaths`
460
+ // - paths OR extensions (NOT AND) — a single hit is enough
461
+ //
462
+ // Skill `paths` use the minimal glob set logmind already uses
463
+ // (`*` matches non-slash, `**` matches across slashes, `?` single
464
+ // char). Anything fancier would need a real glob lib.
465
+ export function appliesToPr(skillContent, prPaths) {
466
+ const rule = readAppliesTo(skillContent);
467
+ if (rule === null) return true; // back-compat: no rule → applies
468
+ if (!Array.isArray(prPaths)) return true; // be permissive on bad input
469
+ for (const path of prPaths) {
470
+ if (typeof path !== 'string') continue;
471
+ for (const ext of rule.extensions) {
472
+ if (path.endsWith(ext)) return true;
473
+ }
474
+ for (const glob of rule.paths) {
475
+ if (globMatch(glob, path)) return true;
476
+ }
477
+ }
478
+ return false;
479
+ }
480
+
481
+ // Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
482
+ // everything else escaped. Anchored full-string match.
483
+ function globMatch(glob, path) {
484
+ const escaped = glob
485
+ .replace(/([.+^${}()|[\]\\])/g, '\\$1')
486
+ .replace(/\*\*/g, '__DOUBLESTAR__')
487
+ .replace(/\*/g, '[^/]*')
488
+ .replace(/__DOUBLESTAR__/g, '.*')
489
+ .replace(/\?/g, '.');
490
+ return new RegExp(`^${escaped}$`).test(path);
491
+ }
492
+
386
493
  // Partition a set of loaded skills into {shared, dedicated} buckets per
387
494
  // each skill's review_mode frontmatter. Expects skills with a `content`
388
495
  // field (SKILL.md text). Skills without content default to `shared`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.6.20",
3
+ "version": "0.6.21",
4
4
  "description": "Skill-driven Claude PR review. Ship a brand-voice skill, get brand reviews. Each finding cites the skill that motivated it. CLI installs the workflow + a baseline kit; add more from skills.sh.",
5
5
  "homepage": "https://cludbug.dev",
6
6
  "bugs": "https://github.com/thrillmade/clud-bug/issues",
@@ -156,6 +156,6 @@ jobs:
156
156
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
157
157
  - name: Strict mode — fail check on critical findings
158
158
  if: success()
159
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
159
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.21
160
160
  with:
161
161
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -156,6 +156,6 @@ jobs:
156
156
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
157
157
  - name: Strict mode — fail check on critical findings
158
158
  if: success()
159
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
159
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.21
160
160
  with:
161
161
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -247,6 +247,6 @@ jobs:
247
247
  # Letting the action's own failure fail the check is louder and right.
248
248
  - name: Strict mode — fail check on critical findings
249
249
  if: success()
250
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.20
250
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.21
251
251
  with:
252
252
  github-token: ${{ secrets.GITHUB_TOKEN }}