clud-bug 0.6.29 → 0.6.30

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/bin/clud-bug.js CHANGED
@@ -33,6 +33,10 @@ function parseArgs(argv) {
33
33
  repo: null, pr: null, limit: null, json: false,
34
34
  // 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
35
35
  stdin: false,
36
+ // v0.6.30: cross-review aggregation read source for `usage --health`.
37
+ // Defaults to true (artifact mode); `--no-artifacts` forces local
38
+ // .clud-bug.json read (matches v0.6.28 behavior).
39
+ artifacts: true,
36
40
  };
37
41
  for (let i = 0; i < argv.length; i++) {
38
42
  const a = argv[i];
@@ -53,6 +57,7 @@ function parseArgs(argv) {
53
57
  else if (a === '--json') args.json = true;
54
58
  else if (a === '--stdin') args.stdin = true;
55
59
  else if (a === '--health') args.health = true;
60
+ else if (a === '--no-artifacts') args.artifacts = false;
56
61
  else args._.push(a);
57
62
  }
58
63
  return args;
@@ -80,13 +85,18 @@ Commands:
80
85
  rate, 30-day rolling \$/LOC trend, per-repo/per-model
81
86
  distributions, and outliers (> 2x org median).
82
87
  Use --pr / --repo / --since / --limit / --json to filter.
83
- usage --health Deterministic skill-health dashboard (v0.6.28). Reads
84
- \`.claude/skills/.clud-bug.json\` usage block + renders
85
- archive-candidate / stale / new / healthy status per skill.
86
- Read-only no automation acts on the output. Humans
87
- decide which skills to prune. v0.6.29 wires the workflow
88
- post-step that auto-writes per-review deltas; v0.6.30
89
- will aggregate across runs into the dashboard read path.
88
+ usage --health Deterministic skill-health dashboard. Renders archive-
89
+ candidate / stale / new / healthy status per skill, applying
90
+ the v0.6.28 thresholds (citations==0 + loads>=5 archive
91
+ candidate; last cited >60d stale; etc.). Read-only —
92
+ humans decide what to prune.
93
+ Read source (v0.6.30): by default, walks
94
+ \`clud-bug-skill-usage-pr-*\` workflow artifacts uploaded
95
+ by every clud-bug-review run and accumulates them into
96
+ one org-level snapshot. Pass \`--repo owner/name\` to
97
+ target a specific repo; otherwise infers from the local
98
+ git remote. \`--no-artifacts\` falls back to reading the
99
+ local \`.claude/skills/.clud-bug.json\` (v0.6.28 behavior).
90
100
  eval Run the golden-set regression gate against the rendered review
91
101
  prompt (must-contain / must-not-contain / byte-budget). Same as
92
102
  \`node --test test/prompts.eval.test.js\` but works from any cwd.
@@ -985,39 +995,119 @@ async function runUsage(args) {
985
995
  // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
986
996
  // .claude/skills/.clud-bug.json usage block, applies deterministic
987
997
  // thresholds, renders a read-only dashboard. No I/O beyond the JSON
988
- // read. Workflow integration that POPULATES the usage block ships in
989
- // v0.6.29; today this command is the consumer half of the contract.
990
- async function runUsageHealth(_args) {
991
- const fs = await import('node:fs/promises');
992
- const path = await import('node:path');
998
+ // read.
999
+ //
1000
+ // v0.6.30 — read accumulated usage from workflow artifacts (uploaded
1001
+ // by v0.6.29's post-step). Defaults to artifact mode when --repo is
1002
+ // passed OR an `owner/name` can be inferred from `git remote`. Falls
1003
+ // back to the local-file path otherwise. The `--no-artifacts` flag
1004
+ // forces the v0.6.28 local-only behavior (handy for tests + offline).
1005
+ async function runUsageHealth(args) {
993
1006
  const { assessSkillHealth, formatHealthDashboard } = await import('../lib/skill-usage.js');
994
1007
 
995
- const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
1008
+ // Decide read source. Priority: explicit --no-artifacts → local;
1009
+ // explicit --repo OR inferred owner/repo → artifacts; else local.
1010
+ const wantArtifacts = args.artifacts !== false;
1011
+ let ownerRepo = null;
1012
+ if (wantArtifacts) {
1013
+ ownerRepo = args.repo || await inferOwnerRepoFromGit();
1014
+ }
996
1015
 
997
- let parsed;
1016
+ let usage;
1017
+ let source;
1018
+ if (wantArtifacts && ownerRepo) {
1019
+ const result = await loadUsageFromArtifacts(ownerRepo, args);
1020
+ if (result) {
1021
+ usage = result.usage;
1022
+ source = `${result.artifactCount} artifact${result.artifactCount === 1 ? '' : 's'} from ${ownerRepo}`;
1023
+ }
1024
+ }
1025
+
1026
+ // Fallback to local .clud-bug.json (v0.6.28 behavior).
1027
+ if (usage == null) {
1028
+ const localResult = await loadUsageFromLocalFile();
1029
+ if (localResult == null) {
1030
+ // Both paths failed. The local helper has already written its
1031
+ // own stderr explanation; we just exit.
1032
+ process.exit(1);
1033
+ }
1034
+ usage = localResult;
1035
+ source = `local .clud-bug.json`;
1036
+ }
1037
+
1038
+ const rows = assessSkillHealth(usage, new Date());
1039
+ process.stdout.write(formatHealthDashboard(rows) + '\n');
1040
+
1041
+ // Exit code semantics: 0 (informational). The dashboard is read-only;
1042
+ // archive-candidates being present is NOT a failure mode — humans
1043
+ // decide. CI gates should NOT block on this.
1044
+ ok(`skill health: ${rows.length} skill${rows.length === 1 ? '' : 's'} tracked (source: ${source})`);
1045
+ }
1046
+
1047
+ // Helpers split out from runUsageHealth so the two read paths are
1048
+ // independently testable + composable in future commands.
1049
+
1050
+ async function loadUsageFromArtifacts(ownerRepo, args) {
1051
+ const { fetchUsageArtifacts, aggregateUsageStream } = await import('../lib/skill-usage.js');
1052
+ const [owner, repo] = ownerRepo.split('/');
1053
+ if (!owner || !repo) {
1054
+ process.stderr.write(`clud-bug usage --health: --repo must be in owner/name form, got "${ownerRepo}".\n`);
1055
+ return null;
1056
+ }
1057
+ const since = parseSinceArg(args.since);
1058
+ let artifacts;
1059
+ try {
1060
+ artifacts = await fetchUsageArtifacts({ owner, repo, since });
1061
+ } catch (err) {
1062
+ process.stderr.write(`::notice::clud-bug usage --health: artifact fetch failed (${err.message}) — falling back to local .clud-bug.json\n`);
1063
+ return null;
1064
+ }
1065
+ if (artifacts.length === 0) {
1066
+ process.stderr.write(`::notice::clud-bug usage --health: no skill-usage artifacts found in ${ownerRepo} — falling back to local .clud-bug.json\n`);
1067
+ return null;
1068
+ }
1069
+ return {
1070
+ usage: aggregateUsageStream(artifacts),
1071
+ artifactCount: artifacts.length,
1072
+ };
1073
+ }
1074
+
1075
+ async function loadUsageFromLocalFile() {
1076
+ const fs = await import('node:fs/promises');
1077
+ const path = await import('node:path');
1078
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
998
1079
  try {
999
1080
  const raw = await fs.readFile(jsonPath, 'utf-8');
1000
- parsed = JSON.parse(raw);
1081
+ const parsed = JSON.parse(raw);
1082
+ return (parsed && parsed.usage) ? parsed.usage : {};
1001
1083
  } catch (err) {
1002
1084
  if (err.code === 'ENOENT') {
1003
1085
  process.stderr.write(
1004
1086
  `clud-bug usage --health: no .claude/skills/.clud-bug.json found in ${process.cwd()}.\n` +
1005
- `Run \`npx clud-bug init\` first to install the catalog state.\n`
1087
+ `Run \`npx clud-bug init\` first OR pass --repo owner/name to read from workflow artifacts.\n`
1006
1088
  );
