clud-bug 0.2.0 โ†’ 0.5.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.
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,21 @@ 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
+ const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
111
+ const sourceLabel = baseline.length === 0
112
+ ? ''
113
+ : fromAgentSkills === baseline.length ? ' (from thrillmot/agent-skills)'
114
+ : fromAgentSkills === 0 ? ' (bundled fallback)'
115
+ : ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
116
+ log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
87
117
 
88
118
  let curated = [];
89
119
  let searched = [];
@@ -92,7 +122,7 @@ async function runInit(args) {
92
122
  } else {
93
123
  const client = new SkillsClient();
94
124
  try {
95
- log(' querying skills.sh...');
125
+ log(' consulting skills.sh...');
96
126
  [curated, searched] = await Promise.all([
97
127
  client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
98
128
  client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
@@ -105,7 +135,7 @@ async function runInit(args) {
105
135
 
106
136
  const recommended = rankAndCap(curated, searched, baseline);
107
137
  log('');
108
- log('Recommended skills:');
138
+ log('Specimens to pin:');
109
139
  for (const s of recommended) {
110
140
  const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
111
141
  log(` โ€ข ${s.name} ${tag}`);
@@ -118,12 +148,20 @@ async function runInit(args) {
118
148
  chosen = await promptForSkills(recommended);
119
149
  }
120
150
 
121
- log(' installing skills into .claude/skills/...');
151
+ log(' pinning specimens to .claude/skills/...');
122
152
  const client = new SkillsClient();
123
153
  const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
124
- log(` wrote ${written.length} skills`);
154
+ log(` pinned ${written.length} specimens`);
155
+
156
+ // Empty-skills warning: clud-bug shines when paired with project-specific
157
+ // skills. Reviews that load only the three baselines are functional but
158
+ // generic; flag this so users notice.
159
+ const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
160
+ if (remoteCount === 0) {
161
+ 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`.');
162
+ }
125
163
 
126
- log(' rendering workflow...');
164
+ log(' drafting field kit...');
127
165
  const tmplName = pickTemplate(signals.languages);
128
166
  const tmplPath = join(TEMPLATES, tmplName);
129
167
  const workflow = await renderFile(tmplPath, {
@@ -135,24 +173,61 @@ async function runInit(args) {
135
173
  await writeFile(workflowPath, workflow);
136
174
  log(` wrote ${rel(cwd, workflowPath)}`);
137
175
 
176
+ // Install the audit workflow alongside the per-PR review one.
177
+ // Manual-trigger by default; users opt into the cron by uncommenting.
178
+ const auditTmpl = await readFile(join(TEMPLATES, 'audit.yml.tmpl'), 'utf8');
179
+ const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
180
+ await writeFile(auditPath, auditTmpl);
181
+ log(` wrote ${rel(cwd, auditPath)}`);
182
+
183
+ // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
184
+ // a PR if a newer clud-bug version is published. Disable by deleting the
185
+ // file or pinning via .claude/skills/.clud-bug.json.
186
+ const selfUpdateTmpl = await readFile(join(TEMPLATES, 'self-update.yml.tmpl'), 'utf8');
187
+ const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
188
+ await writeFile(selfUpdatePath, selfUpdateTmpl);
189
+ log(` wrote ${rel(cwd, selfUpdatePath)}`);
190
+
191
+ // Stamp the manifest. Sets strictMode: true ONLY on fresh installs โ€”
192
+ // a manifest that's never been touched by clud-bug init/update has no
193
+ // lastUpdate field. Existing v0.3.x advisory installs (where strictMode
194
+ // was never written and so == undefined) keep their advisory behavior
195
+ // because lastUpdate IS set; the strictMode default only fires on truly
196
+ // fresh inits. Users opt out by setting strictMode: false.
197
+ const skillsDirPath = join(cwd, '.claude', 'skills');
198
+ const manifest = await readManifest(skillsDirPath);
199
+ const isFreshInstall = manifest.lastUpdate === undefined;
200
+ manifest.lastUpdateVersion = await readPkgVersion();
201
+ manifest.lastUpdate = new Date().toISOString();
202
+ if (isFreshInstall && manifest.strictMode === undefined) {
203
+ manifest.strictMode = true;
204
+ }
205
+ await writeManifest(skillsDirPath, manifest);
206
+
138
207
  if (args.commit) {
139
208
  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' });
209
+ 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' });
210
+ spawnSync('git', ['commit', '-m', 'Add clud-bug ๐Ÿ› โ€” a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
142
211
  }
143
212
 
144
213
  log('');
145
- log('Done. Next steps:');
214
+ log('Field kit assembled. Next:');
146
215
  log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
147
216
  log(' Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret');
148
217
  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.');
218
+ log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
219
+ log(' 3. Open a PR โ€” the naturalist arrives within ~2 minutes.');
151
220
  } else {
152
- log(' 2. git push, then open a PR โ€” Clud Bug should comment within ~2 min.');
221
+ log(' 2. git push, then open a PR โ€” the naturalist arrives within ~2 minutes.');
153
222
  }
154
223
  log('');
155
- log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ€” they\'re auto-loaded.');
224
+ log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ€” they get pinned automatically.');
225
+ log('For a whole-repo walk: Actions tab โ†’ Clud Bug ๐Ÿ› Audit โ†’ Run workflow.');
226
+ log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
227
+ log('');
228
+ log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
229
+ log(' โ€ข Add `clud-bug-review` to your branch protection required checks for full enforcement.');
230
+ log(' โ€ข Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
156
231
  }
157
232
 
158
233
  async function promptForSkills(recommended) {
@@ -182,13 +257,13 @@ async function runList(_args) {
182
257
  const groups = await listInstalled(skillsDir);
183
258
  const total = groups.baseline.length + groups.remote.length + groups.custom.length;
184
259
  if (total === 0) {
185
- log('No skills installed yet. Run `clud-bug init` to get started.');
260
+ log('Empty collection. Run `clud-bug init` to open field season.');
186
261
  return;
187
262
  }
188
- log(`๐Ÿ› ${total} skill${total === 1 ? '' : 's'} in .claude/skills/`);
263
+ log(`๐Ÿ› ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
189
264
  if (groups.baseline.length) {
190
265
  log('');
191
- log('Baseline (always installed):');
266
+ log('Baseline (always pinned):');
192
267
  for (const s of groups.baseline) log(` โ€ข ${s.slug}`);
193
268
  }
194
269
  if (groups.remote.length) {
@@ -198,7 +273,7 @@ async function runList(_args) {
198
273
  }
199
274
  if (groups.custom.length) {
200
275
  log('');
201
- log('Custom (yours, never auto-modified):');
276
+ log('Custom (your own โ€” never auto-modified):');
202
277
  for (const s of groups.custom) {
203
278
  log(` โ€ข ${s.slug}${s.description ? ` โ€” ${s.description}` : ''}`);
204
279
  }
@@ -220,9 +295,12 @@ async function runAdd(args) {
220
295
  const client = new SkillsClient();
221
296
  const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
222
297
  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`);
298
+ // Mutate in place so caller-set fields on the manifest (pinVersion,
299
+ // lastUpdate, lastUpdateVersion) survive the add. Building a fresh
300
+ // {version, installed} object would silently drop them.
301
+ manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
302
+ await writeManifest(skillsDir, manifest);
303
+ log(` โœ“ pinned ${entry.slug} โ†’ .claude/skills/${entry.slug}/SKILL.md`);
226
304
  log(' Commit + push to apply on the next PR.');
227
305
  }
228
306
 
@@ -234,7 +312,7 @@ async function runRemove(args) {
234
312
  }
235
313
  const skillsDir = join(process.cwd(), '.claude', 'skills');
236
314
  const entry = await removeSkill(skillsDir, slug);
237
- log(` โœ“ removed ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ€” will return on next init)' : ''}`);
315
+ log(` โœ“ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ€” returns on next init)' : ''}`);
238
316
  }
239
317
 
240
318
  async function runRefresh(args) {
@@ -242,11 +320,11 @@ async function runRefresh(args) {
242
320
  const skillsDir = join(cwd, '.claude', 'skills');
243
321
  const manifest = await readManifest(skillsDir);
244
322
  if (manifest.installed.length === 0) {
245
- log('No clud-bug-managed skills found. Run `clud-bug init` first.');
323
+ log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
246
324
  return;
247
325
  }
248
326
 
249
- log(' detecting repo signals...');
327
+ log(' re-surveying habitat...');
250
328
  const signals = await detect(cwd);
251
329
  log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
252
330
  log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
@@ -286,7 +364,7 @@ async function runRefresh(args) {
286
364
 
287
365
  if (diff.add.length === 0 && diff.remove.length === 0) {
288
366
  log('');
289
- log('Nothing to update. Skills are in sync with skills.sh recommendations.');
367
+ log('Collection in sync with skills.sh โ€” nothing to update.');
290
368
  return;
291
369
  }
292
370
 
@@ -307,7 +385,134 @@ async function runRefresh(args) {
307
385
  const client = new SkillsClient();
308
386
  if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
309
387
  for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
310
- log(' โœ“ skills updated. Commit + push to apply on the next PR.');
388
+ log(' โœ“ collection updated. Commit + push to apply on the next PR.');
389
+ }
390
+
391
+ async function runEditWorkflow(_args) {
392
+ const cwd = process.cwd();
393
+
394
+ // Validate: must have pending changes, all scoped to clud-bug workflow files.
395
+ let pending;
396
+ try {
397
+ pending = getPendingWorkflowEdits(cwd);
398
+ } catch (err) {
399
+ process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
400
+ process.exit(2);
401
+ }
402
+
403
+ if (pending.files.length === 0) {
404
+ log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
405
+ return;
406
+ }
407
+ if (!pending.allWorkflow) {
408
+ process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
409
+ for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
410
+ process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
411
+ process.exit(2);
412
+ }
413
+
414
+ log('๐Ÿ› Preparing an isolated PR for your workflow edit.');
415
+ const branch = makeBranchName();
416
+ log(` branch: ${branch} (rooted at origin/main)`);
417
+ for (const f of pending.files) log(` โ€ข ${f}`);
418
+
419
+ // Stash the pending workflow changes, branch from origin/main explicitly
420
+ // (NOT from HEAD โ€” if the user is on a feature branch with unrelated
421
+ // commits, those would otherwise leak into the "isolated" PR), then
422
+ // restore the changes onto the new branch and commit.
423
+ gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
424
+ try {
425
+ gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
426
+ gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
427
+ } catch (err) {
428
+ // Restore the user's stash before bubbling up.
429
+ gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
430
+ throw err;
431
+ }
432
+ const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
433
+ if (!popped.ok) {
434
+ 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`);
435
+ process.exit(1);
436
+ }
437
+ gitCmd(cwd, ['add', ...pending.files]);
438
+ gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
439
+ gitCmd(cwd, ['push', '-u', 'origin', branch]);
440
+
441
+ log('');
442
+ log('Done. Open the PR:');
443
+ 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."`);
444
+ }
445
+
446
+ async function runUpdateCmd(_args) {
447
+ const cwd = process.cwd();
448
+ const ourVersion = await readPkgVersion();
449
+ log(`๐Ÿ› Refreshing the field kit (${ourVersion}).`);
450
+
451
+ const result = await runUpdate({
452
+ cwd,
453
+ templatesDir: TEMPLATES,
454
+ baselineDir: BASELINE_DIR,
455
+ ourVersion,
456
+ });
457
+
458
+ if (result.missing === 'init') {
459
+ log(' No clud-bug installation detected. Run `clud-bug init` first.');
460
+ return;
461
+ }
462
+
463
+ if (result.changed.length === 0) {
464
+ log(' Already current. Nothing to update.');
465
+ return;
466
+ }
467
+
468
+ log(` โœ“ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
469
+ for (const c of result.changed) log(` โ€ข ${rel(cwd, c.path)} (${c.label})`);
470
+ if (result.unchanged.length > 0) {
471
+ log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
472
+ }
473
+ log('');
474
+ log('Commit + push to apply the refreshed kit on the next PR.');
475
+ }
476
+
477
+ async function runAudit(args) {
478
+ const cwd = process.cwd();
479
+ const date = new Date().toISOString().slice(0, 10);
480
+
481
+ let scopeLabel;
482
+ if (args.since) scopeLabel = `commits since ${args.since}`;
483
+ else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
484
+ else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
485
+ else scopeLabel = 'all tracked files';
486
+
487
+ log(`๐Ÿ› Audit walk in ${cwd}.`);
488
+ log(` scope: ${scopeLabel}`);
489
+
490
+ let files;
491
+ try {
492
+ files = computeAuditFileSet({
493
+ cwd,
494
+ since: args.since,
495
+ changedIn: args.changedIn,
496
+ scopes: args.scopes,
497
+ });
498
+ } catch (err) {
499
+ process.stderr.write(`clud-bug audit: ${err.message}\n`);
500
+ process.exit(2);
501
+ }
502
+ log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
503
+
504
+ if (files.length === 0) {
505
+ log(' Nothing in scope. Try widening --scope or --changed-in.');
506
+ return;
507
+ }
508
+
509
+ const outPath = args.out || join(cwd, 'audits', `${date}.md`);
510
+ await mkdir(dirname(outPath), { recursive: true });
511
+ await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
512
+ log(` โœ“ wrote stub: ${rel(cwd, outPath)}`);
513
+ log('');
514
+ log('Stub is empty findings โ€” populated by the GitHub Action.');
515
+ log('Run locally without the workflow if you want โ€” Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
311
516
  }
312
517
 
313
518
  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
+ }