clud-bug 0.6.28 → 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,16 +85,27 @@ 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. Workflow integration ships
88
- in v0.6.29; today this command surfaces whatever data
89
- has been written manually or by future runs.
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.
103
+ update-skill-usage Update the .claude/skills/.clud-bug.json usage block from
104
+ a structured-output JSON payload (the action's
105
+ \`outputs.structured_output\`). Called as a workflow
106
+ post-step alongside \`render\` (v0.6.29 / Component 4).
107
+ Pipe the JSON to stdin. Idempotent + atomic write.
108
+ Silent no-op on empty stdin (parity with \`render\`).
93
109
  render --stdin Render a structured-output JSON payload (the action's
94
110
  \`outputs.structured_output\`, piped via stdin) to the
95
111
  GitHub-markdown summary comment shape. Invoked by the
@@ -147,6 +163,7 @@ async function main() {
147
163
  case 'usage': return runUsage(args);
148
164
  case 'eval': return runEval();
149
165
  case 'render': return runRender(args);
166
+ case 'update-skill-usage': return runUpdateSkillUsage(args);
150
167
  default:
151
168
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
152
169
  process.exit(2);
@@ -193,6 +210,104 @@ async function runRender(args) {
193
210
  }
194
211
  }
195
212
 
213
+ // v0.6.29 — Component 4. Pipe the action's structured_output through
214
+ // the skill-usage data layer (v0.6.28) + write the merged result back
215
+ // to .claude/skills/.clud-bug.json atomically.
216
+ //
217
+ // Workflow integration (post-step in workflow.yml.tmpl):
218
+ //
219
+ // echo "${{ steps.review.outputs.structured_output }}" \
220
+ // | npx clud-bug@latest update-skill-usage --stdin
221
+ //
222
+ // Runs AFTER the render post-step. Silent no-op on empty stdin
223
+ // (same contract as `render` — preserves the workflow's existing
224
+ // "skip both if empty" branch). Idempotent: running on the same JSON
225
+ // twice produces the same result.
226
+ async function runUpdateSkillUsage(args) {
227
+ const fs = await import('node:fs/promises');
228
+ const path = await import('node:path');
229
+ const {
230
+ computeSkillUsageDelta,
231
+ mergeSkillUsage,
232
+ } = await import('../lib/skill-usage.js');
233
+
234
+ if (!args.stdin) {
235
+ process.stderr.write('clud-bug update-skill-usage: --stdin is required.\n');
236
+ process.exit(2);
237
+ }
238
+
239
+ let raw = '';
240
+ for await (const chunk of process.stdin) raw += chunk;
241
+ raw = raw.trim();
242
+ if (!raw) {
243
+ // Empty structured_output → render is also skipped → nothing to
244
+ // update. Match the render contract: exit 0 with a stderr note.
245
+ process.stderr.write('clud-bug update-skill-usage: stdin empty — no usage update.\n');
246
+ return;
247
+ }
248
+
249
+ let reviewJson;
250
+ try {
251
+ reviewJson = JSON.parse(raw);
252
+ } catch (e) {
253
+ process.stderr.write(`clud-bug update-skill-usage: invalid JSON: ${e.message}\n`);
254
+ process.exit(2);
255
+ }
256
+ if (!reviewJson || typeof reviewJson !== 'object') {
257
+ process.stderr.write('clud-bug update-skill-usage: payload must be a JSON object.\n');
258
+ process.exit(2);
259
+ }
260
+
261
+ // Compute per-review delta. Empty delta is fine — just means no
262
+ // skills loaded or cited (workflow-only PRs, e.g.).
263
+ const delta = computeSkillUsageDelta(reviewJson);
264
+ if (Object.keys(delta).length === 0) {
265
+ process.stderr.write('clud-bug update-skill-usage: no skills in payload — nothing to record.\n');
266
+ return;
267
+ }
268
+
269
+ // Read existing .clud-bug.json. The path is canonical:
270
+ // .claude/skills/.clud-bug.json relative to cwd (the workflow runs
271
+ // from the repo root).
272
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
273
+ let parsed;
274
+ try {
275
+ const existingRaw = await fs.readFile(jsonPath, 'utf-8');
276
+ parsed = JSON.parse(existingRaw);
277
+ } catch (err) {
278
+ if (err.code === 'ENOENT') {
279
+ process.stderr.write(
280
+ `clud-bug update-skill-usage: no .clud-bug.json at ${jsonPath} — skipping. ` +
281
+ `Run \`npx clud-bug init\` first.\n`
282
+ );
283
+ return;
284
+ }
285
+ process.stderr.write(`clud-bug update-skill-usage: parse failed: ${err.message}\n`);
286
+ process.exit(2);
287
+ }
288
+ if (!parsed || typeof parsed !== 'object') {
289
+ process.stderr.write('clud-bug update-skill-usage: .clud-bug.json malformed.\n');
290
+ process.exit(2);
291
+ }
292
+
293
+ const existingUsage = parsed.usage || {};
294
+ const timestamp = new Date().toISOString();
295
+ const mergedUsage = mergeSkillUsage(existingUsage, delta, timestamp);
296
+ parsed.usage = mergedUsage;
297
+
298
+ // Write back ATOMICALLY: temp file + rename. Guards against a
299
+ // crashed write leaving the JSON half-written + unparseable on next
300
+ // read (which would brick the entire skill catalog).
301
+ const tmpPath = jsonPath + '.tmp';
302
+ const serialized = JSON.stringify(parsed, null, 2) + '\n';
303
+ await fs.writeFile(tmpPath, serialized, 'utf-8');
304
+ await fs.rename(tmpPath, jsonPath);
305
+
306
+ const skillCount = Object.keys(delta).length;
307
+ ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
308
+ }
309
+
310
+
196
311
  // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
