clud-bug 0.6.27 → 0.6.29

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
@@ -278,3 +278,16 @@ npm test # node:test, no runtime deps
278
278
  ## License
279
279
 
280
280
  MIT.
281
+
282
+ ---
283
+
284
+ ## Part of the thrillmade SkDD toolchain
285
+
286
+ [Skills-Driven Development](https://zakelfassi.com/skdd-skills-driven-development) (Zak Elfassi's methodology) gives you the loop; the thrillmade toolchain ships the parts:
287
+
288
+ - **[logmind](https://github.com/thrillmade/logmind)** — the *why* behind every change (decision logging as commit primitive); skill-creation + testing + auditing
289
+ - **[clud-bug](https://github.com/thrillmade/clud-bug)** — skill-driven PR review at gate time; every finding cites the skill that motivated it
290
+ - **[agent-skills](https://github.com/thrillmade/agent-skills)** — public catalog of reusable skills
291
+ - **[skills.sh](https://skills.sh)** — skill discovery + install
292
+
293
+ End-to-end agentic auto dev: write skills first → log the *why* → run them against PRs → iterate based on usage. The tools work independently; better together.
package/bin/clud-bug.js CHANGED
@@ -52,6 +52,7 @@ function parseArgs(argv) {
52
52
  else if (a === '--limit') args.limit = Number(argv[++i]);
53
53
  else if (a === '--json') args.json = true;
54
54
  else if (a === '--stdin') args.stdin = true;
55
+ else if (a === '--health') args.health = true;
55
56
  else args._.push(a);
56
57
  }
57
58
  return args;
@@ -79,9 +80,22 @@ Commands:
79
80
  rate, 30-day rolling \$/LOC trend, per-repo/per-model
80
81
  distributions, and outliers (> 2x org median).
81
82
  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.
82
90
  eval Run the golden-set regression gate against the rendered review
83
91
  prompt (must-contain / must-not-contain / byte-budget). Same as
84
92
  \`node --test test/prompts.eval.test.js\` but works from any cwd.
93
+ update-skill-usage Update the .claude/skills/.clud-bug.json usage block from
94
+ a structured-output JSON payload (the action's
95
+ \`outputs.structured_output\`). Called as a workflow
96
+ post-step alongside \`render\` (v0.6.29 / Component 4).
97
+ Pipe the JSON to stdin. Idempotent + atomic write.
98
+ Silent no-op on empty stdin (parity with \`render\`).
85
99
  render --stdin Render a structured-output JSON payload (the action's
86
100
  \`outputs.structured_output\`, piped via stdin) to the
87
101
  GitHub-markdown summary comment shape. Invoked by the
@@ -139,6 +153,7 @@ async function main() {
139
153
  case 'usage': return runUsage(args);
140
154
  case 'eval': return runEval();
141
155
  case 'render': return runRender(args);
156
+ case 'update-skill-usage': return runUpdateSkillUsage(args);
142
157
  default:
143
158
  process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
144
159
  process.exit(2);
@@ -185,6 +200,104 @@ async function runRender(args) {
185
200
  }
186
201
  }
187
202
 
203
+ // v0.6.29 — Component 4. Pipe the action's structured_output through
204
+ // the skill-usage data layer (v0.6.28) + write the merged result back
205
+ // to .claude/skills/.clud-bug.json atomically.
206
+ //
207
+ // Workflow integration (post-step in workflow.yml.tmpl):
208
+ //
209
+ // echo "${{ steps.review.outputs.structured_output }}" \
210
+ // | npx clud-bug@latest update-skill-usage --stdin
211
+ //
212
+ // Runs AFTER the render post-step. Silent no-op on empty stdin
213
+ // (same contract as `render` — preserves the workflow's existing
214
+ // "skip both if empty" branch). Idempotent: running on the same JSON
215
+ // twice produces the same result.
216
+ async function runUpdateSkillUsage(args) {
217
+ const fs = await import('node:fs/promises');
218
+ const path = await import('node:path');
219
+ const {
220
+ computeSkillUsageDelta,
221
+ mergeSkillUsage,
222
+ } = await import('../lib/skill-usage.js');
223
+
224
+ if (!args.stdin) {
225
+ process.stderr.write('clud-bug update-skill-usage: --stdin is required.\n');
226
+ process.exit(2);
227
+ }
228
+
229
+ let raw = '';
230
+ for await (const chunk of process.stdin) raw += chunk;
231
+ raw = raw.trim();
232
+ if (!raw) {
233
+ // Empty structured_output → render is also skipped → nothing to
234
+ // update. Match the render contract: exit 0 with a stderr note.
235
+ process.stderr.write('clud-bug update-skill-usage: stdin empty — no usage update.\n');
236
+ return;
237
+ }
238
+
239
+ let reviewJson;
240
+ try {
241
+ reviewJson = JSON.parse(raw);
242
+ } catch (e) {
243
+ process.stderr.write(`clud-bug update-skill-usage: invalid JSON: ${e.message}\n`);
244
+ process.exit(2);
245
+ }
246
+ if (!reviewJson || typeof reviewJson !== 'object') {
247
+ process.stderr.write('clud-bug update-skill-usage: payload must be a JSON object.\n');
248
+ process.exit(2);
249
+ }
250
+
251
+ // Compute per-review delta. Empty delta is fine — just means no
252
+ // skills loaded or cited (workflow-only PRs, e.g.).
253
+ const delta = computeSkillUsageDelta(reviewJson);
254
+ if (Object.keys(delta).length === 0) {
255
+ process.stderr.write('clud-bug update-skill-usage: no skills in payload — nothing to record.\n');
256
+ return;
257
+ }
258
+
259
+ // Read existing .clud-bug.json. The path is canonical:
260
+ // .claude/skills/.clud-bug.json relative to cwd (the workflow runs
261
+ // from the repo root).
262
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
263
+ let parsed;
264
+ try {
265
+ const existingRaw = await fs.readFile(jsonPath, 'utf-8');
266
+ parsed = JSON.parse(existingRaw);
267
+ } catch (err) {
268
+ if (err.code === 'ENOENT') {
269
+ process.stderr.write(
270
+ `clud-bug update-skill-usage: no .clud-bug.json at ${jsonPath} — skipping. ` +
271
+ `Run \`npx clud-bug init\` first.\n`
272
+ );
273
+ return;
274
+ }
275
+ process.stderr.write(`clud-bug update-skill-usage: parse failed: ${err.message}\n`);
276
+ process.exit(2);
277
+ }
278
+ if (!parsed || typeof parsed !== 'object') {
279
+ process.stderr.write('clud-bug update-skill-usage: .clud-bug.json malformed.\n');
280
+ process.exit(2);
281
+ }
282
+
283
+ const existingUsage = parsed.usage || {};
284
+ const timestamp = new Date().toISOString();
285
+ const mergedUsage = mergeSkillUsage(existingUsage, delta, timestamp);
286
+ parsed.usage = mergedUsage;
287
+
288
+ // Write back ATOMICALLY: temp file + rename. Guards against a
289
+ // crashed write leaving the JSON half-written + unparseable on next
290
+ // read (which would brick the entire skill catalog).
291
+ const tmpPath = jsonPath + '.tmp';
292
+ const serialized = JSON.stringify(parsed, null, 2) + '\n';
293
+ await fs.writeFile(tmpPath, serialized, 'utf-8');
294
+ await fs.rename(tmpPath, jsonPath);
295
+
296
+ const skillCount = Object.keys(delta).length;
297
+ ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
298
+ }
299
+
300
+
188
301
  // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
189
302
  // who follow the README invoke `clud-bug eval` — this routes to the
190
303
  // same `node --test` runner CI uses, so dev and CI verdicts match.
@@ -807,6 +920,14 @@ async function runAudit(args) {
807
920
  // Default scope: 30 days, all repos with clud-bug-review.yml in the gh
808
921
  // user's auth scope. --repo / --pr / --since / --limit narrow.
809
922
  async function runUsage(args) {
923
+ // v0.6.28 — `clud-bug usage --health`: deterministic skill-health
924
+ // dashboard. Reads `.claude/skills/.clud-bug.json` usage block,
925
+ // applies thresholds, renders read-only table. No automation acts
926
+ // on the output. Per the pragmatic SkDD pivot (2026-05-30).
927
+ if (args.health) {
928
+ return runUsageHealth(args);
929
+ }
930
+
810
931
  const limit = args.limit ?? 50;
811
932
  const since = args.since ?? '30d';
812
933
 
@@ -861,6 +982,44 @@ async function runUsage(args) {
861
982
 
862
983
  // `gh repo list` won't filter by workflow file content, so we iterate
863
984
  // repos the user has access to and probe for clud-bug-review.yml. We
985
+ // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
986
+ // .claude/skills/.clud-bug.json usage block, applies deterministic
987
+ // 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');
993
+ const { assessSkillHealth, formatHealthDashboard } = await import('../lib/skill-usage.js');
994
+
995
+ const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
996
+
997
+ let parsed;
998
+ try {
999
+ const raw = await fs.readFile(jsonPath, 'utf-8');
1000
+ parsed = JSON.parse(raw);
1001
+ } catch (err) {
1002
+ if (err.code === 'ENOENT') {
1003
+ process.stderr.write(
1004
+ `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`
1006
+ );
1007
+ process.exit(1);
1008
+ }
1009
+ process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
1010
+ process.exit(1);
1011
+ }
1012
+
1013
+ const usage = parsed && parsed.usage ? parsed.usage : {};
1014
+ const rows = assessSkillHealth(usage, new Date());
1015
+ process.stdout.write(formatHealthDashboard(rows) + '\n');
1016
+
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`);
1021
+ }
1022
+
864
1023
  // limit to 100 to avoid pagination explosions.
865
1024
  async function discoverConsumingRepos() {
866
1025
  const list = await ghJson(['repo', 'list', '--limit', '100', '--json', 'nameWithOwner']);
@@ -0,0 +1,262 @@
1
+ // lib/skill-usage.js — Component 1+2 of the pragmatic SkDD pivot.
2
+ //
3
+ // Pure functions for deterministic skill-usage tracking. Per the
4
+ // strategic pivot (2026-05-30): replace Zak Elfassi's speculative
5
+ // recursive-meta-skill direction with concrete usage data + human-gated
6
+ // approval. This module is the data layer.
7
+ //
8
+ // Three responsibilities:
9
+ //
10
+ // 1. computeSkillUsageDelta(reviewJson)
11
+ // Given the structured-output JSON from one clud-bug review,
12
+ // return the per-skill delta for that one review.
13
+ //
14
+ // 2. mergeSkillUsage(existing, delta, timestamp)
15
+ // Merge a delta into the persistent usage block (the `usage`
16
+ // field in `.claude/skills/.clud-bug.json`).
17
+ //
18
+ // 3. assessSkillHealth(usage, now)
19
+ // Apply the deterministic thresholds + return a row per skill
20
+ // that `clud-bug usage --health` renders as a table.
21
+ //
22
+ // All three are pure. Side effects (file I/O) live in bin/clud-bug.js
23
+ // and the workflow post-step (v0.6.29).
24
+ //
25
+ // Thresholds — concrete numbers per design (2026-05-30):
26
+ //
27
+ // - archive-candidate: citations == 0 across last 90 days of loads
28
+ // - stale: last cited > 60 days ago
29
+ // - healthy: >= 3 citations in any rolling 90-day window
30
+ // - new: loads < 5 (still bedding in; don't judge yet)
31
+ //
32
+ // No automation acts on this output. It's a READ-ONLY dashboard.
33
+ // Humans read; humans decide; humans act.
34
+
35
+ /**
36
+ * Compute per-skill usage delta from a single review's structured JSON.
37
+ *
38
+ * @param {object} reviewJson - Parsed structured-output JSON from one
39
+ * clud-bug review. Expected shape (subset of review-schema.js):
40
+ * - per_skill_scan: [{ skill, outcome }, ...]
41
+ * - critical_findings: [{ skill, ... }, ...]
42
+ * - minor_findings: [{ skill, ... }, ...]
43
+ * - dedicated_sections: [{ skill, findings: [...] }, ...]
44
+ *
45
+ * @returns {object} - Per-skill delta:
46
+ * { "<slug>": { loads: 1, citations: 0|1 } }
47
+ *
48
+ * Rules:
49
+ * - loads = 1 for every skill in per_skill_scan (the skill was in
50
+ * context for this review).
51
+ * - citations = 1 if the skill slug appears in ANY finding bucket
52
+ * (critical / minor / dedicated). Multiple findings from the same
53
+ * skill on one review = 1 citation, not N. Citations count REVIEWS
54
+ * that cited the skill, not findings within a review.
55
+ *
56
+ * Returns {} on missing / malformed input (defensive — never throws).
57
+ */
58
+ export function computeSkillUsageDelta(reviewJson) {
59
+ if (!reviewJson || typeof reviewJson !== 'object') return {};
60
+
61
+ const delta = {};
62
+
63
+ // Loads — one per skill that scanned.
64
+ for (const entry of reviewJson.per_skill_scan || []) {
65
+ if (!entry || typeof entry.skill !== 'string') continue;
66
+ const slug = entry.skill;
67
+ if (!delta[slug]) delta[slug] = { loads: 0, citations: 0 };
68
+ delta[slug].loads = 1;
69
+ }
70
+
71
+ // Citations — collect unique skill slugs across all finding buckets.
72
+ const cited = new Set();
73
+ const collect = (findings) => {
74
+ for (const f of findings || []) {
75
+ if (f && typeof f.skill === 'string') cited.add(f.skill);
76
+ }
77
+ };
78
+ collect(reviewJson.critical_findings);
79
+ collect(reviewJson.minor_findings);
80
+ collect(reviewJson.preexisting_findings);
81
+ for (const section of reviewJson.dedicated_sections || []) {
82
+ collect(section?.findings);
83
+ }
84
+
85
+ for (const slug of cited) {
86
+ if (!delta[slug]) delta[slug] = { loads: 0, citations: 0 };
87
+ delta[slug].citations = 1;
88
+ }
89
+
90
+ return delta;
91
+ }
92
+
93
+ /**
94
+ * Merge a per-review delta into a persistent usage block.
95
+ *
96
+ * @param {object} existing - Current usage block (may be empty/missing).
97
+ * Shape: { "<slug>": { loads: int, citations: int, last_cited: string|null } }
98
+ * @param {object} delta - From computeSkillUsageDelta (above).
99
+ * @param {string|null} timestamp - ISO 8601 timestamp of THIS review
100
+ * (e.g., "2026-05-30T16:22:26Z"). Used to update last_cited when the
101
+ * skill is cited in this review. Pass null to skip the timestamp
102
+ * update (rarely useful — tests primarily).
103
+ *
104
+ * @returns {object} - New merged usage block (does NOT mutate inputs).
105
+ *
106
+ * Semantics:
107
+ * - existing.loads + delta.loads → new.loads (accumulates forever)
108
+ * - existing.citations + delta.citations → new.citations
109
+ * - last_cited updates only when delta.citations > 0 (i.e., cited
110
+ * in THIS review). Stays at the prior value otherwise.
111
+ * - New skills (not in existing) get initialized fresh.
112
+ */
113
+ export function mergeSkillUsage(existing, delta, timestamp) {
114
+ const safeExisting = (existing && typeof existing === 'object') ? existing : {};
115
+ const result = {};
116
+
117
+ // Copy all existing skills first (preserve skills NOT in this delta).
118
+ for (const [slug, entry] of Object.entries(safeExisting)) {
119
+ if (entry && typeof entry === 'object') {
120
+ result[slug] = {
121
+ loads: Number(entry.loads) || 0,
122
+ citations: Number(entry.citations) || 0,
123
+ last_cited: entry.last_cited || null,
124
+ };
125
+ }
126
+ }
127
+
128
+ // Merge delta.
129
+ for (const [slug, d] of Object.entries(delta || {})) {
130
+ if (!result[slug]) {
131
+ result[slug] = { loads: 0, citations: 0, last_cited: null };
132
+ }
133
+ result[slug].loads += Number(d.loads) || 0;
134
+ result[slug].citations += Number(d.citations) || 0;
135
+ if ((Number(d.citations) || 0) > 0 && timestamp) {
136
+ result[slug].last_cited = timestamp;
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * Apply deterministic skill-health thresholds to a usage block.
145
+ *
146
+ * @param {object} usage - The usage block from mergeSkillUsage.
147
+ * @param {Date} now - The current time (injected for testability).
148
+ *
149
+ * @returns {object[]} - Sorted array of:
150
+ * { slug, status, loads, citations, last_cited, days_since_cited }
151
+ *
152
+ * Status values:
153
+ * - "archive-candidate": citations == 0 AND loads >= 5
154
+ * → loaded enough to judge, never cited → propose for removal
155
+ * - "stale": last_cited > 60 days ago (even with citations history)
156
+ * → was useful, hasn't fired recently
157
+ * - "new": loads < 5
158
+ * → still bedding in; don't judge yet
159
+ * - "healthy": cited within 60 days
160
+ * → still earning its place
161
+ *
162
+ * Sorted by status priority (archive > stale > new > healthy), then
163
+ * by loads desc within each group. Highest-noise skills surface first.
164
+ */
165
+ export function assessSkillHealth(usage, now) {
166
+ const safeUsage = (usage && typeof usage === 'object') ? usage : {};
167
+ const safeNow = (now instanceof Date) ? now : new Date();
168
+ const sixtyDaysAgoMs = safeNow.getTime() - (60 * 24 * 60 * 60 * 1000);
169
+
170
+ const rows = [];
171
+ for (const [slug, entry] of Object.entries(safeUsage)) {
172
+ if (!entry || typeof entry !== 'object') continue;
173
+
174
+ const loads = Number(entry.loads) || 0;
175
+ const citations = Number(entry.citations) || 0;
176
+ const last_cited = entry.last_cited || null;
177
+
178
+ let status;
179
+ let days_since_cited = null;
180
+
181
+ if (loads < 5) {
182
+ status = 'new';
183
+ } else if (citations === 0) {
184
+ status = 'archive-candidate';
185
+ } else {
186
+ // Has citations. Check recency.
187
+ const lastCitedMs = last_cited ? Date.parse(last_cited) : null;
188
+ if (lastCitedMs && lastCitedMs >= sixtyDaysAgoMs) {
189
+ status = 'healthy';
190
+ days_since_cited = Math.floor((safeNow.getTime() - lastCitedMs) / (24 * 60 * 60 * 1000));
191
+ } else if (lastCitedMs) {
192
+ status = 'stale';
193
+ days_since_cited = Math.floor((safeNow.getTime() - lastCitedMs) / (24 * 60 * 60 * 1000));
194
+ } else {
195
+ // Has citations count but no timestamp (legacy / corrupted) — treat as stale.
196
+ status = 'stale';
197
+ }
198
+ }
199
+
200
+ rows.push({ slug, status, loads, citations, last_cited, days_since_cited });
201
+ }
202
+
203
+ // Sort: archive-candidates first, then stale, then new, then healthy.
204
+ // Within each group, by loads descending (loudest first).
205
+ const statusOrder = { 'archive-candidate': 0, 'stale': 1, 'new': 2, 'healthy': 3 };
206
+ rows.sort((a, b) => {
207
+ const da = statusOrder[a.status] ?? 99;
208
+ const db = statusOrder[b.status] ?? 99;
209
+ if (da !== db) return da - db;
210
+ return b.loads - a.loads;
211
+ });
212
+
213
+ return rows;
214
+ }
215
+
216
+
217
+ /**
218
+ * Render the health dashboard as a 3-column table for the CLI.
219
+ *
220
+ * @param {object[]} rows - Output of assessSkillHealth.
221
+ * @returns {string} - Multi-line markdown-ish table for stdout.
222
+ */
223
+ export function formatHealthDashboard(rows) {
224
+ if (!rows || rows.length === 0) {
225
+ return (
226
+ 'Skill health: no usage data yet.\n\n' +
227
+ 'Usage data accumulates after clud-bug reviews land in your repo.\n' +
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.'
231
+ );
232
+ }
233
+
234
+ const STATUS_GLYPH = {
235
+ 'archive-candidate': '🟥 archive?',
236
+ 'stale': '🟨 stale',
237
+ 'new': '🟦 new',
238
+ 'healthy': '🟩 healthy',
239
+ };
240
+
241
+ const lines = [];
242
+ lines.push('Skill health (deterministic — read-only; no automation acts on this)');
243
+ lines.push('');
244
+ lines.push(' STATUS SLUG LOADS CITES LAST CITED');
245
+ lines.push(' ---------------- -------------------------------- ----- ----- --------------');
246
+ for (const r of rows) {
247
+ const status = STATUS_GLYPH[r.status] || r.status;
248
+ const slug = r.slug.length > 32 ? r.slug.slice(0, 29) + '...' : r.slug;
249
+ const ago = r.days_since_cited != null ? `${r.days_since_cited}d ago` : '(never)';
250
+ lines.push(
251
+ ` ${status.padEnd(16)} ${slug.padEnd(32)} ${String(r.loads).padStart(5)} ` +
252
+ `${String(r.citations).padStart(5)} ${ago}`
253
+ );
254
+ }
255
+ lines.push('');
256
+ lines.push('Thresholds:');
257
+ lines.push(' archive-candidate = citations==0 + loads>=5');
258
+ lines.push(' stale = last cited >60 days ago');
259
+ lines.push(' new = loads<5 (still bedding in)');
260
+ lines.push(' healthy = cited within 60 days');
261
+ return lines.join('\n');
262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
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.27
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
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.27
366
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
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.27
630
+ uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.29
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