clud-bug 0.2.0 โ†’ 0.4.1

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/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Clud Bug ๐Ÿ›
2
- ### Crawling all over your code
2
+ ### A field guide to specimens crawling your code
3
3
 
4
- > **[cludbug.dev](https://cludbug.dev)** ยท A field guide.
4
+ > **[cludbug.dev](https://cludbug.dev)** ยท live field journal.
5
5
 
6
- Claude PR review for any GitHub repo, with **project-aware skills** auto-discovered from [skills.sh](https://skills.sh) and a baseline of review-discipline skills bundled in.
6
+ Clud Bug is a Claude PR-review naturalist for your GitHub repo. It pins **project-aware skills** auto-discovered from [skills.sh](https://skills.sh) and ships a baseline kit of review discipline so reviews stay focused on what matters: bugs, security, performance, and missing tests.
7
7
 
8
- One command to install. The first PR you open afterwards gets a real review comment back.
8
+ One command to install. The first PR you open afterwards gets a real review comment back โ€” typically within two minutes.
9
9
 
10
10
  ## Quickstart
11
11
 
@@ -24,14 +24,16 @@ Open a PR. A review comment should appear within ~2 minutes.
24
24
 
25
25
  ## What `clud-bug init` does
26
26
 
27
- 1. **Detects** your stack (Node, Python, Go, Rust, Ruby โ€” reads `package.json`, `pyproject.toml`, `go.mod`, etc.).
28
- 2. **Queries [skills.sh](https://skills.sh)** for review skills relevant to your dependencies (e.g. a Next.js project gets Next.js review skills).
29
- 3. **Installs three baseline skills** that enforce review discipline regardless of stack:
27
+ The naturalist arrives at your repo, surveys the habitat, and assembles a field kit:
28
+
29
+ 1. **Surveys habitat.** Reads `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc., to learn what your stack is.
30
+ 2. **Consults [skills.sh](https://skills.sh).** Pulls review skills relevant to your dependencies (e.g. a Next.js project gets Next.js review specimens).
31
+ 3. **Pins three baseline specimens** that enforce review discipline regardless of stack:
30
32
  - `critical-issues-only` โ€” flag bugs, security, perf only. Skip nits.
31
33
  - `evidence-based-review` โ€” every claim must quote the line being criticized.
32
34
  - `respect-existing-conventions` โ€” don't suggest fights with the codebase's patterns.
33
- 4. **Writes** the chosen skills to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
34
- 5. **Generates** `.github/workflows/clud-bug-review.yml` with the project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually work.
35
+ 4. **Writes** the chosen specimens to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
36
+ 5. **Drafts the field kit** at `.github/workflows/clud-bug-review.yml` with your project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually post.
35
37
 
36
38
  ## CLI options
37
39
 
@@ -44,6 +46,70 @@ npx clud-bug init [options]
44
46
  --help,-h Show help.
45
47
  ```
46
48
 
49
+ ## Staying up to date
50
+
51
+ `clud-bug init` ships a third workflow: `clud-bug-self-update.yml`. Once a week (Mondays 12:00 UTC), it checks npm for a newer `clud-bug` version. If one exists, it runs `clud-bug update` and opens a PR titled `๐Ÿ› Clud Bug self-update: vX.Y.Z โ†’ vA.B.C`. Custom and skills.sh-installed specimens are never touched โ€” only baseline specimens and the workflow templates get refreshed.
52
+
53
+ You can also run the update manually:
54
+
55
+ ```bash
56
+ clud-bug update
57
+ ```
58
+
59
+ To pin a specific version and stop receiving update PRs, add `pinVersion` to `.claude/skills/.clud-bug.json`:
60
+
61
+ ```json
62
+ { "pinVersion": "0.3.0", ... }
63
+ ```
64
+
65
+ ## Auditing the whole repo
66
+
67
+ PR reviews catch issues entering. Audits catch issues that already crossed the line.
68
+
69
+ ```bash
70
+ clud-bug audit # walk every tracked file
71
+ clud-bug audit --changed-in 7d # only files touched in the last 7 days
72
+ clud-bug audit --since 2026-01-01 # only files touched since a date
73
+ clud-bug audit --scope 'src/**/*.ts' # narrow by glob (repeatable)
74
+ ```
75
+
76
+ The CLI prepares an `audits/YYYY-MM-DD.md` stub. For findings, `clud-bug init` also installed `.github/workflows/clud-bug-audit.yml` โ€” go to **Actions โ†’ Clud Bug ๐Ÿ› Audit โ†’ Run workflow**. Clud Bug walks the manifest, appends findings to the same file, opens a PR you can read, act on, then merge or close.
77
+
78
+ The workflow ships with `workflow_dispatch` only (manual). The cron is in the file, commented โ€” uncomment for weekly audits.
79
+
80
+ ## Strict mode (default since v0.4.0)
81
+
82
+ Clud Bug runs in **strict mode by default** for new installs. The workflow check fails when Clud Bug flags a critical issue (bug, security, performance, missing test coverage) โ€” green means clean, red means the bot found something to address. Add `clud-bug-review` to your branch protection's required status checks and merging is blocked until findings are addressed.
83
+
84
+ `clud-bug init` writes `{ "strictMode": true }` to `.claude/skills/.clud-bug.json`. To opt out into advisory mode (the bot still reviews; the check stays green regardless of findings), set `strictMode: false`:
85
+
86
+ ```json
87
+ { "strictMode": false, ... }
88
+ ```
89
+
90
+ The toggle takes effect on PRs opened *after* the new value lands on the base branch (the gate reads the manifest from the base ref so PRs can't disable strict on themselves).
91
+
92
+ **Existing installs upgrading to v0.4.0:** the new default only fires on fresh installs (manifests that have never been touched by `init` or `update`). Existing repos โ€” including v0.3.x advisory installs that never set `strictMode` โ€” keep their prior behavior on re-init. To enable strict mode in an existing repo, add `"strictMode": true` to `.claude/skills/.clud-bug.json` manually.
93
+
94
+ ## Bot-authored PRs (Dependabot, Renovate, fork PRs)
95
+
96
+ GitHub deliberately doesn't pass repository secrets to workflows triggered by bot-authored PRs (`dependabot[bot]`, `renovate[bot]`) or PRs from forks. The action can't authenticate against Anthropic, so Clud Bug can't review.
97
+
98
+ Rather than failing red (wrong signal), the workflow detects this case, posts a one-line advisory comment to the PR explaining the skip, and exits 0. The check stays green; the comment makes the skip visible. Reviews are your responsibility on those PRs.
99
+
100
+ To enable real reviews on Dependabot PRs, [add ANTHROPIC_API_KEY to Dependabot's secret scope](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/configuring-access-to-private-registries-for-dependabot).
101
+
102
+ ## How skills shape reviews
103
+
104
+ Skills aren't background reading material for the bot โ€” they're rules with authority. The workflow prompt now requires Clud Bug to:
105
+
106
+ 1. **Cite the skill by name** when applying its guidance: e.g. `[evidence-based-review]: this claim isn't anchored to a line`.
107
+ 2. **End every review with a footer** listing which skills shaped the findings: `Skills referenced: [critical-issues-only, next-best-practices, my-team-rules]`.
108
+
109
+ The footer is your audit trail. If a review's footer is `[none]`, either the bot found nothing relevant in your installed skills (and should explain why), or your skill set isn't covering the kinds of changes you actually ship โ€” a signal to add or write new specimens.
110
+
111
+ `clud-bug init` warns when it would install only baseline specimens. Pair with at least one project-aware skill from skills.sh, or your own โ€” that's where the wedge over stock Claude review comes from.
112
+
47
113
  ## Managing skills
48
114
 
49
115
  After `init`, four commands let you evolve the skill set without re-running the whole setup:
@@ -92,6 +158,19 @@ If you want clud-bug to review fork PRs too, you have two options:
92
158
 
93
159
  clud-bug's generated workflow uses `pull_request` by default. If you understand the trade-offs, edit the trigger yourself.
94
160
 
161
+ ## When you edit the workflow
162
+
163
+ clud-bug uses [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action), which **refuses to run when the PR being reviewed modifies the action's own workflow file**. That's a security guard against PRs that try to neuter the reviewer or exfiltrate secrets via prompt injection. If you edit `.github/workflows/clud-bug-review.yml` (or any clud-bug workflow), expect this check to fail with a 401 โ€” `App token exchange failed: Workflow validation failed`. That's the documented behavior, not a bug. Merge the workflow change in isolation, and subsequent PRs work normally.
164
+
165
+ To make this easier, `clud-bug edit-workflow` packages the workflow change into a clean PR for you:
166
+
167
+ ```bash
168
+ # Edit .github/workflows/clud-bug-*.yml as you like, then:
169
+ clud-bug edit-workflow
170
+ ```
171
+
172
+ The command refuses to run if your working tree has any non-workflow changes โ€” keeping the PR scoped to just the workflow edit.
173
+
95
174
  ## Verifying it works
96
175
 
97
176
  After install:
package/bin/clud-bug.js CHANGED
@@ -12,40 +12,61 @@ import {
12
12
  SkillsClient, rankAndCap, writeSkills, writeSkill, loadBaseline,
13
13
  readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
14
14
  } from '../lib/skills.js';
15
+ import { computeAuditFileSet, renderAuditHeader } from '../lib/audit.js';
16
+ import { runUpdate } from '../lib/update.js';
17
+ import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from '../lib/edit-workflow.js';
15
18
 
16
19
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
20
  const TEMPLATES = join(PKG_ROOT, 'templates');
18
21
  const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
19
22
 
20
23
  function parseArgs(argv) {
21
- const args = { _: [], offline: false, acceptAll: false, commit: false, help: false, version: false };
22
- for (const a of argv) {
24
+ const args = {
25
+ _: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
26
+ since: null, changedIn: null, scopes: [], out: null,
27
+ };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
23
30
  if (a === '--offline') args.offline = true;
24
31
  else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
25
32
  else if (a === '--commit') args.commit = true;
26
33
  else if (a === '--help' || a === '-h') args.help = true;
27
34
  else if (a === '--version' || a === '-v') args.version = true;
35
+ else if (a === '--since') args.since = argv[++i];
36
+ else if (a === '--changed-in') args.changedIn = argv[++i];
37
+ else if (a === '--scope') args.scopes.push(argv[++i]);
38
+ else if (a === '--out') args.out = argv[++i];
28
39
  else args._.push(a);
29
40
  }
30
41
  return args;
31
42
  }
32
43
 
33
- const HELP = `clud-bug โ€” Claude PR review with project-aware skills
44
+ const HELP = `clud-bug ๐Ÿ› โ€” a field guide to specimens crawling your code
34
45
 
35
46
  Usage:
36
47
  npx clud-bug <command> [options]
37
48
 
38
49
  Commands:
39
- init First-time setup: detect repo, install skills, write workflow.
40
- list Show currently installed skills (baseline / remote / custom).
41
- add <source/name> Install one skill from skills.sh (e.g. vercel-labs/skills/next-best-practices).
42
- remove <slug> Remove an installed skill (refuses if it's a custom skill).
43
- refresh Re-query skills.sh and diff against installed; prompt to update.
50
+ init Open field season: survey the repo, pin baseline specimens, write the workflows.
51
+ list Show your collection (baseline / from skills.sh / custom).
52
+ add <source/name> Pin one new specimen from skills.sh (e.g. vercel-labs/skills/next-best-practices).
53
+ remove <slug> Unpin a clud-bug-managed specimen (refuses to touch your custom ones).
54
+ refresh Re-survey, diff against your collection, prompt to update.
55
+ audit Walk the whole habitat (or a recent slice) and prepare a report stub.
56
+ Use --since / --changed-in / --scope to narrow.
57
+ update Re-render workflows + refresh baseline specimens to the latest shipped
58
+ templates. Custom and skills.sh-installed specimens left alone.
59
+ edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
60
+ PR (the action refuses to review PRs that modify its own workflow).
44
61
 
45
62
  Options:
46
- --offline Skip skills.sh; only use bundled baseline skills (init/refresh).
47
- --accept-all,-y Accept recommendations without prompting.
48
- --commit git add + commit the generated files when done (init only).
63
+ --offline Skip skills.sh; pin only the bundled baseline specimens.
64
+ --accept-all,-y Accept the recommended specimens without prompting.
65
+ --commit git add + commit the generated kit when done (init only).
66
+ --since <date> Audit only files changed in commits after <date> (git date string).
67
+ --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
68
+ --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
69
+ --out <path> Where to write the audit stub. Default: audits/YYYY-MM-DD.md
49
70
  --help,-h Show this help.
50
71
  --version,-v Show version.
51
72
  `;
@@ -67,6 +88,9 @@ async function main() {
67
88
  case 'add': return runAdd(args);
68
89
  case 'remove': return runRemove(args);
69
90
  case 'refresh': return runRefresh(args);
91
+ case 'audit': return runAudit(args);
92
+ case 'update': return runUpdateCmd(args);
93
+ case 'edit-workflow': return runEditWorkflow(args);
70
94
  default:
71
95
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
72
96
  process.exit(2);
@@ -75,15 +99,15 @@ async function main() {
75
99
 
76
100
  async function runInit(args) {
77
101
  const cwd = process.cwd();
78
- log(`๐Ÿ› clud-bug init in ${cwd}`);
102
+ log(`๐Ÿ› Field season opens in ${cwd}.`);
79
103
 
80
- log(' detecting repo signals...');
104
+ log(' surveying habitat...');
81
105
  const signals = await detect(cwd);
82
106
  log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
83
107
  log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
84
108
 
85
109
  const baseline = await loadBaseline(BASELINE_DIR);
86
- log(` baseline skills: ${baseline.length}`);
110
+ log(` baseline kit: ${baseline.length} specimens`);
87
111
 
88
112
  let curated = [];
89
113
  let searched = [];
@@ -92,7 +116,7 @@ async function runInit(args) {
92
116
  } else {
93
117
  const client = new SkillsClient();
94
118
  try {
95
- log(' querying skills.sh...');
119
+ log(' consulting skills.sh...');
96
120
  [curated, searched] = await Promise.all([
97
121
  client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
98
122
  client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
@@ -105,7 +129,7 @@ async function runInit(args) {
105
129
 
106
130
  const recommended = rankAndCap(curated, searched, baseline);
107
131
  log('');
108
- log('Recommended skills:');
132
+ log('Specimens to pin:');
109
133
  for (const s of recommended) {
110
134
  const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
111
135
  log(` โ€ข ${s.name} ${tag}`);
@@ -118,12 +142,20 @@ async function runInit(args) {
118
142
  chosen = await promptForSkills(recommended);
119
143
  }
120
144
 
121
- log(' installing skills into .claude/skills/...');
145
+ log(' pinning specimens to .claude/skills/...');
122
146
  const client = new SkillsClient();
123
147
  const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
124
- log(` wrote ${written.length} skills`);
148
+ log(` pinned ${written.length} specimens`);
149
+
150
+ // Empty-skills warning: clud-bug shines when paired with project-specific
151
+ // skills. Reviews that load only the three baselines are functional but
152
+ // generic; flag this so users notice.
153
+ const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
154
+ if (remoteCount === 0) {
155
+ warn('Only baseline specimens pinned. Add project-specific skills via `clud-bug add vercel-labs/skills/<name>` or drop your own `.claude/skills/<name>/SKILL.md`.');
156
+ }
125
157
 
126
- log(' rendering workflow...');
158
+ log(' drafting field kit...');
127
159
  const tmplName = pickTemplate(signals.languages);
128
160
  const tmplPath = join(TEMPLATES, tmplName);
129
161
  const workflow = await renderFile(tmplPath, {
@@ -135,24 +167,61 @@ async function runInit(args) {
135
167
  await writeFile(workflowPath, workflow);
136
168
  log(` wrote ${rel(cwd, workflowPath)}`);
137
169
 
170
+ // Install the audit workflow alongside the per-PR review one.
171
+ // Manual-trigger by default; users opt into the cron by uncommenting.
172
+ const auditTmpl = await readFile(join(TEMPLATES, 'audit.yml.tmpl'), 'utf8');
173
+ const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
174
+ await writeFile(auditPath, auditTmpl);
175
+ log(` wrote ${rel(cwd, auditPath)}`);
176
+
177
+ // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
178
+ // a PR if a newer clud-bug version is published. Disable by deleting the
179
+ // file or pinning via .claude/skills/.clud-bug.json.
180
+ const selfUpdateTmpl = await readFile(join(TEMPLATES, 'self-update.yml.tmpl'), 'utf8');
181
+ const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
182
+ await writeFile(selfUpdatePath, selfUpdateTmpl);
183
+ log(` wrote ${rel(cwd, selfUpdatePath)}`);
184
+
185
+ // Stamp the manifest. Sets strictMode: true ONLY on fresh installs โ€”
186
+ // a manifest that's never been touched by clud-bug init/update has no
187
+ // lastUpdate field. Existing v0.3.x advisory installs (where strictMode
188
+ // was never written and so == undefined) keep their advisory behavior
189
+ // because lastUpdate IS set; the strictMode default only fires on truly
190
+ // fresh inits. Users opt out by setting strictMode: false.
191
+ const skillsDirPath = join(cwd, '.claude', 'skills');
192
+ const manifest = await readManifest(skillsDirPath);
193
+ const isFreshInstall = manifest.lastUpdate === undefined;
194
+ manifest.lastUpdateVersion = await readPkgVersion();
195
+ manifest.lastUpdate = new Date().toISOString();
196
+ if (isFreshInstall && manifest.strictMode === undefined) {
197
+ manifest.strictMode = true;
198
+ }
199
+ await writeManifest(skillsDirPath, manifest);
200
+
138
201
  if (args.commit) {
139
202
  log(' committing...');
140
- spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml'], { cwd, stdio: 'inherit' });
141
- spawnSync('git', ['commit', '-m', 'Add clud-bug Claude PR review'], { cwd, stdio: 'inherit' });
203
+ spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml', '.github/workflows/clud-bug-audit.yml', '.github/workflows/clud-bug-self-update.yml'], { cwd, stdio: 'inherit' });
204
+ spawnSync('git', ['commit', '-m', 'Add clud-bug ๐Ÿ› โ€” a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
142
205
  }
143
206
 
144
207
  log('');
145
- log('Done. Next steps:');
208
+ log('Field kit assembled. Next:');
146
209
  log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
147
210
  log(' Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret');
148
211
  if (!args.commit) {
149
- log(' 2. git add .claude .github/workflows/clud-bug-review.yml && git commit && git push');
150
- log(' 3. Open a PR โ€” Clud Bug should comment within ~2 min.');
212
+ log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
213
+ log(' 3. Open a PR โ€” the naturalist arrives within ~2 minutes.');
151
214
  } else {
152
- log(' 2. git push, then open a PR โ€” Clud Bug should comment within ~2 min.');
215
+ log(' 2. git push, then open a PR โ€” the naturalist arrives within ~2 minutes.');
153
216
  }
154
217
  log('');
155
- log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ€” they\'re auto-loaded.');
218
+ log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ€” they get pinned automatically.');
219
+ log('For a whole-repo walk: Actions tab โ†’ Clud Bug ๐Ÿ› Audit โ†’ Run workflow.');
220
+ log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
221
+ log('');
222
+ log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
223
+ log(' โ€ข Add `clud-bug-review` to your branch protection required checks for full enforcement.');
224
+ log(' โ€ข Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
156
225
  }
157
226
 
158
227
  async function promptForSkills(recommended) {
@@ -182,13 +251,13 @@ async function runList(_args) {
182
251
  const groups = await listInstalled(skillsDir);
183
252
  const total = groups.baseline.length + groups.remote.length + groups.custom.length;
184
253
  if (total === 0) {
185
- log('No skills installed yet. Run `clud-bug init` to get started.');
254
+ log('Empty collection. Run `clud-bug init` to open field season.');
186
255
  return;
187
256
  }
188
- log(`๐Ÿ› ${total} skill${total === 1 ? '' : 's'} in .claude/skills/`);
257
+ log(`๐Ÿ› ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
189
258
  if (groups.baseline.length) {
190
259
  log('');
191
- log('Baseline (always installed):');
260
+ log('Baseline (always pinned):');
192
261
  for (const s of groups.baseline) log(` โ€ข ${s.slug}`);
193
262
  }
194
263
  if (groups.remote.length) {
@@ -198,7 +267,7 @@ async function runList(_args) {
198
267
  }
199
268
  if (groups.custom.length) {
200
269
  log('');
201
- log('Custom (yours, never auto-modified):');
270
+ log('Custom (your own โ€” never auto-modified):');
202
271
  for (const s of groups.custom) {
203
272
  log(` โ€ข ${s.slug}${s.description ? ` โ€” ${s.description}` : ''}`);
204
273
  }
@@ -220,9 +289,12 @@ async function runAdd(args) {
220
289
  const client = new SkillsClient();
221
290
  const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
222
291
  const manifest = await readManifest(skillsDir);
223
- const merged = { version: 1, installed: [...manifest.installed.filter(e => e.slug !== entry.slug), entry] };
224
- await writeManifest(skillsDir, merged);
225
- log(` โœ“ installed ${entry.slug} โ†’ .claude/skills/${entry.slug}/SKILL.md`);
292
+ // Mutate in place so caller-set fields on the manifest (pinVersion,
293
+ // lastUpdate, lastUpdateVersion) survive the add. Building a fresh
294
+ // {version, installed} object would silently drop them.
295
+ manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
296
+ await writeManifest(skillsDir, manifest);
297
+ log(` โœ“ pinned ${entry.slug} โ†’ .claude/skills/${entry.slug}/SKILL.md`);
226
298
  log(' Commit + push to apply on the next PR.');
227
299
  }
228
300
 
@@ -234,7 +306,7 @@ async function runRemove(args) {
234
306
  }
235
307
  const skillsDir = join(process.cwd(), '.claude', 'skills');
236
308
  const entry = await removeSkill(skillsDir, slug);
237
- log(` โœ“ removed ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ€” will return on next init)' : ''}`);
309
+ log(` โœ“ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ€” returns on next init)' : ''}`);
238
310
  }
239
311
 
240
312
  async function runRefresh(args) {
@@ -242,11 +314,11 @@ async function runRefresh(args) {
242
314
  const skillsDir = join(cwd, '.claude', 'skills');
243
315
  const manifest = await readManifest(skillsDir);
244
316
  if (manifest.installed.length === 0) {
245
- log('No clud-bug-managed skills found. Run `clud-bug init` first.');
317
+ log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
246
318
  return;
247
319
  }
248
320
 
249
- log(' detecting repo signals...');
321
+ log(' re-surveying habitat...');
250
322
  const signals = await detect(cwd);
251
323
  log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
252
324
  log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
@@ -286,7 +358,7 @@ async function runRefresh(args) {
286
358
 
287
359
  if (diff.add.length === 0 && diff.remove.length === 0) {
288
360
  log('');
289
- log('Nothing to update. Skills are in sync with skills.sh recommendations.');
361
+ log('Collection in sync with skills.sh โ€” nothing to update.');
290
362
  return;
291
363
  }
292
364
 
@@ -307,7 +379,134 @@ async function runRefresh(args) {
307
379
  const client = new SkillsClient();
308
380
  if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
309
381
  for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
310
- log(' โœ“ skills updated. Commit + push to apply on the next PR.');
382
+ log(' โœ“ collection updated. Commit + push to apply on the next PR.');
383
+ }
384
+
385
+ async function runEditWorkflow(_args) {
386
+ const cwd = process.cwd();
387
+
388
+ // Validate: must have pending changes, all scoped to clud-bug workflow files.
389
+ let pending;
390
+ try {
391
+ pending = getPendingWorkflowEdits(cwd);
392
+ } catch (err) {
393
+ process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
394
+ process.exit(2);
395
+ }
396
+
397
+ if (pending.files.length === 0) {
398
+ log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
399
+ return;
400
+ }
401
+ if (!pending.allWorkflow) {
402
+ process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
403
+ for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
404
+ process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
405
+ process.exit(2);
406
+ }
407
+
408
+ log('๐Ÿ› Preparing an isolated PR for your workflow edit.');
409
+ const branch = makeBranchName();
410
+ log(` branch: ${branch} (rooted at origin/main)`);
411
+ for (const f of pending.files) log(` โ€ข ${f}`);
412
+
413
+ // Stash the pending workflow changes, branch from origin/main explicitly
414
+ // (NOT from HEAD โ€” if the user is on a feature branch with unrelated
415
+ // commits, those would otherwise leak into the "isolated" PR), then
416
+ // restore the changes onto the new branch and commit.
417
+ gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
418
+ try {
419
+ gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
420
+ gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
421
+ } catch (err) {
422
+ // Restore the user's stash before bubbling up.
423
+ gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
424
+ throw err;
425
+ }
426
+ const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
427
+ if (!popped.ok) {
428
+ process.stderr.write(`clud-bug edit-workflow: stash pop conflicted on origin/main โ€” your edits are still in 'git stash'. Resolve manually:\n git stash pop\n`);
429
+ process.exit(1);
430
+ }
431
+ gitCmd(cwd, ['add', ...pending.files]);
432
+ gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
433
+ gitCmd(cwd, ['push', '-u', 'origin', branch]);
434
+
435
+ log('');
436
+ log('Done. Open the PR:');
437
+ log(` gh pr create --title "Edit clud-bug workflow" --body "Workflow tweak. The clud-bug-review check on this PR will fail with a 401 (Anthropic's self-protection against PRs that modify the reviewer's own workflow); merge once and subsequent PRs work normally."`);
438
+ }
439
+
440
+ async function runUpdateCmd(_args) {
441
+ const cwd = process.cwd();
442
+ const ourVersion = await readPkgVersion();
443
+ log(`๐Ÿ› Refreshing the field kit (${ourVersion}).`);
444
+
445
+ const result = await runUpdate({
446
+ cwd,
447
+ templatesDir: TEMPLATES,
448
+ baselineDir: BASELINE_DIR,
449
+ ourVersion,
450
+ });
451
+
452
+ if (result.missing === 'init') {
453
+ log(' No clud-bug installation detected. Run `clud-bug init` first.');
454
+ return;
455
+ }
456
+
457
+ if (result.changed.length === 0) {
458
+ log(' Already current. Nothing to update.');
459
+ return;
460
+ }
461
+
462
+ log(` โœ“ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
463
+ for (const c of result.changed) log(` โ€ข ${rel(cwd, c.path)} (${c.label})`);
464
+ if (result.unchanged.length > 0) {
465
+ log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
466
+ }
467
+ log('');
468
+ log('Commit + push to apply the refreshed kit on the next PR.');
469
+ }
470
+
471
+ async function runAudit(args) {
472
+ const cwd = process.cwd();
473
+ const date = new Date().toISOString().slice(0, 10);
474
+
475
+ let scopeLabel;
476
+ if (args.since) scopeLabel = `commits since ${args.since}`;
477
+ else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
478
+ else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
479
+ else scopeLabel = 'all tracked files';
480
+
481
+ log(`๐Ÿ› Audit walk in ${cwd}.`);
482
+ log(` scope: ${scopeLabel}`);
483
+
484
+ let files;
485
+ try {
486
+ files = computeAuditFileSet({
487
+ cwd,
488
+ since: args.since,
489
+ changedIn: args.changedIn,
490
+ scopes: args.scopes,
491
+ });
492
+ } catch (err) {
493
+ process.stderr.write(`clud-bug audit: ${err.message}\n`);
494
+ process.exit(2);
495
+ }
496
+ log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
497
+
498
+ if (files.length === 0) {
499
+ log(' Nothing in scope. Try widening --scope or --changed-in.');
500
+ return;
501
+ }
502
+
503
+ const outPath = args.out || join(cwd, 'audits', `${date}.md`);
504
+ await mkdir(dirname(outPath), { recursive: true });
505
+ await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
506
+ log(` โœ“ wrote stub: ${rel(cwd, outPath)}`);
507
+ log('');
508
+ log('Stub is empty findings โ€” populated by the GitHub Action.');
509
+ log('Run locally without the workflow if you want โ€” Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
311
510
  }
312
511
 
313
512
  function rel(from, to) {
package/lib/audit.js ADDED
@@ -0,0 +1,111 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ // Convert a duration like "7d", "2w", "1mo", "3mo", "1y" to a git --since arg.
4
+ // Returns null if the input is empty/undefined; throws on malformed input.
5
+ export function durationToGitSince(input) {
6
+ if (!input) return null;
7
+ const m = String(input).trim().match(/^(\d+)\s*(d|w|mo|m|y)$/i);
8
+ if (!m) {
9
+ throw new Error(`Unrecognized duration "${input}". Examples: 7d, 2w, 1mo, 1y.`);
10
+ }
11
+ const n = Number(m[1]);
12
+ const unit = m[2].toLowerCase();
13
+ const map = { d: 'day', w: 'week', mo: 'month', m: 'month', y: 'year' };
14
+ return `${n} ${map[unit]}${n === 1 ? '' : 's'} ago`;
15
+ }
16
+
17
+ // Run a git command, return stdout lines split by \n. Throws on non-zero exit
18
+ // unless { allowFail: true }, in which case returns [].
19
+ export function gitLines(args, opts = {}) {
20
+ const r = spawnSync('git', args, { encoding: 'utf8', cwd: opts.cwd || process.cwd() });
21
+ if (r.status !== 0) {
22
+ if (opts.allowFail) return [];
23
+ throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr.trim()}`);
24
+ }
25
+ return r.stdout.split('\n').filter(Boolean);
26
+ }
27
+
28
+ // Returns the file set the audit should consider, in repo-relative paths.
29
+ // Filters: optional --since (git date), optional --changed-in (duration string),
30
+ // optional --scope globs (one or more, repeatable).
31
+ export function computeAuditFileSet({ since, changedIn, scopes = [], cwd } = {}) {
32
+ const sinceArg = since || (changedIn ? durationToGitSince(changedIn) : null);
33
+
34
+ let files;
35
+ if (sinceArg) {
36
+ // Files touched in any commit within the window.
37
+ files = [...new Set(gitLines(['log', `--since=${sinceArg}`, '--name-only', '--pretty=format:'], { cwd }))];
38
+ // --diff-filter at the log level only excludes the delete commit; a file
39
+ // that was modified (and emitted by --name-only) and *later* deleted will
40
+ // still appear here. Intersect with the current tracked-file set so the
41
+ // manifest only contains paths we can actually read.
42
+ const tracked = new Set(gitLines(['ls-files'], { cwd }));
43
+ files = files.filter((f) => tracked.has(f));
44
+ } else {
45
+ files = gitLines(['ls-files'], { cwd });
46
+ }
47
+
48
+ if (scopes.length) {
49
+ const matchers = scopes.map(globToRegex);
50
+ files = files.filter((f) => matchers.some((rx) => rx.test(f)));
51
+ }
52
+
53
+ // Skip vendor / build artifacts that bloat audits without adding signal.
54
+ const skip = /(^|\/)(node_modules|dist|build|out|\.next|\.vercel|coverage|target|__pycache__)\//;
55
+ return files.filter((f) => !skip.test(f)).sort();
56
+ }
57
+
58
+ // Minimal glob โ†’ RegExp. Supports **, *, ?. Anchors at both ends so that
59
+ // 'src/**/*.ts' matches 'src/lib/foo.ts' but not 'app/src/lib/foo.ts'.
60
+ function globToRegex(glob) {
61
+ let rx = '';
62
+ let i = 0;
63
+ while (i < glob.length) {
64
+ const ch = glob[i];
65
+ if (ch === '*' && glob[i + 1] === '*') {
66
+ // ** = any depth (including zero) of path segments
67
+ rx += '.*';
68
+ i += 2;
69
+ // consume an optional trailing slash so 'src/**/*.ts' works cleanly
70
+ if (glob[i] === '/') i++;
71
+ } else if (ch === '*') {
72
+ rx += '[^/]*';
73
+ i++;
74
+ } else if (ch === '?') {
75
+ rx += '[^/]';
76
+ i++;
77
+ } else if (/[.+^$|()\[\]{}\\]/.test(ch)) {
78
+ rx += '\\' + ch;
79
+ i++;
80
+ } else {
81
+ rx += ch;
82
+ i++;
83
+ }
84
+ }
85
+ return new RegExp(`^${rx}$`);
86
+ }
87
+
88
+ // Render the audit report's initial markdown body. The Action's Claude run
89
+ // will append findings under a "## Findings" section after this header.
90
+ export function renderAuditHeader({ date, scopeLabel, files }) {
91
+ const head = `# ๐Ÿ› Clud Bug audit โ€” ${date}
92
+
93
+ A scheduled walk through the habitat. Scope: ${scopeLabel}.
94
+ Files surveyed: **${files.length}**.
95
+
96
+ <details>
97
+ <summary>File manifest (${files.length})</summary>
98
+
99
+ \`\`\`
100
+ ${files.join('\n')}
101
+ \`\`\`
102
+
103
+ </details>
104
+
105
+ ---
106
+
107
+ ## Findings
108
+
109
+ `;
110
+ return head;
111
+ }