197
312
  // who follow the README invoke `clud-bug eval` — this routes to the
198
313
  // same `node --test` runner CI uses, so dev and CI verdicts match.
@@ -880,39 +995,119 @@ async function runUsage(args) {
880
995
  // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
881
996
  // .claude/skills/.clud-bug.json usage block, applies deterministic
882
997
  // thresholds, renders a read-only dashboard. No I/O beyond the JSON
883
- // read. Workflow integration that POPULATES the usage block ships in
884
- // v0.6.29; today this command is the consumer half of the contract.
885
- async function runUsageHealth(_args) {
886
- const fs = await import('node:fs/promises');
887
- 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) {
888
1006
  const { assessSkillHealth, formatHealthDashboard } = await import('../lib/skill-usage.js');
889
1007
 
890
- 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
+ }
891
1015
 
892
- 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');
893
1079
  try {
894
1080
  const raw = await fs.readFile(jsonPath, 'utf-8');
895
- parsed = JSON.parse(raw);
1081
+ const parsed = JSON.parse(raw);
1082
+ return (parsed && parsed.usage) ? parsed.usage : {};
896
1083
  } catch (err) {
897
1084
  if (err.code === 'ENOENT') {
898
1085
  process.stderr.write(
899
1086
  `clud-bug usage --health: no .claude/skills/.clud-bug.json found in ${process.cwd()}.\n` +
900
- `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`
901
1088
  );
902
- process.exit(1);
1089
+ return null;
903
1090
  }
904
1091
  process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
905
- process.exit(1);
1092
+ return null;
906
1093
  }
1094
+ }
907
1095
 
908
- const usage = parsed && parsed.usage ? parsed.usage : {};
909
- const rows = assessSkillHealth(usage, new Date());
910
- 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
+ }
911
1102
 
912
- // Exit code semantics: 0 (informational). The dashboard is read-only;
913
- // archive-candidates being present is NOT a failure mode — humans
914
- // decide. CI gates should NOT block on this.
915
- 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);
916
1111
  }
917
1112
 
918
1113
  // limit to 100 to avoid pagination explosions.
@@ -225,8 +225,9 @@ export function formatHealthDashboard(rows) {
225
225
  return (
226
226
  'Skill health: no usage data yet.\n\n' +
227
227
  'Usage data accumulates after clud-bug reviews land in your repo.\n' +
228
- 'Workflow integration ships in v0.6.29 until then this command is\n' +
229
- 'a structural placeholder.'
228
+ 'v0.6.29 wires up per-review delta writes via the workflow post-step\n' +
229
+ 'and uploads them as 90-day artifacts. v0.6.30 will add cross-review\n' +
230
+ 'aggregation so this dashboard reads the full artifact stream.'
230
231
  );
231
232
  }
232
233
 
@@ -259,3 +260,173 @@ export function formatHealthDashboard(rows) {
259
260
  lines.push(' healthy = cited within 60 days');
260
261
  return lines.join('\n');
261
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.28",
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",
@@ -283,6 +283,30 @@ jobs:
283
283
 
284
284
  $CALIBRATION"
285
285
 
286
+ # v0.6.29 / Component 4 — see workflow.yml.tmpl for design notes.
287
+ - name: Update skill-usage tracking
288
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
289
+ continue-on-error: true
290
+ env:
291
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
292
+ run: |
293
+ set -euo pipefail
294
+ mkdir -p .claude/skills
295
+ if [ ! -f .claude/skills/.clud-bug.json ]; then
296
+ echo '{"version": 1}' > .claude/skills/.clud-bug.json
297
+ fi
298
+ printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} update-skill-usage --stdin
299
+ echo "::notice title=skill-usage::recorded review delta to ephemeral .clud-bug.json (v0.6.30 will add cross-review aggregation)"
300
+
301
+ - name: Upload skill-usage artifact
302
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
303
+ continue-on-error: true
304
+ uses: actions/upload-artifact@v4
305
+ with:
306
+ name: clud-bug-skill-usage-pr-${{ github.event.pull_request.number }}
307
+ path: .claude/skills/.clud-bug.json
308
+ retention-days: 90
309
+
286
310
  # v0.6.26 / §5.5 Layer 6 fallback render-from-inlines — see workflow.yml.tmpl for design notes.