1007
- process.exit(1);
1089
+ return null;
1008
1090
  }
1009
1091
  process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
1010
- process.exit(1);
1092
+ return null;
1011
1093
  }
1094
+ }
1012
1095
 
1013
- const usage = parsed && parsed.usage ? parsed.usage : {};
1014
- const rows = assessSkillHealth(usage, new Date());
1015
- process.stdout.write(formatHealthDashboard(rows) + '\n');
1096
+ async function inferOwnerRepoFromGit() {
1097
+ // `gh repo view --json nameWithOwner` reads the current dir's git
1098
+ // remote AND respects gh's config. Returns null on non-git dirs.
1099
+ const result = await ghJson(['repo', 'view', '--json', 'nameWithOwner']);
1100
+ return result && result.nameWithOwner ? result.nameWithOwner : null;
1101
+ }
1016
1102
 
1017
- // Exit code semantics: 0 (informational). The dashboard is read-only;
1018
- // archive-candidates being present is NOT a failure mode — humans
1019
- // decide. CI gates should NOT block on this.
1020
- ok(`skill health: ${rows.length} skill${rows.length === 1 ? '' : 's'} tracked`);
1103
+ function parseSinceArg(since) {
1104
+ if (!since) return null;
1105
+ if (since instanceof Date) return since;
1106
+ const m = String(since).match(/^(\d+)([dwmy])$/);
1107
+ if (!m) return null;
1108
+ const n = Number(m[1]);
1109
+ const unitMs = { d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3, y: 365 * 86400e3 }[m[2]];
1110
+ return new Date(Date.now() - n * unitMs);
1021
1111
  }
