clud-bug 0.6.34 → 0.7.0-rc.11
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 +20 -0
- package/bin/clud-bug.js +10 -1353
- package/data/canonical-v1.json +37 -0
- package/dist/cli/agents-md.d.ts +16 -0
- package/dist/cli/agents-md.d.ts.map +1 -0
- package/dist/cli/agents-md.js +226 -0
- package/dist/cli/agents-md.js.map +1 -0
- package/dist/cli/audit.d.ts +13 -0
- package/dist/cli/audit.d.ts.map +1 -0
- package/dist/cli/audit.js +90 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/branch-protection.d.ts +57 -0
- package/dist/cli/branch-protection.d.ts.map +1 -0
- package/dist/cli/branch-protection.js +118 -0
- package/dist/cli/branch-protection.js.map +1 -0
- package/dist/cli/configure-github.d.ts +58 -0
- package/dist/cli/configure-github.d.ts.map +1 -0
- package/dist/cli/configure-github.js +242 -0
- package/dist/cli/configure-github.js.map +1 -0
- package/dist/cli/edit-workflow.d.ts +18 -0
- package/dist/cli/edit-workflow.d.ts.map +1 -0
- package/dist/cli/edit-workflow.js +43 -0
- package/dist/cli/edit-workflow.js.map +1 -0
- package/dist/cli/index.d.ts +9 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +2067 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/skill-usage.d.ts +109 -0
- package/dist/cli/skill-usage.d.ts.map +1 -0
- package/dist/cli/skill-usage.js +380 -0
- package/dist/cli/skill-usage.js.map +1 -0
- package/dist/cli/skills.d.ts +56 -0
- package/dist/cli/skills.d.ts.map +1 -0
- package/dist/cli/skills.js +292 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/update.d.ts +29 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +205 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/usage.d.ts +142 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +348 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/core/audit.d.ts +8 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +47 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/auto-resolve.d.ts +235 -0
- package/dist/core/auto-resolve.d.ts.map +1 -0
- package/dist/core/auto-resolve.js +293 -0
- package/dist/core/auto-resolve.js.map +1 -0
- package/dist/core/configure-github.d.ts +179 -0
- package/dist/core/configure-github.d.ts.map +1 -0
- package/dist/core/configure-github.js +289 -0
- package/dist/core/configure-github.js.map +1 -0
- package/dist/core/detect.d.ts +77 -0
- package/dist/core/detect.d.ts.map +1 -0
- package/dist/core/detect.js +262 -0
- package/dist/core/detect.js.map +1 -0
- package/dist/core/diff-findings.d.ts +96 -0
- package/dist/core/diff-findings.d.ts.map +1 -0
- package/dist/core/diff-findings.js +261 -0
- package/dist/core/diff-findings.js.map +1 -0
- package/dist/core/formal-review.d.ts +79 -0
- package/dist/core/formal-review.d.ts.map +1 -0
- package/dist/core/formal-review.js +71 -0
- package/dist/core/formal-review.js.map +1 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +66 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/inline-threads.d.ts +160 -0
- package/dist/core/inline-threads.d.ts.map +1 -0
- package/dist/core/inline-threads.js +369 -0
- package/dist/core/inline-threads.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +164 -0
- package/dist/core/prompt-builder.d.ts.map +1 -0
- package/dist/core/prompt-builder.js +419 -0
- package/dist/core/prompt-builder.js.map +1 -0
- package/dist/core/prompts.d.ts +9 -0
- package/dist/core/prompts.d.ts.map +1 -0
- package/dist/core/prompts.js +401 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/render-review.d.ts +6 -0
- package/dist/core/render-review.d.ts.map +1 -0
- package/dist/core/render-review.js +219 -0
- package/dist/core/render-review.js.map +1 -0
- package/dist/core/render.d.ts +13 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +62 -0
- package/dist/core/render.js.map +1 -0
- package/dist/core/resolve-verifier.d.ts +46 -0
- package/dist/core/resolve-verifier.d.ts.map +1 -0
- package/dist/core/resolve-verifier.js +187 -0
- package/dist/core/resolve-verifier.js.map +1 -0
- package/dist/core/review-schema-zod.d.ts +240 -0
- package/dist/core/review-schema-zod.d.ts.map +1 -0
- package/dist/core/review-schema-zod.js +218 -0
- package/dist/core/review-schema-zod.js.map +1 -0
- package/dist/core/review-schema.d.ts +42 -0
- package/dist/core/review-schema.d.ts.map +1 -0
- package/dist/core/review-schema.js +156 -0
- package/dist/core/review-schema.js.map +1 -0
- package/dist/core/review-writeback.d.ts +193 -0
- package/dist/core/review-writeback.d.ts.map +1 -0
- package/dist/core/review-writeback.js +364 -0
- package/dist/core/review-writeback.js.map +1 -0
- package/dist/core/skills.d.ts +147 -0
- package/dist/core/skills.d.ts.map +1 -0
- package/dist/core/skills.js +726 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +4 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +37 -5
- package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
- package/{lib/audit.js → src/cli/audit.ts} +37 -44
- package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
- package/src/cli/configure-github.ts +329 -0
- package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
- package/src/cli/index.ts +107 -0
- package/src/cli/main.ts +2204 -0
- package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
- package/src/cli/skills.ts +386 -0
- package/{lib/update.js → src/cli/update.ts} +87 -27
- package/{lib/usage.js → src/cli/usage.ts} +167 -76
- package/src/core/audit.ts +53 -0
- package/src/core/auto-resolve.ts +488 -0
- package/src/core/configure-github.ts +497 -0
- package/{lib/detect.js → src/core/detect.ts} +100 -47
- package/src/core/diff-findings.ts +323 -0
- package/src/core/formal-review.ts +168 -0
- package/src/core/index.ts +250 -0
- package/src/core/inline-threads.ts +471 -0
- package/src/core/prompt-builder.ts +561 -0
- package/{lib/prompts.js → src/core/prompts.ts} +16 -2
- package/{lib/render-review.js → src/core/render-review.ts} +57 -25
- package/{lib/render.js → src/core/render.ts} +37 -17
- package/src/core/resolve-verifier.ts +228 -0
- package/src/core/review-schema-zod.ts +262 -0
- package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
- package/src/core/review-writeback.ts +565 -0
- package/{lib/skills.js → src/core/skills.ts} +456 -342
- package/src/core/version.ts +3 -0
- package/templates/clud-bug-review.md.tmpl +115 -0
- package/templates/workflow-py.yml.tmpl +80 -3
- package/templates/workflow-ts.yml.tmpl +80 -3
- package/templates/workflow.yml.tmpl +175 -8
|
@@ -1,34 +1,86 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
// Pure skill helpers — no FS, only the network via injectable fetch.
|
|
2
|
+
//
|
|
3
|
+
// Split from lib/skills.js during the v0.7.0 TS migration. The App
|
|
4
|
+
// (clud-bug-app) consumes these for review-time skill routing without
|
|
5
|
+
// pulling node:fs into the serverless bundle. CLI-only install/update
|
|
6
|
+
// helpers live in src/cli/skills.ts.
|
|
7
|
+
//
|
|
8
|
+
// The `_internal` debug-export pattern from lib/skills.js is removed
|
|
9
|
+
// here: every helper that needed test access has been promoted to a
|
|
10
|
+
// direct named export. Constants (`MAX_SKILLS`, `API_BASE`) and the
|
|
11
|
+
// shape normaliser (`normalizeList`) are now first-class core exports.
|
|
12
|
+
|
|
13
|
+
export const API_BASE = 'https://skills.sh/api/v1';
|
|
14
|
+
export const MAX_SKILLS = 8;
|
|
15
|
+
|
|
16
|
+
// Skill descriptors as returned by skills.sh after normaliseList()
|
|
17
|
+
// massages the API shape. The CLI install path adds further fields
|
|
18
|
+
// (`kind`, `content`) before passing to writeSkill — we type the
|
|
19
|
+
// result loosely with optional fields so a single shape works for
|
|
20
|
+
// both core (search results, ranking) and cli (write+manifest) sites.
|
|
21
|
+
export interface SkillDescriptor {
|
|
22
|
+
source: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
installs: number;
|
|
26
|
+
kind?: string;
|
|
27
|
+
content?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Raw shape coming back from skills.sh. Tolerant: some endpoints return
|
|
31
|
+
// { skills: [...] }, some a bare array, some { results: [...] }, and
|
|
32
|
+
// individual items may use `source` or `repo`, `name` or `slug`.
|
|
33
|
+
type RawSkillItem = {
|
|
34
|
+
source?: string;
|
|
35
|
+
repo?: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
slug?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
summary?: string;
|
|
40
|
+
installs?: number;
|
|
41
|
+
installCount?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type RawSkillListResponse =
|
|
45
|
+
| RawSkillItem[]
|
|
46
|
+
| { skills?: RawSkillItem[]; results?: RawSkillItem[] }
|
|
47
|
+
| unknown;
|
|
48
|
+
|
|
49
|
+
export function normalizeList(data: RawSkillListResponse): SkillDescriptor[] {
|
|
50
|
+
// Tolerate either { skills: [...] } or a bare array.
|
|
51
|
+
const list: RawSkillItem[] = Array.isArray(data)
|
|
52
|
+
? data
|
|
53
|
+
: ((data as { skills?: RawSkillItem[]; results?: RawSkillItem[] } | null)?.skills
|
|
54
|
+
|| (data as { results?: RawSkillItem[] } | null)?.results
|
|
55
|
+
|| []);
|
|
56
|
+
return list
|
|
57
|
+
.map((item) => ({
|
|
58
|
+
source: item.source || item.repo || '',
|
|
59
|
+
name: item.name || item.slug || '',
|
|
60
|
+
description: item.description || item.summary || '',
|
|
61
|
+
installs: item.installs || item.installCount || 0,
|
|
62
|
+
}))
|
|
63
|
+
.filter((s) => s.source && s.name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SkillsClientOptions {
|
|
67
|
+
fetch?: typeof globalThis.fetch;
|
|
68
|
+
base?: string;
|
|
69
|
+
userAgent?: string;
|
|
70
|
+
}
|
|
23
71
|
|
|
24
72
|
export class SkillsClient {
|
|
25
|
-
|
|
73
|
+
private readonly fetch: typeof globalThis.fetch;
|
|
74
|
+
private readonly base: string;
|
|
75
|
+
private readonly userAgent: string;
|
|
76
|
+
|
|
77
|
+
constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' }: SkillsClientOptions = {}) {
|
|
26
78
|
this.fetch = fetch;
|
|
27
|
-
this.base = base ?? process.env
|
|
79
|
+
this.base = base ?? process.env['CLUD_BUG_SKILLS_SH_BASE'] ?? API_BASE;
|
|
28
80
|
this.userAgent = userAgent;
|
|
29
81
|
}
|
|
30
82
|
|
|
31
|
-
async #json(path) {
|
|
83
|
+
async #json(path: string): Promise<unknown> {
|
|
32
84
|
const res = await this.fetch(`${this.base}${path}`, {
|
|
33
85
|
headers: { 'User-Agent': this.userAgent, accept: 'application/json' },
|
|
34
86
|
});
|
|
@@ -38,45 +90,48 @@ export class SkillsClient {
|
|
|
38
90
|
return res.json();
|
|
39
91
|
}
|
|
40
92
|
|
|
41
|
-
async search(terms) {
|
|
93
|
+
async search(terms: string[]): Promise<SkillDescriptor[]> {
|
|
42
94
|
const q = terms.filter(Boolean).join(' ').trim();
|
|
43
95
|
if (!q) return [];
|
|
44
96
|
const data = await this.#json(`/skills/search?q=${encodeURIComponent(q)}`);
|
|
45
97
|
return normalizeList(data);
|
|
46
98
|
}
|
|
47
99
|
|
|
48
|
-
async curated() {
|
|
100
|
+
async curated(): Promise<SkillDescriptor[]> {
|
|
49
101
|
const data = await this.#json('/skills/curated');
|
|
50
102
|
return normalizeList(data);
|
|
51
103
|
}
|
|
52
104
|
|
|
53
|
-
async getContent(source, name) {
|
|
54
|
-
const data = await this.#json(
|
|
105
|
+
async getContent(source: string, name: string): Promise<string> {
|
|
106
|
+
const data = (await this.#json(
|
|
107
|
+
`/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`,
|
|
108
|
+
)) as { content?: unknown; body?: unknown; files?: Array<{ content?: unknown }> } | null;
|
|
55
109
|
// The API may return content as `body`, `content`, or under `files[0].content`.
|
|
56
110
|
// Try the documented shapes in order; fail loudly if none match so we know
|
|
57
111
|
// the API contract changed.
|
|
58
112
|
if (typeof data?.content === 'string') return data.content;
|
|
59
113
|
if (typeof data?.body === 'string') return data.body;
|
|
60
|
-
|
|
114
|
+
const first = data?.files?.[0]?.content;
|
|
115
|
+
if (typeof first === 'string') return first;
|
|
61
116
|
throw new Error(`skills.sh response for ${source}/${name} had no content field`);
|
|
62
117
|
}
|
|
63
118
|
}
|
|
64
119
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
source: item.source || item.repo || '',
|
|
70
|
-
name: item.name || item.slug || '',
|
|
71
|
-
description: item.description || item.summary || '',
|
|
72
|
-
installs: item.installs || item.installCount || 0,
|
|
73
|
-
})).filter(s => s.source && s.name);
|
|
120
|
+
// Used by rankAndCap input. Allows mixing baseline skills (which carry a
|
|
121
|
+
// `kind: 'baseline'`) with remote ones. Matches the cli-side shape too.
|
|
122
|
+
export interface RankableSkill extends SkillDescriptor {
|
|
123
|
+
kind?: string;
|
|
74
124
|
}
|
|
75
125
|
|
|
76
126
|
// Deduplicates by source/name and caps at MAX_SKILLS, preferring curated then by install count.
|
|
77
|
-
export function rankAndCap(
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
export function rankAndCap(
|
|
128
|
+
curated: RankableSkill[],
|
|
129
|
+
searched: RankableSkill[],
|
|
130
|
+
baseline: RankableSkill[],
|
|
131
|
+
cap: number = MAX_SKILLS,
|
|
132
|
+
): RankableSkill[] {
|
|
133
|
+
const seen = new Set(baseline.map((b) => `local:${b.name}`));
|
|
134
|
+
const out: RankableSkill[] = [...baseline];
|
|
80
135
|
const remaining = cap - baseline.length;
|
|
81
136
|
if (remaining <= 0) return out.slice(0, cap);
|
|
82
137
|
|
|
@@ -93,261 +148,6 @@ export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
|
|
|
93
148
|
return out;
|
|
94
149
|
}
|
|
95
150
|
|
|
96
|
-
// Loads the baseline skills, preferring the pinned thrillmade/agent-skills
|
|
97
|
-
// commit and falling back to the bundled npm-package copy on any fetch failure.
|
|
98
|
-
// Returns the same shape as before, plus a `_source` of either 'agent-skills'
|
|
99
|
-
// or 'bundled' so the CLI can report which path was used.
|
|
100
|
-
//
|
|
101
|
-
// Options:
|
|
102
|
-
// - fetch — injectable for tests (defaults to globalThis.fetch)
|
|
103
|
-
// - cacheDir — where to cache fetched SKILL.md files (defaults to
|
|
104
|
-
// ~/.cache/clud-bug/skills/, skipped if null)
|
|
105
|
-
export async function loadBaseline(baselineDir, opts = {}) {
|
|
106
|
-
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
107
|
-
const cacheDir = opts.cacheDir === null ? null
|
|
108
|
-
: (opts.cacheDir ?? join(homedir(), '.cache', 'clud-bug', 'skills'));
|
|
109
|
-
|
|
110
|
-
// First, enumerate the bundled baseline skills (source of truth for which
|
|
111
|
-
// names exist). Then fetch each in parallel — sequential awaits would
|
|
112
|
-
// stack timeouts (3 baselines × 5s = 15s before fallback when offline).
|
|
113
|
-
const bundled = await readBundled(baselineDir);
|
|
114
|
-
const remotes = await Promise.all(
|
|
115
|
-
bundled.map((s) => tryFetchSkill(s.name, fetchImpl, cacheDir)),
|
|
116
|
-
);
|
|
117
|
-
return bundled.map((skill, i) => remotes[i]
|
|
118
|
-
? { ...skill, content: remotes[i], _source: 'agent-skills' }
|
|
119
|
-
: { ...skill, _source: 'bundled' });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Reads the bundled baseline from the npm-package directory.
|
|
123
|
-
async function readBundled(baselineDir) {
|
|
124
|
-
const skills = [];
|
|
125
|
-
let entries;
|
|
126
|
-
try {
|
|
127
|
-
entries = await readdir(baselineDir, { withFileTypes: true });
|
|
128
|
-
} catch {
|
|
129
|
-
return skills;
|
|
130
|
-
}
|
|
131
|
-
for (const entry of entries) {
|
|
132
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
133
|
-
const content = await readFile(join(baselineDir, entry.name), 'utf8');
|
|
134
|
-
skills.push({
|
|
135
|
-
source: 'clud-bug-baseline',
|
|
136
|
-
name: entry.name.replace(/\.md$/, ''),
|
|
137
|
-
description: '(baseline)',
|
|
138
|
-
installs: 0,
|
|
139
|
-
kind: 'baseline',
|
|
140
|
-
content,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
return skills;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Try to read from cache, then fall back to network. Returns the SKILL.md
|
|
147
|
-
// content string on success, null on any failure (caller falls back to bundled).
|
|
148
|
-
async function tryFetchSkill(name, fetchImpl, cacheDir) {
|
|
149
|
-
// Cache lookup first.
|
|
150
|
-
if (cacheDir) {
|
|
151
|
-
const cached = await readFromCache(cacheDir, name);
|
|
152
|
-
if (cached !== null) return cached;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Network fetch with timeout covering BOTH the connection AND the body
|
|
156
|
-
// read (clearTimeout in finally guarantees the timer doesn't keep the
|
|
157
|
-
// event loop alive for up to 5s past a failed CLI run).
|
|
158
|
-
const url = `${AGENT_SKILLS_BASE}/${encodeURIComponent(name)}/SKILL.md`;
|
|
159
|
-
const ctrl = new AbortController();
|
|
160
|
-
const timer = setTimeout(() => ctrl.abort(), SKILL_FETCH_TIMEOUT_MS);
|
|
161
|
-
try {
|
|
162
|
-
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
163
|
-
if (!res.ok) return null;
|
|
164
|
-
const content = await res.text();
|
|
165
|
-
if (!content || !content.trim()) return null;
|
|
166
|
-
if (cacheDir) await writeToCache(cacheDir, name, content);
|
|
167
|
-
return content;
|
|
168
|
-
} catch {
|
|
169
|
-
return null;
|
|
170
|
-
} finally {
|
|
171
|
-
clearTimeout(timer);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function readFromCache(cacheDir, name) {
|
|
176
|
-
const path = cachePath(cacheDir, name);
|
|
177
|
-
try {
|
|
178
|
-
const st = await stat(path);
|
|
179
|
-
if (Date.now() - st.mtimeMs > SKILL_CACHE_TTL_MS) return null;
|
|
180
|
-
return await readFile(path, 'utf8');
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function writeToCache(cacheDir, name, content) {
|
|
187
|
-
try {
|
|
188
|
-
await mkdir(cacheDir, { recursive: true });
|
|
189
|
-
await writeFile(cachePath(cacheDir, name), content);
|
|
190
|
-
} catch {
|
|
191
|
-
// Cache write failures are non-fatal — we already have the content.
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function cachePath(cacheDir, name) {
|
|
196
|
-
// Include AGENT_SKILLS_BASE in the hash so different upstream URLs (e.g.
|
|
197
|
-
// a fork via CLUD_BUG_AGENT_SKILLS_BASE, or a different pinned SHA after
|
|
198
|
-
// a clud-bug release) get different cache entries. Otherwise switching
|
|
199
|
-
// bases would silently return the previously-cached content from a
|
|
200
|
-
// different upstream — cross-base cache poisoning.
|
|
201
|
-
const hash = createHash('sha256').update(`${AGENT_SKILLS_BASE}\n${name}`).digest('hex').slice(0, 16);
|
|
202
|
-
return join(cacheDir, `${hash}.md`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export async function writeSkills(targetDir, skills, client) {
|
|
206
|
-
await mkdir(targetDir, { recursive: true });
|
|
207
|
-
const written = [];
|
|
208
|
-
for (const skill of skills) {
|
|
209
|
-
const entry = await writeSkill(targetDir, skill, client);
|
|
210
|
-
written.push(entry);
|
|
211
|
-
}
|
|
212
|
-
await writeManifest(targetDir, mergeManifest(await readManifest(targetDir), written));
|
|
213
|
-
return written;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function writeSkill(targetDir, skill, client) {
|
|
217
|
-
await mkdir(targetDir, { recursive: true });
|
|
218
|
-
const slug = sanitizeSlug(skill.name);
|
|
219
|
-
const skillDir = join(targetDir, slug);
|
|
220
|
-
await mkdir(skillDir, { recursive: true });
|
|
221
|
-
const content = skill.content ?? await client.getContent(skill.source, skill.name);
|
|
222
|
-
await writeFile(join(skillDir, 'SKILL.md'), content);
|
|
223
|
-
return {
|
|
224
|
-
slug,
|
|
225
|
-
name: skill.name,
|
|
226
|
-
source: skill.source,
|
|
227
|
-
kind: skill.kind || 'remote',
|
|
228
|
-
description: skill.description || '',
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export async function readManifest(targetDir) {
|
|
233
|
-
try {
|
|
234
|
-
const text = await readFile(join(targetDir, MANIFEST_FILE), 'utf8');
|
|
235
|
-
const data = JSON.parse(text);
|
|
236
|
-
return {
|
|
237
|
-
...data,
|
|
238
|
-
version: data.version || MANIFEST_VERSION,
|
|
239
|
-
installed: Array.isArray(data.installed) ? data.installed : [],
|
|
240
|
-
};
|
|
241
|
-
} catch {
|
|
242
|
-
return { version: MANIFEST_VERSION, installed: [] };
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export async function writeManifest(targetDir, manifest) {
|
|
247
|
-
await mkdir(targetDir, { recursive: true });
|
|
248
|
-
// Preserve any additional fields callers want to stamp (e.g. lastUpdate,
|
|
249
|
-
// lastUpdateVersion, pinVersion). Only `version` and `installed` are normalized.
|
|
250
|
-
const out = {
|
|
251
|
-
...manifest,
|
|
252
|
-
version: manifest.version || MANIFEST_VERSION,
|
|
253
|
-
installed: manifest.installed || [],
|
|
254
|
-
};
|
|
255
|
-
await writeFile(join(targetDir, MANIFEST_FILE), JSON.stringify(out, null, 2) + '\n');
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function mergeManifest(existing, newEntries) {
|
|
259
|
-
const byKey = new Map();
|
|
260
|
-
for (const entry of existing.installed || []) {
|
|
261
|
-
byKey.set(entryKey(entry), entry);
|
|
262
|
-
}
|
|
263
|
-
for (const entry of newEntries) {
|
|
264
|
-
byKey.set(entryKey(entry), entry);
|
|
265
|
-
}
|
|
266
|
-
// Spread `existing` so caller-set fields (pinVersion, lastUpdate,
|
|
267
|
-
// lastUpdateVersion, etc.) survive merges performed by writeSkills /
|
|
268
|
-
// refresh / add. Only `installed` is rebuilt; everything else carries.
|
|
269
|
-
return { ...existing, version: MANIFEST_VERSION, installed: [...byKey.values()] };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function entryKey(entry) {
|
|
273
|
-
// Baseline skills have no source; key by slug. Remote skills key by source/name.
|
|
274
|
-
return entry.kind === 'baseline' ? `baseline:${entry.slug}` : `${entry.source}/${entry.name || entry.slug}`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export async function removeSkill(targetDir, slug) {
|
|
278
|
-
const manifest = await readManifest(targetDir);
|
|
279
|
-
const entry = manifest.installed.find(e => e.slug === slug);
|
|
280
|
-
if (!entry) {
|
|
281
|
-
throw new Error(`'${slug}' is not in the clud-bug manifest. If it's a custom skill, delete it manually with: rm -rf .claude/skills/${slug}`);
|
|
282
|
-
}
|
|
283
|
-
await rm(join(targetDir, slug), { recursive: true, force: true });
|
|
284
|
-
manifest.installed = manifest.installed.filter(e => e.slug !== slug);
|
|
285
|
-
await writeManifest(targetDir, manifest);
|
|
286
|
-
return entry;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export async function listInstalled(targetDir) {
|
|
290
|
-
const manifest = await readManifest(targetDir);
|
|
291
|
-
const managedSlugs = new Set(manifest.installed.map(e => e.slug));
|
|
292
|
-
const groups = { baseline: [], remote: [], custom: [] };
|
|
293
|
-
for (const entry of manifest.installed) {
|
|
294
|
-
(groups[entry.kind === 'baseline' ? 'baseline' : 'remote']).push(entry);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
let entries;
|
|
298
|
-
try {
|
|
299
|
-
entries = await readdir(targetDir, { withFileTypes: true });
|
|
300
|
-
} catch {
|
|
301
|
-
return groups;
|
|
302
|
-
}
|
|
303
|
-
for (const entry of entries) {
|
|
304
|
-
if (!entry.isDirectory()) continue;
|
|
305
|
-
if (managedSlugs.has(entry.name)) continue;
|
|
306
|
-
const skillFile = join(targetDir, entry.name, 'SKILL.md');
|
|
307
|
-
let description = '';
|
|
308
|
-
try {
|
|
309
|
-
const text = await readFile(skillFile, 'utf8');
|
|
310
|
-
const m = text.match(/^description:\s*(.+)$/m);
|
|
311
|
-
description = m?.[1]?.trim() || '';
|
|
312
|
-
} catch {
|
|
313
|
-
continue; // not a skill dir
|
|
314
|
-
}
|
|
315
|
-
groups.custom.push({ slug: entry.name, kind: 'custom', description });
|
|
316
|
-
}
|
|
317
|
-
return groups;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Diff a current manifest against a freshly-recommended skill set.
|
|
321
|
-
// Returns { add: [], remove: [], unchanged: [] }. Custom skills are never affected.
|
|
322
|
-
export function diffManifest(manifest, recommended) {
|
|
323
|
-
const recByKey = new Map(recommended.map(s => [
|
|
324
|
-
s.kind === 'baseline' ? `baseline:${sanitizeSlug(s.name)}` : `${s.source}/${s.name}`,
|
|
325
|
-
s,
|
|
326
|
-
]));
|
|
327
|
-
const installedByKey = new Map(manifest.installed.map(e => [entryKey(e), e]));
|
|
328
|
-
|
|
329
|
-
const add = [];
|
|
330
|
-
const remove = [];
|
|
331
|
-
const unchanged = [];
|
|
332
|
-
|
|
333
|
-
for (const [key, skill] of recByKey) {
|
|
334
|
-
if (installedByKey.has(key)) {
|
|
335
|
-
unchanged.push(skill);
|
|
336
|
-
} else {
|
|
337
|
-
add.push(skill);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
for (const [key, entry] of installedByKey) {
|
|
341
|
-
if (entry.kind === 'baseline') continue; // baseline always stays
|
|
342
|
-
if (!recByKey.has(key)) remove.push(entry);
|
|
343
|
-
}
|
|
344
|
-
return { add, remove, unchanged };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function sanitizeSlug(name) {
|
|
348
|
-
return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
349
|
-
}
|
|
350
|
-
|
|
351
151
|
// Extract the `review_mode` field from a SKILL.md's frontmatter.
|
|
352
152
|
//
|
|
353
153
|
// Contract (from the v0.6 plan, option D-unified):
|
|
@@ -367,22 +167,27 @@ function sanitizeSlug(name) {
|
|
|
367
167
|
// The CLI runtime (v0.5.9) honors this via prompt restructuring inside a
|
|
368
168
|
// single claude-code-action call. The v0.6 GitHub App will use the same
|
|
369
169
|
// field to route to literal parallel API calls. Single source of truth.
|
|
370
|
-
export function readReviewMode(skillContent) {
|
|
170
|
+
export function readReviewMode(skillContent: unknown): 'shared' | 'dedicated' {
|
|
371
171
|
if (typeof skillContent !== 'string') return 'shared';
|
|
372
172
|
// Scope to the YAML frontmatter block (between the first two `---` lines).
|
|
373
173
|
// A `review_mode:` line in the body is documentation, not configuration.
|
|
374
174
|
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
375
175
|
if (!fm) return 'shared';
|
|
376
|
-
const m = fm[1].match(/^review_mode:\s*(\S+)\s*$/m);
|
|
176
|
+
const m = (fm[1] as string).match(/^review_mode:\s*(\S+)\s*$/m);
|
|
377
177
|
if (!m) return 'shared';
|
|
378
178
|
// Strip optional YAML string-quotes — `review_mode: "dedicated"` and
|
|
379
179
|
// `review_mode: 'dedicated'` are both valid YAML, but the (\S+) capture
|
|
380
180
|
// grabs the quotes too. Without this, quoted forms silently fell back
|
|
381
181
|
// to `shared` even though the author clearly meant dedicated.
|
|
382
|
-
const value = m[1].toLowerCase().replace(/^["']|["']$/g, '');
|
|
182
|
+
const value = (m[1] as string).toLowerCase().replace(/^["']|["']$/g, '');
|
|
383
183
|
return value === 'dedicated' ? 'dedicated' : 'shared';
|
|
384
184
|
}
|
|
385
185
|
|
|
186
|
+
export interface AppliesToRule {
|
|
187
|
+
paths: string[];
|
|
188
|
+
extensions: string[];
|
|
189
|
+
}
|
|
190
|
+
|
|
386
191
|
// 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
|
|
387
192
|
//
|
|
388
193
|
// Schema:
|
|
@@ -400,11 +205,11 @@ export function readReviewMode(skillContent) {
|
|
|
400
205
|
// Hand-rolled YAML parser scoped to this exact shape. The frontmatter
|
|
401
206
|
// is otherwise opaque (review_mode is parsed elsewhere with a similar
|
|
402
207
|
// single-key regex), so pulling in a YAML dep would be overkill.
|
|
403
|
-
export function readAppliesTo(skillContent) {
|
|
208
|
+
export function readAppliesTo(skillContent: unknown): AppliesToRule | null {
|
|
404
209
|
if (typeof skillContent !== 'string') return null;
|
|
405
210
|
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
406
211
|
if (!fm) return null;
|
|
407
|
-
const block = fm[1];
|
|
212
|
+
const block = fm[1] as string;
|
|
408
213
|
// Anchor on `applies_to:` at start of line (the body of a SKILL.md
|
|
409
214
|
// could mention the term in prose; only the frontmatter key fires).
|
|
410
215
|
const head = block.match(/^applies_to:\s*$/m);
|
|
@@ -412,7 +217,9 @@ export function readAppliesTo(skillContent) {
|
|
|
412
217
|
// Slice from after the `applies_to:` line; the block ends at the
|
|
413
218
|
// next top-level key (a line starting with a word character + `:`)
|
|
414
219
|
// OR end-of-block.
|
|
415
|
-
|
|
220
|
+
// `head.index` is defined here because String.prototype.match returns
|
|
221
|
+
// a RegExpMatchArray with `index` set when the regex is non-global.
|
|
222
|
+
const startIdx = (head.index as number) + head[0].length;
|
|
416
223
|
const rest = block.slice(startIdx);
|
|
417
224
|
const stop = rest.search(/^\w[\w-]*:/m);
|
|
418
225
|
const scoped = stop === -1 ? rest : rest.slice(0, stop);
|
|
@@ -425,10 +232,10 @@ export function readAppliesTo(skillContent) {
|
|
|
425
232
|
// Parse a YAML list under `<key>:`, handling both the inline-array
|
|
426
233
|
// form (`extensions: [".tsx", ".jsx"]`) and the block form
|
|
427
234
|
// (`paths:` followed by ` - "src/ui/**"` lines).
|
|
428
|
-
function parseYamlList(block, key) {
|
|
235
|
+
function parseYamlList(block: string, key: string): string[] {
|
|
429
236
|
const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
|
|
430
237
|
if (inline) {
|
|
431
|
-
return inline[1]
|
|
238
|
+
return (inline[1] as string)
|
|
432
239
|
.split(',')
|
|
433
240
|
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
434
241
|
.filter(Boolean);
|
|
@@ -436,12 +243,12 @@ function parseYamlList(block, key) {
|
|
|
436
243
|
const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
|
|
437
244
|
const head = block.match(headerRe);
|
|
438
245
|
if (!head) return [];
|
|
439
|
-
const after = block.slice(head.index + head[0].length);
|
|
440
|
-
const items = [];
|
|
246
|
+
const after = block.slice((head.index as number) + head[0].length);
|
|
247
|
+
const items: string[] = [];
|
|
441
248
|
for (const line of after.split('\n')) {
|
|
442
249
|
const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
|
|
443
250
|
if (item) {
|
|
444
|
-
items.push(item[1].replace(/^["']|["']$/g, ''));
|
|
251
|
+
items.push((item[1] as string).replace(/^["']|["']$/g, ''));
|
|
445
252
|
continue;
|
|
446
253
|
}
|
|
447
254
|
// Anything that isn't a list item (or blank) ends the list.
|
|
@@ -462,10 +269,10 @@ function parseYamlList(block, key) {
|
|
|
462
269
|
// Skill `paths` use the minimal glob set logmind already uses
|
|
463
270
|
// (`*` matches non-slash, `**` matches across slashes, `?` single
|
|
464
271
|
// char). Anything fancier would need a real glob lib.
|
|
465
|
-
export function appliesToPr(skillContent, prPaths) {
|
|
272
|
+
export function appliesToPr(skillContent: unknown, prPaths: unknown): boolean {
|
|
466
273
|
const rule = readAppliesTo(skillContent);
|
|
467
|
-
if (rule === null) return true;
|
|
468
|
-
if (!Array.isArray(prPaths)) return true;
|
|
274
|
+
if (rule === null) return true; // back-compat: no rule → applies
|
|
275
|
+
if (!Array.isArray(prPaths)) return true; // be permissive on bad input
|
|
469
276
|
for (const path of prPaths) {
|
|
470
277
|
if (typeof path !== 'string') continue;
|
|
471
278
|
for (const ext of rule.extensions) {
|
|
@@ -478,9 +285,63 @@ export function appliesToPr(skillContent, prPaths) {
|
|
|
478
285
|
return false;
|
|
479
286
|
}
|
|
480
287
|
|
|
288
|
+
// v0.7.0-rc.6 (SPEC §1.10.1 v0.5.1+) — does this skill apply to a PR
|
|
289
|
+
// opened by `prAuthor`? Reads `applies_to.author` from the raw SKILL.md
|
|
290
|
+
// frontmatter. Returns:
|
|
291
|
+
// - `true` if `applies_to.author` is absent (no author filter set —
|
|
292
|
+
// skill loads regardless of author; v0.5.0 backward-compat default).
|
|
293
|
+
// - `true` if `applies_to.author === prAuthor` (case-sensitive match
|
|
294
|
+
// against the PR author's GitHub login).
|
|
295
|
+
// - `false` if `applies_to.author` is set to a different login.
|
|
296
|
+
//
|
|
297
|
+
// Designed to mirror `appliesToPr`'s signature (raw skill content +
|
|
298
|
+
// runtime PR context) so consumers can call both filters in a single
|
|
299
|
+
// loader pass. Skills with NO `applies_to` block at all also return
|
|
300
|
+
// `true` (the existing v0.5.0 unconditional-load behavior).
|
|
301
|
+
//
|
|
302
|
+
// Strict-AND composition per SPEC §1.10.1: when a skill sets BOTH
|
|
303
|
+
// `applies_to.author` AND `paths`/`extensions`, callers MUST call BOTH
|
|
304
|
+
// filters and AND the results. This helper only checks the author leg;
|
|
305
|
+
// the caller composes.
|
|
306
|
+
export function appliesToAuthor(
|
|
307
|
+
skillContent: unknown,
|
|
308
|
+
prAuthor: unknown,
|
|
309
|
+
): boolean {
|
|
310
|
+
if (typeof skillContent !== 'string') return true;
|
|
311
|
+
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
312
|
+
if (!fm) return true;
|
|
313
|
+
const block = fm[1] as string;
|
|
314
|
+
// Look for `applies_to:` block; if absent, no author constraint.
|
|
315
|
+
const head = block.match(/^applies_to:\s*$/m);
|
|
316
|
+
if (!head) return true;
|
|
317
|
+
const startIdx = (head.index as number) + head[0].length;
|
|
318
|
+
const rest = block.slice(startIdx);
|
|
319
|
+
// The block ends at the next top-level key (line starting with a
|
|
320
|
+
// word char + `:`) OR end-of-block. Mirrors `readAppliesTo`.
|
|
321
|
+
const stop = rest.search(/^\w[\w-]*:/m);
|
|
322
|
+
const scoped = stop === -1 ? rest : rest.slice(0, stop);
|
|
323
|
+
// Match ` author: <value>` — single string scalar; strip optional
|
|
324
|
+
// quotes. `author: [a, b]` form is intentionally NOT supported
|
|
325
|
+
// (SPEC: "single GitHub login string, no list").
|
|
326
|
+
const authorMatch = scoped.match(/^\s{2}author:\s*(.+?)\s*$/m);
|
|
327
|
+
if (!authorMatch) return true;
|
|
328
|
+
const declared = (authorMatch[1] as string)
|
|
329
|
+
.trim()
|
|
330
|
+
.replace(/^["']|["']$/g, '');
|
|
331
|
+
if (!declared) return true; // empty value treated as unset
|
|
332
|
+
if (typeof prAuthor !== 'string' || !prAuthor) return false;
|
|
333
|
+
// GitHub logins are case-insensitive (GitHub rejects creating two
|
|
334
|
+
// accounts that differ only in case; `pull_request.user.login` is
|
|
335
|
+
// case-preserved but routing is not case-sensitive). Compare
|
|
336
|
+
// case-insensitively so a skill author writing `Thrillmot` still
|
|
337
|
+
// matches a webhook delivering `thrillmot` (or vice versa).
|
|
338
|
+
// Reviewer-flagged Important on PR #179.
|
|
339
|
+
return declared.toLowerCase() === prAuthor.toLowerCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
481
342
|
// Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
|
|
482
343
|
// everything else escaped. Anchored full-string match.
|
|
483
|
-
function globMatch(glob, path) {
|
|
344
|
+
function globMatch(glob: string, path: string): boolean {
|
|
484
345
|
const escaped = glob
|
|
485
346
|
.replace(/([.+^${}()|[\]\\])/g, '\\$1')
|
|
486
347
|
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
@@ -490,6 +351,13 @@ function globMatch(glob, path) {
|
|
|
490
351
|
return new RegExp(`^${escaped}$`).test(path);
|
|
491
352
|
}
|
|
492
353
|
|
|
354
|
+
// Loose shape of a "skill" object as it flows through the runtime. Caller
|
|
355
|
+
// may attach `content` (the SKILL.md text) for partitioning decisions.
|
|
356
|
+
export interface SkillWithOptionalContent {
|
|
357
|
+
content?: unknown;
|
|
358
|
+
[key: string]: unknown;
|
|
359
|
+
}
|
|
360
|
+
|
|
493
361
|
// Partition a set of loaded skills into {shared, dedicated} buckets per
|
|
494
362
|
// each skill's review_mode frontmatter. Expects skills with a `content`
|
|
495
363
|
// field (SKILL.md text). Skills without content default to `shared`.
|
|
@@ -497,9 +365,11 @@ function globMatch(glob, path) {
|
|
|
497
365
|
// Shape: input is the same skill objects produced by loadBaseline /
|
|
498
366
|
// writeSkills / listInstalled. Output is two arrays of the same shape;
|
|
499
367
|
// caller decides what to do with each bucket.
|
|
500
|
-
export function partitionByReviewMode(
|
|
501
|
-
|
|
502
|
-
|
|
368
|
+
export function partitionByReviewMode<T extends SkillWithOptionalContent>(
|
|
369
|
+
skills: T[],
|
|
370
|
+
): { shared: T[]; dedicated: T[] } {
|
|
371
|
+
const shared: T[] = [];
|
|
372
|
+
const dedicated: T[] = [];
|
|
503
373
|
for (const skill of skills) {
|
|
504
374
|
const mode = readReviewMode(skill?.content);
|
|
505
375
|
(mode === 'dedicated' ? dedicated : shared).push(skill);
|
|
@@ -521,7 +391,7 @@ export function partitionByReviewMode(skills) {
|
|
|
521
391
|
//
|
|
522
392
|
// The brackets in the line prefix anchor the match so a partial-name collision
|
|
523
393
|
// (e.g. `brand-voice` finding `brand-voice-review`) is impossible.
|
|
524
|
-
export function extractPerSkillLine(comment, skillName) {
|
|
394
|
+
export function extractPerSkillLine(comment: unknown, skillName: unknown): string | null {
|
|
525
395
|
if (typeof comment !== 'string' || !comment) return null;
|
|
526
396
|
if (typeof skillName !== 'string' || !skillName) return null;
|
|
527
397
|
// Escape regex metacharacters in the skill name. A skill name with a `.` or
|
|
@@ -532,7 +402,16 @@ export function extractPerSkillLine(comment, skillName) {
|
|
|
532
402
|
// dash. The OUTCOME is everything from after `]:` to end-of-line.
|
|
533
403
|
const re = new RegExp(`^\\s*-\\s*\\[${escaped}\\]:\\s*(.+?)\\s*$`, 'm');
|
|
534
404
|
const m = comment.match(re);
|
|
535
|
-
return m ? m[1] : null;
|
|
405
|
+
return m ? (m[1] as string) : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Minimal shape of a PR comment (subset of GitHub's REST schema) used by
|
|
409
|
+
// selectReviewHeader / selectReviewBody. `body` and `user.login` are
|
|
410
|
+
// the only fields we actually read; `created_at` is used for sorting.
|
|
411
|
+
export interface PrComment {
|
|
412
|
+
body?: unknown;
|
|
413
|
+
user?: { login?: unknown };
|
|
414
|
+
created_at?: unknown;
|
|
536
415
|
}
|
|
537
416
|
|
|
538
417
|
// Find the latest clud-bug review header line from a list of PR comments.
|
|
@@ -561,7 +440,7 @@ export function extractPerSkillLine(comment, skillName) {
|
|
|
561
440
|
// quoted sentinels in body text" property: a comment that mentions the
|
|
562
441
|
// strict-mode header in prose (inline-code, blockquote) won't match
|
|
563
442
|
// because the quoted version isn't at start-of-line.
|
|
564
|
-
export function selectReviewHeader(comments, botLogin) {
|
|
443
|
+
export function selectReviewHeader(comments: unknown, botLogin: unknown): string | null {
|
|
565
444
|
if (!Array.isArray(comments)) return null;
|
|
566
445
|
if (typeof botLogin !== 'string' || !botLogin) return null;
|
|
567
446
|
// Sort newest-first by created_at. The composite passes the result of
|
|
@@ -572,9 +451,9 @@ export function selectReviewHeader(comments, botLogin) {
|
|
|
572
451
|
// the newer "— clean" follow-up, so fix-push reviews that resolved
|
|
573
452
|
// critical findings still failed the gate. Explicit sort here makes
|
|
574
453
|
// selection deterministic regardless of upstream API quirks.
|
|
575
|
-
const sorted = [...comments].sort((a, b) => {
|
|
576
|
-
const ta = a?.created_at ? Date.parse(a.created_at) : 0;
|
|
577
|
-
const tb = b?.created_at ? Date.parse(b.created_at) : 0;
|
|
454
|
+
const sorted: PrComment[] = [...(comments as PrComment[])].sort((a, b) => {
|
|
455
|
+
const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
|
|
456
|
+
const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
|
|
578
457
|
return tb - ta; // newest first
|
|
579
458
|
});
|
|
580
459
|
for (const c of sorted) {
|
|
@@ -591,7 +470,7 @@ export function selectReviewHeader(comments, botLogin) {
|
|
|
591
470
|
// Pull the FIRST line of `body` that starts with the H2 sentinel.
|
|
592
471
|
// Exported separately so callers can extract a header from a known body
|
|
593
472
|
// without re-running the comment filter (useful in tests + the v0.6 App).
|
|
594
|
-
export function extractFirstReviewHeaderLine(body) {
|
|
473
|
+
export function extractFirstReviewHeaderLine(body: unknown): string | null {
|
|
595
474
|
if (typeof body !== 'string') return null;
|
|
596
475
|
const m = body.match(/^## 🐛 Clud Bug review[^\n]*/m);
|
|
597
476
|
return m ? m[0] : null;
|
|
@@ -613,16 +492,16 @@ export function extractFirstReviewHeaderLine(body) {
|
|
|
613
492
|
// of the composite still used the broken `.body | startswith(...)` jq
|
|
614
493
|
// filter even after step 1 was refactored, leaving per-skill check-runs
|
|
615
494
|
// silently disabled on every install with strictSkills since v0.5.10.
|
|
616
|
-
export function selectReviewBody(comments, botLogin) {
|
|
495
|
+
export function selectReviewBody(comments: unknown, botLogin: unknown): string | null {
|
|
617
496
|
if (!Array.isArray(comments)) return null;
|
|
618
497
|
if (typeof botLogin !== 'string' || !botLogin) return null;
|
|
619
498
|
// Same explicit newest-first sort as selectReviewHeader — gh api
|
|
620
499
|
// ignores direction=desc on issue-comments and returns ascending,
|
|
621
500
|
// so without this BB.3 was parsing per-skill outcomes from the
|
|
622
501
|
// OLDEST review comment, not the latest. See selectReviewHeader.
|
|
623
|
-
const sorted = [...comments].sort((a, b) => {
|
|
624
|
-
const ta = a?.created_at ? Date.parse(a.created_at) : 0;
|
|
625
|
-
const tb = b?.created_at ? Date.parse(b.created_at) : 0;
|
|
502
|
+
const sorted: PrComment[] = [...(comments as PrComment[])].sort((a, b) => {
|
|
503
|
+
const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
|
|
504
|
+
const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
|
|
626
505
|
return tb - ta;
|
|
627
506
|
});
|
|
628
507
|
for (const c of sorted) {
|
|
@@ -635,15 +514,12 @@ export function selectReviewBody(comments, botLogin) {
|
|
|
635
514
|
return null;
|
|
636
515
|
}
|
|
637
516
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
// passing — which is the right posture: if the bot didn't post a review
|
|
645
|
-
// with the strict-mode header, there's nothing for the gate to fail on.
|
|
646
|
-
// "Loud failure for missing manifest" is handled upstream in the composite.
|
|
517
|
+
export interface ReviewStatsHeader {
|
|
518
|
+
important: number;
|
|
519
|
+
nit: number;
|
|
520
|
+
preExisting: number;
|
|
521
|
+
}
|
|
522
|
+
|
|
647
523
|
// Extract the v0.6.5+ stats header line "Found: N 🔴 / N 🟡 / N 🟣"
|
|
648
524
|
// from a review comment body. Returns {important, nit, preExisting} when
|
|
649
525
|
// found, null otherwise. The header lets agents reading the comment on a
|
|
@@ -655,19 +531,28 @@ export function selectReviewBody(comments, botLogin) {
|
|
|
655
531
|
// and tolerates 1+ digits for each count. Severity emoji are matched
|
|
656
532
|
// literally — a future bot revision that changes the emoji would break
|
|
657
533
|
// this parser loudly, which is the intended behavior (catches drift).
|
|
658
|
-
export function extractStatsHeader(comment) {
|
|
534
|
+
export function extractStatsHeader(comment: unknown): ReviewStatsHeader | null {
|
|
659
535
|
if (typeof comment !== 'string' || !comment) return null;
|
|
660
536
|
const re = /Found:\s*(\d+)\s*🔴\s*\/\s*(\d+)\s*🟡\s*\/\s*(\d+)\s*🟣/u;
|
|
661
537
|
const m = comment.match(re);
|
|
662
538
|
if (!m) return null;
|
|
663
539
|
return {
|
|
664
|
-
important: parseInt(m[1], 10),
|
|
665
|
-
nit: parseInt(m[2], 10),
|
|
666
|
-
preExisting: parseInt(m[3], 10),
|
|
540
|
+
important: parseInt(m[1] as string, 10),
|
|
541
|
+
nit: parseInt(m[2] as string, 10),
|
|
542
|
+
preExisting: parseInt(m[3] as string, 10),
|
|
667
543
|
};
|
|
668
544
|
}
|
|
669
545
|
|
|
670
|
-
|
|
546
|
+
// Decide whether a review-header line is the strict-mode "critical findings"
|
|
547
|
+
// verdict that should fail the gate. Mirrors the v0.5.x bash predicate
|
|
548
|
+
// `grep -q "Clud Bug review — critical findings"`.
|
|
549
|
+
//
|
|
550
|
+
// Returns false for null/non-string input so a "no header found" path
|
|
551
|
+
// (selectReviewHeader returning null) safely falls through to the gate
|
|
552
|
+
// passing — which is the right posture: if the bot didn't post a review
|
|
553
|
+
// with the strict-mode header, there's nothing for the gate to fail on.
|
|
554
|
+
// "Loud failure for missing manifest" is handled upstream in the composite.
|
|
555
|
+
export function isCriticalReviewHeader(headerLine: unknown): boolean {
|
|
671
556
|
if (typeof headerLine !== 'string') return false;
|
|
672
557
|
return /Clud Bug review — critical findings/.test(headerLine);
|
|
673
558
|
}
|
|
@@ -699,7 +584,7 @@ export function isCriticalReviewHeader(headerLine) {
|
|
|
699
584
|
// findings" / "100 findings" don't substring-match to success — the exact
|
|
700
585
|
// bug that v0.5.10's first revision had, caught by clud-bug-review + claude-
|
|
701
586
|
// review on PR #57.
|
|
702
|
-
export function classifyPerSkillOutcome(outcomeLine) {
|
|
587
|
+
export function classifyPerSkillOutcome(outcomeLine: unknown): 'failure' | 'success' {
|
|
703
588
|
if (outcomeLine == null) return 'failure';
|
|
704
589
|
const text = String(outcomeLine);
|
|
705
590
|
|
|
@@ -746,4 +631,233 @@ export function classifyPerSkillOutcome(outcomeLine) {
|
|
|
746
631
|
return 'failure';
|
|
747
632
|
}
|
|
748
633
|
|
|
749
|
-
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// SKILL.md frontmatter parser (ported from clud-bug-app/lib/skills-loader.ts).
|
|
636
|
+
//
|
|
637
|
+
// The App's `loadSkillsFromBaseRef` Octokit fetcher stays App-side (depends
|
|
638
|
+
// on Octokit). The PURE parser belongs in core so both the App and any
|
|
639
|
+
// future CLI runtime that wants to read SKILL.md frontmatter without an
|
|
640
|
+
// Octokit dependency can use it. SPEC §1.10 is the frontmatter contract.
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
/** Source provenance for an installed skill (see SPEC §1.10). */
|
|
644
|
+
export type SkillSource =
|
|
645
|
+
| 'manual'
|
|
646
|
+
| 'logmind-derived'
|
|
647
|
+
| 'skills-sh'
|
|
648
|
+
| 'clud-bug-baseline';
|
|
649
|
+
|
|
650
|
+
export type SkillReviewMode = 'shared' | 'dedicated';
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Parsed SKILL.md frontmatter. The App uses this shape (see
|
|
654
|
+
* `clud-bug-app/lib/skills-loader.ts:40`); the prompt builder's
|
|
655
|
+
* `PromptSkillFrontmatter` is a narrower subset (name + description +
|
|
656
|
+
* applies_to) so we keep the full shape here.
|
|
657
|
+
*/
|
|
658
|
+
export type SkillKind = 'rule' | 'voice';
|
|
659
|
+
export type VoiceScope = 'personal' | 'team' | 'org' | 'community';
|
|
660
|
+
|
|
661
|
+
export interface SkillFrontmatter {
|
|
662
|
+
name: string;
|
|
663
|
+
description: string;
|
|
664
|
+
source: SkillSource | string; // string fallback for forward-compat
|
|
665
|
+
review_mode: SkillReviewMode;
|
|
666
|
+
/**
|
|
667
|
+
* SPEC §1.10.1 v0.5.0+ skill classification. OPTIONAL — absent =
|
|
668
|
+
* `kind: rule` (the v0.4.0 default). Surfaced on the parsed
|
|
669
|
+
* frontmatter (v0.7.0-rc.6+) so consumers can route on the field;
|
|
670
|
+
* v0.7.0-rc.5 and earlier silently dropped it.
|
|
671
|
+
*/
|
|
672
|
+
kind?: SkillKind;
|
|
673
|
+
/**
|
|
674
|
+
* SPEC §1.10.1 v0.5.0+ voice scope. REQUIRED when `kind: voice`,
|
|
675
|
+
* absent otherwise. Surfaced on the parsed frontmatter (v0.7.0-rc.6+).
|
|
676
|
+
*/
|
|
677
|
+
voice_scope?: VoiceScope;
|
|
678
|
+
applies_to?: {
|
|
679
|
+
paths?: string[];
|
|
680
|
+
extensions?: string[];
|
|
681
|
+
/**
|
|
682
|
+
* SPEC §1.10.1 v0.5.1+ — single GitHub login string (no `@`
|
|
683
|
+
* prefix, no list). When present, the skill MUST only load when
|
|
684
|
+
* `pull_request.user.login === author`. Filters via strict AND
|
|
685
|
+
* with the sibling `paths` + `extensions` filters when all are
|
|
686
|
+
* present. v0.7.0-rc.5 and earlier silently dropped this field
|
|
687
|
+
* (treated skills as if it were absent — load unconditionally);
|
|
688
|
+
* v0.7.0-rc.6+ enforces the filter via `appliesToAuthor`.
|
|
689
|
+
*/
|
|
690
|
+
author?: string;
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Minimal YAML frontmatter parser. Handles:
|
|
696
|
+
* - `key: value` (scalar)
|
|
697
|
+
* - `key: [a, b, c]` (inline list)
|
|
698
|
+
* - `key:\n subkey: value` (one-level nesting — applies_to)
|
|
699
|
+
*
|
|
700
|
+
* Throws on malformed input; the App's `loadSkillsFromBaseRef` catches
|
|
701
|
+
* and skips the skill (a bad SKILL.md doesn't take down the whole review).
|
|
702
|
+
*
|
|
703
|
+
* Deliberately NOT a general-purpose YAML parser — SPEC §1.10 fixes the
|
|
704
|
+
* frontmatter schema to a handful of fields. If the schema grows beyond
|
|
705
|
+
* what this hand-rolled parser handles, swap to `js-yaml` — the boundary
|
|
706
|
+
* is this function.
|
|
707
|
+
*/
|
|
708
|
+
export function parseFrontmatter(raw: string): SkillFrontmatter {
|
|
709
|
+
// Frontmatter MUST be the literal `---\n...\n---\n` at the file head.
|
|
710
|
+
// We tolerate a leading BOM and trailing whitespace.
|
|
711
|
+
const trimmed = raw.replace(/^/, '');
|
|
712
|
+
const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
713
|
+
if (!match) {
|
|
714
|
+
throw new Error('missing YAML frontmatter');
|
|
715
|
+
}
|
|
716
|
+
const body = match[1] ?? '';
|
|
717
|
+
const lines = body.split(/\r?\n/);
|
|
718
|
+
|
|
719
|
+
const out: Record<string, unknown> = {};
|
|
720
|
+
let currentNested: string | null = null;
|
|
721
|
+
|
|
722
|
+
for (const line of lines) {
|
|
723
|
+
if (!line.trim()) continue;
|
|
724
|
+
// Comment line; YAML allows '#' as a comment marker at column 0.
|
|
725
|
+
if (line.trim().startsWith('#')) continue;
|
|
726
|
+
|
|
727
|
+
// Nested-block lines start with whitespace (e.g. " paths: [...]").
|
|
728
|
+
const isIndented = /^\s/.test(line);
|
|
729
|
+
if (isIndented && currentNested) {
|
|
730
|
+
const nested = out[currentNested] as Record<string, unknown> | undefined;
|
|
731
|
+
if (!nested) continue;
|
|
732
|
+
const trimmedLine = line.trim();
|
|
733
|
+
const colon = trimmedLine.indexOf(':');
|
|
734
|
+
if (colon === -1) continue;
|
|
735
|
+
const key = trimmedLine.slice(0, colon).trim();
|
|
736
|
+
const value = trimmedLine.slice(colon + 1).trim();
|
|
737
|
+
nested[key] = parseScalarOrList(value);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Top-level key.
|
|
742
|
+
currentNested = null;
|
|
743
|
+
const colon = line.indexOf(':');
|
|
744
|
+
if (colon === -1) {
|
|
745
|
+
throw new Error(`malformed frontmatter line: ${line}`);
|
|
746
|
+
}
|
|
747
|
+
const key = line.slice(0, colon).trim();
|
|
748
|
+
const value = line.slice(colon + 1).trim();
|
|
749
|
+
|
|
750
|
+
if (value === '') {
|
|
751
|
+
// Block — next indented lines populate this key as a nested map.
|
|
752
|
+
out[key] = {};
|
|
753
|
+
currentNested = key;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
out[key] = parseScalarOrList(value);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Validate the SPEC-required fields are present and apply documented
|
|
760
|
+
// defaults for optional ones.
|
|
761
|
+
const name = String(out['name'] ?? '').trim();
|
|
762
|
+
if (!name) throw new Error('frontmatter.name is required');
|
|
763
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
|
|
764
|
+
throw new Error(`frontmatter.name is not a valid kebab-case slug: ${name}`);
|
|
765
|
+
}
|
|
766
|
+
const description = String(out['description'] ?? '').trim();
|
|
767
|
+
if (!description) throw new Error('frontmatter.description is required');
|
|
768
|
+
|
|
769
|
+
const source = String(out['source'] ?? 'manual').trim();
|
|
770
|
+
const reviewMode: SkillReviewMode =
|
|
771
|
+
out['review_mode'] === 'dedicated' ? 'dedicated' : 'shared';
|
|
772
|
+
|
|
773
|
+
// v0.7.0-rc.6 — surface SPEC §1.10.1 v0.5.0+ kind + voice_scope on
|
|
774
|
+
// the parsed frontmatter. v0.7.0-rc.5 silently dropped these fields
|
|
775
|
+
// (the Wave 4d reviewer-flagged silent-drop). Validation is lenient:
|
|
776
|
+
// unknown values are surfaced as undefined so downstream code sees a
|
|
777
|
+
// clean type rather than a malformed value. SPEC enforcement (e.g.,
|
|
778
|
+
// "voice_scope REQUIRED when kind: voice") is the caller's job.
|
|
779
|
+
const kindRaw = out['kind'];
|
|
780
|
+
const kind: SkillKind | undefined =
|
|
781
|
+
kindRaw === 'voice' ? 'voice' : kindRaw === 'rule' ? 'rule' : undefined;
|
|
782
|
+
const voiceScopeRaw = out['voice_scope'];
|
|
783
|
+
const voiceScope: VoiceScope | undefined =
|
|
784
|
+
voiceScopeRaw === 'personal' ||
|
|
785
|
+
voiceScopeRaw === 'team' ||
|
|
786
|
+
voiceScopeRaw === 'org' ||
|
|
787
|
+
voiceScopeRaw === 'community'
|
|
788
|
+
? voiceScopeRaw
|
|
789
|
+
: undefined;
|
|
790
|
+
|
|
791
|
+
const appliesToRaw = out['applies_to'] as
|
|
792
|
+
| { paths?: unknown; extensions?: unknown; author?: unknown }
|
|
793
|
+
| undefined;
|
|
794
|
+
let appliesTo: SkillFrontmatter['applies_to'] | undefined;
|
|
795
|
+
if (appliesToRaw) {
|
|
796
|
+
const paths = Array.isArray(appliesToRaw.paths)
|
|
797
|
+
? appliesToRaw.paths.map(String)
|
|
798
|
+
: undefined;
|
|
799
|
+
const extensions = Array.isArray(appliesToRaw.extensions)
|
|
800
|
+
? appliesToRaw.extensions.map(String)
|
|
801
|
+
: undefined;
|
|
802
|
+
// v0.7.0-rc.6 — SPEC §1.10.1 v0.5.1+ author filter. Single string
|
|
803
|
+
// only; reject lists + falsy values to keep the filter contract
|
|
804
|
+
// unambiguous (a future SPEC could broaden to lists if customer
|
|
805
|
+
// demand argues for it).
|
|
806
|
+
const authorRaw = appliesToRaw.author;
|
|
807
|
+
const author =
|
|
808
|
+
typeof authorRaw === 'string' && authorRaw.trim().length > 0
|
|
809
|
+
? authorRaw.trim()
|
|
810
|
+
: undefined;
|
|
811
|
+
// Reviewer-flagged Important on PR #179: an empty `applies_to:`
|
|
812
|
+
// block (no sub-keys) was emitting `applies_to: {}` on the
|
|
813
|
+
// returned frontmatter, which leaked into the prompt builder as
|
|
814
|
+
// JSON noise + semantically diverged from readAppliesTo's null
|
|
815
|
+
// return for the same input. Only construct appliesTo when at
|
|
816
|
+
// least one sub-field is present.
|
|
817
|
+
if (paths !== undefined || extensions !== undefined || author !== undefined) {
|
|
818
|
+
appliesTo = {
|
|
819
|
+
...(paths !== undefined ? { paths } : {}),
|
|
820
|
+
...(extensions !== undefined ? { extensions } : {}),
|
|
821
|
+
...(author !== undefined ? { author } : {}),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
name,
|
|
828
|
+
description,
|
|
829
|
+
source,
|
|
830
|
+
review_mode: reviewMode,
|
|
831
|
+
...(kind !== undefined ? { kind } : {}),
|
|
832
|
+
...(voiceScope !== undefined ? { voice_scope: voiceScope } : {}),
|
|
833
|
+
...(appliesTo !== undefined ? { applies_to: appliesTo } : {}),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function parseScalarOrList(value: string): unknown {
|
|
838
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
839
|
+
// Inline list: [a, "b", 'c'] → ['a', 'b', 'c']
|
|
840
|
+
const inner = value.slice(1, -1).trim();
|
|
841
|
+
if (!inner) return [];
|
|
842
|
+
return inner
|
|
843
|
+
.split(',')
|
|
844
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
845
|
+
.filter(Boolean);
|
|
846
|
+
}
|
|
847
|
+
// Strip surrounding quotes; YAML allows both ' and ".
|
|
848
|
+
return value.replace(/^['"]|['"]$/g, '');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Strip the leading `---\n...\n---\n` from a SKILL.md file. Returns the
|
|
853
|
+
* markdown body (the part the LLM actually reads).
|
|
854
|
+
*
|
|
855
|
+
* Ported from `clud-bug-app/lib/skills-loader.ts:269` so callers don't have
|
|
856
|
+
* to re-implement the regex.
|
|
857
|
+
*/
|
|
858
|
+
export function stripFrontmatter(raw: string): string {
|
|
859
|
+
const trimmed = raw.replace(/^/, '');
|
|
860
|
+
const match = trimmed.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
861
|
+
if (!match) return trimmed;
|
|
862
|
+
return trimmed.slice(match[0].length);
|
|
863
|
+
}
|