287
311
  - name: Fallback summary (structured_output empty)
288
312
  if: success() && steps.clud-bug-review.outputs.structured_output == ''
@@ -339,7 +363,7 @@ jobs:
339
363
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
340
364
  - name: Strict mode — fail check on critical findings
341
365
  if: success()
342
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.28
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
343
367
  with:
344
368
  github-token: ${{ secrets.GITHUB_TOKEN }}
345
369
  # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
@@ -283,6 +283,30 @@ jobs:
283
283
 
284
284
  $CALIBRATION"
285
285
 
286
+ # v0.6.29 / Component 4 — see workflow.yml.tmpl for design notes.
287
+ - name: Update skill-usage tracking
288
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
289
+ continue-on-error: true
290
+ env:
291
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
292
+ run: |
293
+ set -euo pipefail
294
+ mkdir -p .claude/skills
295
+ if [ ! -f .claude/skills/.clud-bug.json ]; then
296
+ echo '{"version": 1}' > .claude/skills/.clud-bug.json
297
+ fi
298
+ printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} update-skill-usage --stdin
299
+ echo "::notice title=skill-usage::recorded review delta to ephemeral .clud-bug.json (v0.6.30 will add cross-review aggregation)"
300
+
301
+ - name: Upload skill-usage artifact
302
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
303
+ continue-on-error: true
304
+ uses: actions/upload-artifact@v4
305
+ with:
306
+ name: clud-bug-skill-usage-pr-${{ github.event.pull_request.number }}
307
+ path: .claude/skills/.clud-bug.json
308
+ retention-days: 90
309
+
286
310
  # v0.6.26 / §5.5 Layer 6 fallback render-from-inlines — see workflow.yml.tmpl for design notes.
287
311
  - name: Fallback summary (structured_output empty)
288
312
  if: success() && steps.clud-bug-review.outputs.structured_output == ''
@@ -339,7 +363,7 @@ jobs:
339
363
  # Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
340
364
  - name: Strict mode — fail check on critical findings
341
365
  if: success()
342
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.28
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
343
367
  with:
344
368
  github-token: ${{ secrets.GITHUB_TOKEN }}
345
369
  # v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
@@ -494,6 +494,44 @@ jobs:
494
494
 
495
495
  $CALIBRATION"
496
496
 
497
+ # v0.6.29 / Component 4: pipe structured_output through
498
+ # `clud-bug update-skill-usage` to compute the per-skill loads +
499
+ # citations delta from this one review. Writes to the ephemeral
500
+ # workspace .clud-bug.json AND uploads as a workflow artifact
501
+ # (90-day retention). v0.6.30 will add aggregation logic so
502
+ # `clud-bug usage --health` reads + merges artifacts across runs.
503
+ #
504
+ # Why artifact instead of committing back to main: committing
505
+ # would require `contents: write` permission (private-repo
506
+ # trigger-firing regression risk per v0.6.23 hotfix) + race
507
+ # handling. Artifacts are GitHub-native persistence with zero
508
+ # permission expansion + zero commit noise.
509
+ - name: Update skill-usage tracking
510
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
511
+ continue-on-error: true # don't fail the whole review if usage update flakes
512
+ env:
513
+ STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
514
+ run: |
515
+ set -euo pipefail
516
+ mkdir -p .claude/skills
517
+ # If a .clud-bug.json already exists in the workspace (PR's
518
+ # snapshot), keep it. Otherwise initialize an empty shell so
519
+ # update-skill-usage has something to merge into.
520
+ if [ ! -f .claude/skills/.clud-bug.json ]; then
521
+ echo '{"version": 1}' > .claude/skills/.clud-bug.json
522
+ fi
523
+ printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} update-skill-usage --stdin
524
+ echo "::notice title=skill-usage::recorded review delta to ephemeral .clud-bug.json (v0.6.30 will add cross-review aggregation)"
525
+
526
+ - name: Upload skill-usage artifact
527
+ if: success() && steps.clud-bug-review.outputs.structured_output != ''
528
+ continue-on-error: true
529
+ uses: actions/upload-artifact@v4
530
+ with:
531
+ name: clud-bug-skill-usage-pr-${{ github.event.pull_request.number }}
532
+ path: .claude/skills/.clud-bug.json
533
+ retention-days: 90
534
+
497
535
  # Fallback comment when the model couldn't produce schema-valid
498
536
  # output after max retries (structured_output is empty). Keeps a
499
537
  # bare H2 header so the strict-mode gate sees a comment and falls
@@ -589,7 +627,7 @@ jobs:
589
627
  # Letting the action's own failure fail the check is louder and right.
590
628
  - name: Strict mode — fail check on critical findings
591
629
  if: success()
592
- uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.28
630
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.30
593
631
  with:
594
632
  github-token: ${{ secrets.GITHUB_TOKEN }}
595
633
  # v0.6.22 / 0.0.O: the summary is now posted by the workflow