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 +220 -25
- package/lib/skill-usage.js +173 -2
- 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/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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
decide
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
884
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1087
|
+
`Run \`npx clud-bug init\` first OR pass --repo owner/name to read from workflow artifacts.\n`
|
|
901
1088
|
);
|
|
902
|
-
|
|
1089
|
+
return null;
|
|
903
1090
|
}
|
|
904
1091
|
process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
|
|
905
|
-
|
|
1092
|
+
return null;
|
|
906
1093
|
}
|
|
1094
|
+
}
|
|
907
1095
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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.
|
package/lib/skill-usage.js
CHANGED
|
@@ -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
|
-
'
|
|
229
|
-
'
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|