1022
1112
 
1023
1113
  // limit to 100 to avoid pagination explosions.
@@ -260,3 +260,173 @@ export function formatHealthDashboard(rows) {
260
260
  lines.push(' healthy = cited within 60 days');
261
261
  return lines.join('\n');
262
262
  }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // v0.6.30 — cross-review aggregation
266
+ //
267
+ // The v0.6.29 workflow post-step uploads `.clud-bug.json` as a per-PR
268
+ // artifact named `clud-bug-skill-usage-pr-<N>` (90-day retention). This
269
+ // section walks the artifact stream + accumulates into one dashboard read.
270
+ //
271
+ // Artifact persistence design choice (recap from v0.6.29): we picked
272
+ // artifacts over commit-back-to-main because commit-back required
273
+ // `contents: write` permission expansion — v0.6.23 hit a regression
274
+ // from a similar expansion. Artifacts are GitHub-native, zero perm
275
+ // widening, zero commit noise. v0.6.30 reads them back here.
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /**
279
+ * Default `gh` runner — spawns the local gh CLI. Tests inject a mock.
280
+ *
281
+ * The runner has two methods:
282
+ * - json(args): returns parsed JSON stdout, or null on error.
283
+ * - run(args): returns {code, stdout, stderr}. For commands that
284
+ * download files etc. — no JSON parsing.
285
+ */
286
+ async function defaultGhJson(args) {
287
+ const { spawn } = await import('node:child_process');
288
+ return new Promise((resolve) => {
289
+ const child = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
290
+ let stdout = '';
291
+ child.stdout.on('data', (d) => { stdout += d; });
292
+ child.on('error', () => resolve(null));
293
+ child.on('close', (code) => {
294
+ if (code !== 0) return resolve(null);
295
+ try { resolve(JSON.parse(stdout)); } catch { resolve(null); }
296
+ });
297
+ });
298
+ }
299
+
300
+ async function defaultGhRun(args) {
301
+ const { spawn } = await import('node:child_process');
302
+ return new Promise((resolve) => {
303
+ const child = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
304
+ let stdout = '';
305
+ let stderr = '';
306
+ child.stdout.on('data', (d) => { stdout += d; });
307
+ child.stderr.on('data', (d) => { stderr += d; });
308
+ child.on('error', () => resolve({ code: 1, stdout: '', stderr: 'gh not on PATH' }));
309
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
310
+ });
311
+ }
312
+
313
+ export const DEFAULT_GH_RUNNER = {
314
+ json: defaultGhJson,
315
+ run: defaultGhRun,
316
+ };
317
+
318
+ /**
319
+ * Fetch all per-PR skill-usage artifacts from a repo. Each artifact is
320
+ * downloaded to a temp dir, its `.clud-bug.json` is parsed, and the
321
+ * usage block is returned.
322
+ *
323
+ * @param {object} opts
324
+ * @param {string} opts.owner - GitHub repo owner.
325
+ * @param {string} opts.repo - GitHub repo name.
326
+ * @param {Date|null} opts.since - Filter to artifacts created on/after this date.
327
+ * @param {object} opts.ghRunner - Injected gh CLI runner (see DEFAULT_GH_RUNNER).
328
+ *
329
+ * @returns {Promise<{prNumber: number, artifactId: number, usage: object, fetchedAt: string}[]>}
330
+ */
331
+ export async function fetchUsageArtifacts({ owner, repo, since = null, ghRunner = DEFAULT_GH_RUNNER }) {
332
+ if (!owner || !repo) {
333
+ throw new Error('fetchUsageArtifacts: owner + repo are required');
334
+ }
335
+
336
+ // List artifacts in one call. We deliberately do NOT use `--paginate`:
337
+ // `gh api --paginate --jq <expr>` applies the jq filter to EACH page
338
+ // independently and concatenates the outputs with newlines, which
339
+ // produces `[...]\n[...]` — invalid as a single JSON document.
340
+ // `JSON.parse` returns null and the dashboard silently shows nothing
341
+ // for repos with >30 artifacts (default page size). Caught by
342
+ // clud-bug-review on PR #127.
343
+ //
344
+ // `?per_page=100` covers up to 100 artifacts in one call. The 90-day
345
+ // artifact retention means most repos won't hit that ceiling (>100
346
+ // PR reviews in 90 days = >1/day sustained). If a future repo
347
+ // saturates this, paginate manually in v0.6.31+ (per_page=100 +
348
+ // explicit `?page=N` loop, parse each response as JSON, concatenate).
349
+ const list = await ghRunner.json([
350
+ 'api',
351
+ `repos/${owner}/${repo}/actions/artifacts?per_page=100`,
352
+ '--jq',
353
+ '[.artifacts[] | select(.name | startswith("clud-bug-skill-usage-pr-")) | select(.expired == false) | {id, name, workflow_run_id: .workflow_run.id, created_at}]',
354
+ ]);
355
+
356
+ // `--jq '[...]'` wraps the stream into a single array. If the runner
357
+ // returns null (404, no auth, etc.), bail to empty list.
358
+ if (!Array.isArray(list)) return [];
359
+
360
+ const filtered = since
361
+ ? list.filter((a) => new Date(a.created_at) >= since)
362
+ : list;
363
+
364
+ const fs = await import('node:fs/promises');
365
+ const path = await import('node:path');
366
+ const os = await import('node:os');
367
+
368
+ const results = [];
369
+ for (const art of filtered) {
370
+ const prMatch = art.name.match(/^clud-bug-skill-usage-pr-(\d+)$/);
371
+ if (!prMatch) continue;
372
+ const prNumber = Number(prMatch[1]);
373
+
374
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clud-bug-art-'));
375
+ try {
376
+ const dl = await ghRunner.run([
377
+ 'run', 'download', String(art.workflow_run_id),
378
+ '-R', `${owner}/${repo}`,
379
+ '-n', art.name,
380
+ '-D', tmpDir,
381
+ ]);
382
+ if (dl.code !== 0) continue;
383
+
384
+ // The workflow uploaded `.clud-bug.json` (single file under the
385
+ // path key). `gh run download -D <dir>` writes it to the dest as
386
+ // `<dir>/.clud-bug.json` (preserves the source path).
387
+ const jsonPath = path.join(tmpDir, '.clud-bug.json');
388
+ let parsed;
389
+ try {
390
+ const raw = await fs.readFile(jsonPath, 'utf-8');
391
+ parsed = JSON.parse(raw);
392
+ } catch {
393
+ continue; // artifact corrupted or layout unexpected
394
+ }
395
+ const usage = parsed && parsed.usage ? parsed.usage : {};
396
+ results.push({
397
+ prNumber,
398
+ artifactId: art.id,
399
+ usage,
400
+ fetchedAt: art.created_at,
401
+ });
402
+ } finally {
403
+ await fs.rm(tmpDir, { recursive: true, force: true });
404
+ }
405
+ }
406
+
407
+ return results;
408
+ }
409
+
410
+ /**
411
+ * Reduce an array of per-PR artifact records into a single accumulated
412
+ * usage block by left-folding `mergeSkillUsage` over them, ordered by
413
+ * `fetchedAt` ascending so the last_cited timestamp is deterministic.
414
+ *
415
+ * Because `mergeSkillUsage` is commutative for loads + citations counts
416
+ * AND keeps the LATEST timestamp it sees as last_cited (we sort
417
+ * ascending so newest wins on the final pass), out-of-order input
418
+ * produces an identical result.
419
+ *
420
+ * @param {{usage: object, fetchedAt: string}[]} artifacts
421
+ * @returns {object} accumulated usage block, shape matches mergeSkillUsage output
422
+ */
423
+ export function aggregateUsageStream(artifacts) {
424
+ if (!Array.isArray(artifacts) || artifacts.length === 0) return {};
425
+ const sorted = [...artifacts].sort(
426
+ (a, b) => new Date(a.fetchedAt) - new Date(b.fetchedAt)
427
+ );
428
+ return sorted.reduce(
429
+ (acc, art) => mergeSkillUsage(acc, art.usage || {}, art.fetchedAt),
430
+ {}
431
+ );
432
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.6.29",
3
+ "version": "0.6.30",
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",
@@ -363,7 +363,7 @@ jobs:
363
363
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
364
364
  - name: Strict mode — fail check on critical findings
365
365
  if: success()
366
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
367
367
  with:
368
368
  github-token: ${{ secrets.GITHUB_TOKEN }}
369
369
  # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
@@ -363,7 +363,7 @@ jobs:
363
363
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
364
364
  - name: Strict mode — fail check on critical findings
365
365
  if: success()
366
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
367
367
  with:
368
368
  github-token: ${{ secrets.GITHUB_TOKEN }}
369
369
  # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
@@ -627,7 +627,7 @@ jobs:
627
627
  # Letting the action's own failure fail the check is louder and right.
628
628
  - name: Strict mode — fail check on critical findings
629
629
  if: success()
630
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
630
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
631
631
  with:
632
632
  github-token: ${{ secrets.GITHUB_TOKEN }}
633
633
  # v0.6.22 / 0.0.O: the summary is now posted by the workflow