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 +13 -0
- package/bin/clud-bug.js +159 -0
- package/lib/skill-usage.js +262 -0
- package/package.json +1 -1
- package/templates/workflow-py.yml.tmpl +25 -1
- package/templates/workflow-ts.yml.tmpl +25 -1
- package/templates/workflow.yml.tmpl +39 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|