agent-skill-doctor 0.1.0
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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.en.md +275 -0
- package/README.md +275 -0
- package/bin/agent-skill-doctor-phase2.js +250 -0
- package/bin/agent-skill-doctor-phase3.js +215 -0
- package/bin/agent-skill-doctor-risk.js +147 -0
- package/bin/agent-skill-doctor.js +1834 -0
- package/examples/basic-usage.js +113 -0
- package/examples/readme-demo-skills/dangerous-deploy/SKILL.md +14 -0
- package/examples/readme-demo-skills/markdown-reporter-a/SKILL.md +8 -0
- package/examples/readme-demo-skills/markdown-reporter-b/SKILL.md +8 -0
- package/examples/readme-demo-skills/npm-installer/SKILL.md +10 -0
- package/examples/readme-demo-skills/pnpm-installer/SKILL.md +10 -0
- package/package.json +58 -0
- package/rules/default/credential-risk.json +19 -0
- package/rules/default/destructive-risk.json +17 -0
- package/rules/default/shell-network-risk.json +31 -0
- package/src/doctor/conflict.js +139 -0
- package/src/doctor/i18n.js +572 -0
- package/src/doctor/index.js +50 -0
- package/src/doctor/phase2.js +261 -0
- package/src/doctor/risk-lite.js +124 -0
- package/src/doctor/rules.js +57 -0
- package/src/doctor/zombie.js +178 -0
|
@@ -0,0 +1,1834 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
9
|
+
const { loadJsonRules, scanSkillForRisks } = require('../src/doctor/risk-lite');
|
|
10
|
+
const { detectConflicts } = require('../src/doctor/conflict');
|
|
11
|
+
const { detectZombies } = require('../src/doctor/zombie');
|
|
12
|
+
const { DEFAULT_CONFLICT_RULES } = require('../src/doctor/rules');
|
|
13
|
+
const phase2 = require('../src/doctor/phase2');
|
|
14
|
+
const { t, dictionaries } = require('../src/doctor/i18n');
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'target', 'dist', 'build', '.cache', '.tmp', '.DS_Store']);
|
|
17
|
+
|
|
18
|
+
function sha256(input) {
|
|
19
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nowIso() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expandHome(p) {
|
|
27
|
+
if (!p) return p;
|
|
28
|
+
if (p === '~') return os.homedir();
|
|
29
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizePath(p) {
|
|
34
|
+
return path.resolve(expandHome(p)).replace(/\\/g, '/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureDir(dir) {
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeHtml(str) {
|
|
42
|
+
if (!str) return '';
|
|
43
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nl2br(str) {
|
|
47
|
+
return escapeHtml(str).replace(/\n/g, '<br>');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const args = { _: [] };
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const token = argv[i];
|
|
54
|
+
if (!token.startsWith('--')) {
|
|
55
|
+
args._.push(token);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const key = token.slice(2);
|
|
59
|
+
const next = argv[i + 1];
|
|
60
|
+
if (next && !next.startsWith('--')) {
|
|
61
|
+
args[key] = next;
|
|
62
|
+
i += 1;
|
|
63
|
+
} else {
|
|
64
|
+
args[key] = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return args;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function slugify(input) {
|
|
71
|
+
return String(input || '')
|
|
72
|
+
.trim()
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
|
75
|
+
.replace(/^-+|-+$/g, '') || 'unknown-skill';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeText(text) {
|
|
79
|
+
return String(text || '')
|
|
80
|
+
.replace(/^---[\s\S]*?---\s*/m, '')
|
|
81
|
+
.replace(/```[\s\S]*?```/g, block => block.replace(/#.*$/gm, ''))
|
|
82
|
+
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/\s+/g, ' ')
|
|
85
|
+
.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeAnchor(text) {
|
|
89
|
+
return normalizeText(text).slice(0, 300);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeReadText(filePath) {
|
|
93
|
+
try { return fs.readFileSync(filePath, 'utf8'); } catch { return ''; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isTextLike(filePath) {
|
|
97
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
98
|
+
return ['.md', '.txt', '.json', '.yaml', '.yml', '.toml', '.js', '.ts', '.py', '.rs', '.sh'].includes(ext);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function doctorHome() {
|
|
102
|
+
return expandHome(process.env.AGENT_SKILL_DOCTOR_HOME || '~/.agent-skill-doctor');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadConfig() {
|
|
106
|
+
const home = doctorHome();
|
|
107
|
+
ensureDir(home);
|
|
108
|
+
ensureDir(path.join(home, 'reports'));
|
|
109
|
+
const candidates = [
|
|
110
|
+
// Central library
|
|
111
|
+
'~/.skills-manager/skills',
|
|
112
|
+
'~/.skills-manager',
|
|
113
|
+
// Agent global skill directories requested by users.
|
|
114
|
+
'~/.agent/skills',
|
|
115
|
+
'~/.agents/skills',
|
|
116
|
+
'~/.agents/skills-core',
|
|
117
|
+
'~/.codex/skills',
|
|
118
|
+
'~/.claude/skills',
|
|
119
|
+
'~/.cursor/skills',
|
|
120
|
+
'~/.opencode/skills',
|
|
121
|
+
// Project-local skill directories
|
|
122
|
+
path.join(process.cwd(), '.agent/skills'),
|
|
123
|
+
path.join(process.cwd(), '.agents/skills'),
|
|
124
|
+
path.join(process.cwd(), '.codex/skills'),
|
|
125
|
+
path.join(process.cwd(), '.claude/skills'),
|
|
126
|
+
path.join(process.cwd(), '.cursor/skills'),
|
|
127
|
+
path.join(process.cwd(), '.opencode/skills'),
|
|
128
|
+
].map(expandHome).filter(p => {
|
|
129
|
+
try { return fs.existsSync(p) && fs.statSync(p).isDirectory(); } catch { return false; }
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
home,
|
|
133
|
+
dbPath: path.join(home, 'doctor.db'),
|
|
134
|
+
reportsDir: path.join(home, 'reports'),
|
|
135
|
+
scan: { maxDepth: 6 },
|
|
136
|
+
roots: candidates,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function openDb(dbPath) {
|
|
141
|
+
ensureDir(path.dirname(dbPath));
|
|
142
|
+
const db = new DatabaseSync(dbPath);
|
|
143
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
144
|
+
db.exec(`
|
|
145
|
+
CREATE TABLE IF NOT EXISTS skill_records (
|
|
146
|
+
id TEXT PRIMARY KEY,
|
|
147
|
+
upstream_skill_id TEXT,
|
|
148
|
+
name TEXT NOT NULL,
|
|
149
|
+
slug TEXT NOT NULL,
|
|
150
|
+
description TEXT,
|
|
151
|
+
source_type TEXT,
|
|
152
|
+
source_url TEXT,
|
|
153
|
+
source_ref TEXT,
|
|
154
|
+
source_commit TEXT,
|
|
155
|
+
source_tree_sha TEXT,
|
|
156
|
+
local_path TEXT NOT NULL,
|
|
157
|
+
root_type TEXT NOT NULL,
|
|
158
|
+
agent TEXT,
|
|
159
|
+
content_hash TEXT,
|
|
160
|
+
normalized_hash TEXT,
|
|
161
|
+
semantic_fingerprint_json TEXT,
|
|
162
|
+
status TEXT,
|
|
163
|
+
created_at TEXT,
|
|
164
|
+
modified_at TEXT,
|
|
165
|
+
last_seen_at TEXT,
|
|
166
|
+
last_used_at TEXT,
|
|
167
|
+
raw_json TEXT NOT NULL
|
|
168
|
+
);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_skill_records_upstream_skill_id ON skill_records(upstream_skill_id);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_skill_records_slug ON skill_records(slug);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_skill_records_content_hash ON skill_records(content_hash);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
type TEXT NOT NULL,
|
|
176
|
+
severity TEXT NOT NULL,
|
|
177
|
+
detector_id TEXT NOT NULL,
|
|
178
|
+
rule_id TEXT,
|
|
179
|
+
title TEXT NOT NULL,
|
|
180
|
+
description TEXT NOT NULL,
|
|
181
|
+
signature TEXT NOT NULL,
|
|
182
|
+
evidence_json TEXT,
|
|
183
|
+
recommendation TEXT,
|
|
184
|
+
ignored INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
ignored_reason TEXT,
|
|
186
|
+
ignored_at TEXT,
|
|
187
|
+
created_at TEXT NOT NULL,
|
|
188
|
+
updated_at TEXT NOT NULL
|
|
189
|
+
);
|
|
190
|
+
CREATE INDEX IF NOT EXISTS idx_findings_type ON findings(type);
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
|
|
192
|
+
CREATE INDEX IF NOT EXISTS idx_findings_ignored ON findings(ignored);
|
|
193
|
+
|
|
194
|
+
CREATE TABLE IF NOT EXISTS finding_skills (
|
|
195
|
+
finding_id TEXT NOT NULL,
|
|
196
|
+
skill_id TEXT NOT NULL,
|
|
197
|
+
role TEXT NOT NULL DEFAULT 'related',
|
|
198
|
+
added_at TEXT NOT NULL,
|
|
199
|
+
PRIMARY KEY (finding_id, skill_id, role),
|
|
200
|
+
FOREIGN KEY (finding_id) REFERENCES findings(id) ON DELETE CASCADE,
|
|
201
|
+
FOREIGN KEY (skill_id) REFERENCES skill_records(id) ON DELETE CASCADE
|
|
202
|
+
);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_finding_skills_skill_id ON finding_skills(skill_id);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_finding_skills_finding_id ON finding_skills(finding_id);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_finding_skills_added_at ON finding_skills(added_at);
|
|
206
|
+
|
|
207
|
+
CREATE TABLE IF NOT EXISTS doctor_runs (
|
|
208
|
+
id TEXT PRIMARY KEY,
|
|
209
|
+
started_at TEXT NOT NULL,
|
|
210
|
+
finished_at TEXT,
|
|
211
|
+
status TEXT NOT NULL,
|
|
212
|
+
skill_count INTEGER NOT NULL DEFAULT 0,
|
|
213
|
+
finding_count INTEGER NOT NULL DEFAULT 0,
|
|
214
|
+
duplicate_group_count INTEGER NOT NULL DEFAULT 0,
|
|
215
|
+
high_count INTEGER NOT NULL DEFAULT 0,
|
|
216
|
+
critical_count INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
config_json TEXT NOT NULL,
|
|
218
|
+
summary_json TEXT
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
222
|
+
id TEXT PRIMARY KEY,
|
|
223
|
+
format TEXT NOT NULL,
|
|
224
|
+
path TEXT NOT NULL,
|
|
225
|
+
created_at TEXT NOT NULL,
|
|
226
|
+
summary_json TEXT NOT NULL
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
CREATE TABLE IF NOT EXISTS duplicate_groups (
|
|
230
|
+
id TEXT PRIMARY KEY,
|
|
231
|
+
strategy TEXT NOT NULL,
|
|
232
|
+
confidence REAL NOT NULL,
|
|
233
|
+
canonical_skill_id TEXT,
|
|
234
|
+
created_at TEXT NOT NULL,
|
|
235
|
+
updated_at TEXT NOT NULL,
|
|
236
|
+
FOREIGN KEY (canonical_skill_id) REFERENCES skill_records(id) ON DELETE SET NULL
|
|
237
|
+
);
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_duplicate_groups_strategy ON duplicate_groups(strategy);
|
|
239
|
+
CREATE INDEX IF NOT EXISTS idx_duplicate_groups_canonical_skill_id ON duplicate_groups(canonical_skill_id);
|
|
240
|
+
|
|
241
|
+
CREATE TABLE IF NOT EXISTS duplicate_group_members (
|
|
242
|
+
group_id TEXT NOT NULL,
|
|
243
|
+
skill_id TEXT NOT NULL,
|
|
244
|
+
role TEXT NOT NULL DEFAULT 'candidate',
|
|
245
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
246
|
+
added_at TEXT NOT NULL,
|
|
247
|
+
PRIMARY KEY (group_id, skill_id),
|
|
248
|
+
FOREIGN KEY (group_id) REFERENCES duplicate_groups(id) ON DELETE CASCADE,
|
|
249
|
+
FOREIGN KEY (skill_id) REFERENCES skill_records(id) ON DELETE CASCADE
|
|
250
|
+
);
|
|
251
|
+
CREATE INDEX IF NOT EXISTS idx_duplicate_group_members_skill_id ON duplicate_group_members(skill_id);
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_duplicate_group_members_added_at ON duplicate_group_members(added_at);
|
|
253
|
+
`);
|
|
254
|
+
// Migration: add score column to findings if missing
|
|
255
|
+
try { db.exec('ALTER TABLE findings ADD COLUMN score REAL'); } catch {}
|
|
256
|
+
return db;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseFrontmatter(text) {
|
|
260
|
+
if (!text || !text.startsWith('---')) return { data: {}, body: text || '', error: null };
|
|
261
|
+
const end = text.indexOf('\n---', 3);
|
|
262
|
+
if (end === -1) return { data: {}, body: text, error: 'frontmatter_missing_closing_delimiter' };
|
|
263
|
+
const raw = text.slice(3, end).trim();
|
|
264
|
+
const body = text.slice(end + 4).trimStart();
|
|
265
|
+
const data = {};
|
|
266
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
267
|
+
const trimmed = line.trim();
|
|
268
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
269
|
+
const idx = trimmed.indexOf(':');
|
|
270
|
+
if (idx <= 0) continue;
|
|
271
|
+
const key = trimmed.slice(0, idx).trim();
|
|
272
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
273
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
274
|
+
data[key] = value;
|
|
275
|
+
}
|
|
276
|
+
return { data, body, error: null };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findSkillCandidates(dir, root, depth, maxDepth) {
|
|
280
|
+
const out = [];
|
|
281
|
+
if (depth > maxDepth) return out;
|
|
282
|
+
let entries;
|
|
283
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
284
|
+
const names = new Set(entries.map(e => e.name));
|
|
285
|
+
const hasSkillMd = names.has('SKILL.md') || names.has('skill.md');
|
|
286
|
+
const hasReadme = names.has('README.md') || names.has('readme.md');
|
|
287
|
+
if (hasSkillMd || (hasReadme && depth > 0)) {
|
|
288
|
+
out.push({ path: normalizePath(dir), root: normalizePath(root), hasSkillMd, hasReadme });
|
|
289
|
+
if (hasSkillMd) return out;
|
|
290
|
+
}
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
|
|
293
|
+
out.push(...findSkillCandidates(path.join(dir, entry.name), root, depth + 1, maxDepth));
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readSkillText(candidatePath) {
|
|
299
|
+
for (const file of ['SKILL.md', 'skill.md', 'README.md', 'readme.md']) {
|
|
300
|
+
const p = path.join(candidatePath, file);
|
|
301
|
+
if (fs.existsSync(p)) return { file, text: safeReadText(p) };
|
|
302
|
+
}
|
|
303
|
+
return { file: null, text: '' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function listFiles(dir, root = dir) {
|
|
307
|
+
const out = [];
|
|
308
|
+
let entries;
|
|
309
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
312
|
+
const p = path.join(dir, entry.name);
|
|
313
|
+
if (entry.isDirectory()) out.push(...listFiles(p, root));
|
|
314
|
+
else if (entry.isFile()) {
|
|
315
|
+
let size = 0;
|
|
316
|
+
try { size = fs.statSync(p).size; } catch {}
|
|
317
|
+
out.push({ path: normalizePath(p), relativePath: path.relative(root, p).replaceAll('\\\\', '/'), sizeBytes: size });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function computeHashes(dir) {
|
|
324
|
+
const files = listFiles(dir);
|
|
325
|
+
const pieces = [];
|
|
326
|
+
const normalizedTexts = [];
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
let buf;
|
|
329
|
+
try { buf = fs.readFileSync(file.path); } catch { continue; }
|
|
330
|
+
const fileHash = sha256(buf);
|
|
331
|
+
pieces.push(`${file.relativePath}\0${fileHash}`);
|
|
332
|
+
file.hashSha256 = fileHash;
|
|
333
|
+
file.kind = kindForFile(file.relativePath);
|
|
334
|
+
if (isTextLike(file.path)) normalizedTexts.push(normalizeText(buf.toString('utf8')));
|
|
335
|
+
}
|
|
336
|
+
return { files, contentSha256: sha256(pieces.join('\n')), normalizedTextSha256: sha256(normalizedTexts.join('\n')) };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function kindForFile(rel) {
|
|
340
|
+
const base = path.basename(rel).toLowerCase();
|
|
341
|
+
if (base === 'skill.md') return 'skill_md';
|
|
342
|
+
if (base === 'readme.md') return 'readme';
|
|
343
|
+
if (/\.(js|ts|py|rs|sh)$/.test(base)) return 'script';
|
|
344
|
+
if (/\.(json|ya?ml|toml)$/.test(base)) return 'config';
|
|
345
|
+
return 'unknown';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rootTypeFor(p) {
|
|
349
|
+
const n = p.replace(/\\/g, '/');
|
|
350
|
+
if (n.includes('/.skills-manager/')) return 'central_library';
|
|
351
|
+
if (n.includes('/.claude/') || n.includes('/.codex/') || n.includes('/.cursor/') || n.includes('/.opencode/') ||
|
|
352
|
+
n.includes('/.agent/') || n.includes('/.windsurf/') || n.includes('/.aider/') ||
|
|
353
|
+
n.includes('/.continue/') || n.includes('/.cody/') || n.includes('/.copilot/')) return 'agent_global';
|
|
354
|
+
if (n.includes('/.agents/')) {
|
|
355
|
+
// ~/.agents/ is global, <cwd>/.agents/ is project-local
|
|
356
|
+
const home = (os.homedir() || '').replace(/\\/g, '/');
|
|
357
|
+
if (n.startsWith(home + '/')) return 'agent_global';
|
|
358
|
+
return 'project_local';
|
|
359
|
+
}
|
|
360
|
+
return 'unknown';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function inferAgent(p) {
|
|
364
|
+
const n = p.replace(/\\/g, '/');
|
|
365
|
+
if (n.includes('/.claude/')) return 'claude';
|
|
366
|
+
if (n.includes('/.codex/')) return 'codex';
|
|
367
|
+
if (n.includes('/.cursor/')) return 'cursor';
|
|
368
|
+
if (n.includes('/.opencode/')) return 'opencode';
|
|
369
|
+
if (n.includes('/.windsurf/')) return 'windsurf';
|
|
370
|
+
if (n.includes('/.aider/')) return 'aider';
|
|
371
|
+
if (n.includes('/.continue/')) return 'continue';
|
|
372
|
+
if (n.includes('/.cody/')) return 'cody';
|
|
373
|
+
if (n.includes('/.copilot/')) return 'copilot';
|
|
374
|
+
if (n.includes('/.agents/')) return 'agents';
|
|
375
|
+
if (n.includes('/.agent/')) return 'agent';
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseTags(frontmatter) {
|
|
380
|
+
const raw = frontmatter.tags || frontmatter.tag || '';
|
|
381
|
+
if (Array.isArray(raw)) return raw.map(String).map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
382
|
+
return String(raw).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function parseManuallyPinned(frontmatter) {
|
|
386
|
+
const v = frontmatter.pinned || frontmatter.pin || frontmatter.keep;
|
|
387
|
+
if (typeof v === 'boolean') return v;
|
|
388
|
+
if (typeof v === 'string') return v === 'true' || v === '';
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function usageSignalsFor(candidatePath, rootType, agent, modifiedAt, frontmatter) {
|
|
393
|
+
const isAgentGlobal = rootType === 'agent_global';
|
|
394
|
+
const isProjectLocal = rootType === 'project_local';
|
|
395
|
+
const modifiedMs = Date.parse(modifiedAt || 0);
|
|
396
|
+
const recentWindowMs = 90 * 24 * 60 * 60 * 1000;
|
|
397
|
+
return {
|
|
398
|
+
installedInAgents: isAgentGlobal && agent ? [agent] : [],
|
|
399
|
+
installedInProjects: isProjectLocal ? [path.dirname(candidatePath)] : [],
|
|
400
|
+
presetCount: Number(frontmatter.preset_count || frontmatter.presetCount || 0),
|
|
401
|
+
hasRecentModification: modifiedMs > 0 ? Date.now() - modifiedMs <= recentWindowMs : false,
|
|
402
|
+
lastActivityLogAt: frontmatter.last_activity_at || frontmatter.lastActivityLogAt || null,
|
|
403
|
+
manuallyPinned: parseManuallyPinned(frontmatter),
|
|
404
|
+
confidence: isAgentGlobal || isProjectLocal ? 0.6 : 0.3,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function inferNameDescription(dir, text, frontmatter) {
|
|
409
|
+
const lines = text.split(/\r?\n/);
|
|
410
|
+
const heading = lines.find(l => /^#\s+/.test(l));
|
|
411
|
+
const name = frontmatter.name || (heading ? heading.replace(/^#\s+/, '').trim() : path.basename(dir));
|
|
412
|
+
let description = frontmatter.description || frontmatter.summary || '';
|
|
413
|
+
if (!description) {
|
|
414
|
+
const bodyLine = lines.map(l => l.trim()).find(l => l && !l.startsWith('#') && !l.startsWith('---') && !l.includes(':'));
|
|
415
|
+
description = bodyLine || '';
|
|
416
|
+
}
|
|
417
|
+
return { name, description };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildSkillIdentityKey(skill) {
|
|
421
|
+
if (skill.upstreamSkillId) return `upstream:${skill.upstreamSkillId}`;
|
|
422
|
+
if (skill.source.url && skill.slug) return `source:${String(skill.source.url).replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/\/$/, '').toLowerCase()}:${skill.source.subdir || ''}:${skill.slug}`;
|
|
423
|
+
if (skill.location.root && skill.slug) return `path:${normalizePath(skill.location.root)}:${skill.slug}`;
|
|
424
|
+
return `hash:${skill.hashes.contentSha256}:${normalizePath(skill.location.path)}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function buildParticipantIdentityKey(skills) {
|
|
428
|
+
return sha256(skills.map(buildSkillIdentityKey).sort().join('\n'));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Load .agents/.skill-lock.json for source tracking
|
|
432
|
+
let _skillLockCache = null;
|
|
433
|
+
function loadSkillLock() {
|
|
434
|
+
if (_skillLockCache !== null) return _skillLockCache;
|
|
435
|
+
_skillLockCache = {};
|
|
436
|
+
const lockPath = path.join(os.homedir(), '.agents', '.skill-lock.json');
|
|
437
|
+
if (!fs.existsSync(lockPath)) return _skillLockCache;
|
|
438
|
+
try {
|
|
439
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
440
|
+
for (const [name, info] of Object.entries(lock.skills || {})) {
|
|
441
|
+
_skillLockCache[name] = info;
|
|
442
|
+
}
|
|
443
|
+
} catch {}
|
|
444
|
+
return _skillLockCache;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Load .agents/skill-state-with-plugin-cache.yaml for source tracking
|
|
448
|
+
let _skillStateCache = null;
|
|
449
|
+
function loadSkillState() {
|
|
450
|
+
if (_skillStateCache !== null) return _skillStateCache;
|
|
451
|
+
_skillStateCache = {};
|
|
452
|
+
const statePath = path.join(os.homedir(), '.agents', 'skill-state-with-plugin-cache.yaml');
|
|
453
|
+
if (!fs.existsSync(statePath)) {
|
|
454
|
+
// Fallback to skill-state.yaml
|
|
455
|
+
const alt = path.join(os.homedir(), '.agents', 'skill-state.yaml');
|
|
456
|
+
if (!fs.existsSync(alt)) return _skillStateCache;
|
|
457
|
+
try {
|
|
458
|
+
const lines = fs.readFileSync(alt, 'utf8').split('\n');
|
|
459
|
+
let cur = '';
|
|
460
|
+
for (const line of lines) {
|
|
461
|
+
const sm = line.match(/^ (\S+):$/);
|
|
462
|
+
if (sm) { cur = sm[1]; _skillStateCache[cur] = {}; }
|
|
463
|
+
const kv = line.match(/^ (\S+):\s*(.+)/);
|
|
464
|
+
if (kv && cur) _skillStateCache[cur][kv[1]] = kv[2].replace(/^"|"$/g, '');
|
|
465
|
+
}
|
|
466
|
+
} catch {}
|
|
467
|
+
return _skillStateCache;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const lines = fs.readFileSync(statePath, 'utf8').split('\n');
|
|
471
|
+
let cur = '';
|
|
472
|
+
for (const line of lines) {
|
|
473
|
+
const sm = line.match(/^ (\S+):$/);
|
|
474
|
+
if (sm) { cur = sm[1]; _skillStateCache[cur] = {}; }
|
|
475
|
+
const kv = line.match(/^ (\S+):\s*(.+)/);
|
|
476
|
+
if (kv && cur) _skillStateCache[cur][kv[1]] = kv[2].replace(/^"|"$/g, '');
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
return _skillStateCache;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Load .claude-plugin/marketplace.json or plugin.json from skill directory
|
|
483
|
+
function loadClaudePluginInfo(skillPath) {
|
|
484
|
+
const pluginDir = path.join(skillPath, '.claude-plugin');
|
|
485
|
+
if (!fs.existsSync(pluginDir)) return null;
|
|
486
|
+
for (const file of ['marketplace.json', 'plugin.json']) {
|
|
487
|
+
try {
|
|
488
|
+
const data = JSON.parse(fs.readFileSync(path.join(pluginDir, file), 'utf8'));
|
|
489
|
+
return { name: data.name, owner: data.owner?.name, homepage: data.homepage };
|
|
490
|
+
} catch {}
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function parseSkillCandidate(candidate) {
|
|
496
|
+
const now = nowIso();
|
|
497
|
+
const { file: mainFile, text } = readSkillText(candidate.path);
|
|
498
|
+
const fm = parseFrontmatter(text);
|
|
499
|
+
const { name, description } = inferNameDescription(candidate.path, text, fm.data);
|
|
500
|
+
const slug = slugify(fm.data.id || fm.data.slug || name || path.basename(candidate.path));
|
|
501
|
+
const hashResult = computeHashes(candidate.path);
|
|
502
|
+
const stat = fs.statSync(candidate.path);
|
|
503
|
+
const modifiedAt = stat.mtime?.toISOString?.() || null;
|
|
504
|
+
const rootType = rootTypeFor(candidate.path);
|
|
505
|
+
const agent = inferAgent(candidate.path);
|
|
506
|
+
const skill = {
|
|
507
|
+
id: '', upstreamSkillId: null, name, slug, description,
|
|
508
|
+
source: { type: fm.data.source ? 'git' : 'unknown', url: fm.data.source || fm.data.source_url || null, subdir: fm.data.subdir || null, ref: fm.data.ref || fm.data.version || null, commit: fm.data.commit || null },
|
|
509
|
+
location: { path: candidate.path, root: candidate.root, rootType, agent, isSymlink: false, symlinkTarget: null },
|
|
510
|
+
version: fm.data.version || null, sourceRef: fm.data.ref || null, sourceCommit: fm.data.commit || null, sourceTreeSha: fm.data.tree_sha || null,
|
|
511
|
+
frontmatter: fm.data, tags: parseTags(fm.data), presets: [], agentTargets: [], files: hashResult.files,
|
|
512
|
+
hashes: { contentSha256: hashResult.contentSha256, normalizedTextSha256: hashResult.normalizedTextSha256, semanticFingerprint: {} },
|
|
513
|
+
capabilities: [], usage: usageSignalsFor(candidate.path, rootType, agent, modifiedAt, fm.data),
|
|
514
|
+
createdAt: stat.birthtime?.toISOString?.() || null, modifiedAt, lastSeenAt: now, lastUsedAt: null, status: 'active',
|
|
515
|
+
_scan: { mainFile, text, frontmatterError: fm.error, hasSkillMd: candidate.hasSkillMd, hasReadme: candidate.hasReadme },
|
|
516
|
+
_sourcePlugin: null, _installedAt: null,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Enhance source from external registries when frontmatter has no source
|
|
520
|
+
if (skill.source.type === 'unknown') {
|
|
521
|
+
const lock = loadSkillLock();
|
|
522
|
+
const lockEntry = lock[slug] || lock[name];
|
|
523
|
+
if (lockEntry) {
|
|
524
|
+
skill.source = {
|
|
525
|
+
type: lockEntry.sourceType || 'git',
|
|
526
|
+
url: lockEntry.sourceUrl || null,
|
|
527
|
+
subdir: lockEntry.skillPath ? path.dirname(lockEntry.skillPath) : null,
|
|
528
|
+
ref: null, commit: null,
|
|
529
|
+
};
|
|
530
|
+
skill._sourcePlugin = lockEntry.source || null;
|
|
531
|
+
skill._installedAt = lockEntry.installedAt || null;
|
|
532
|
+
} else {
|
|
533
|
+
const pluginInfo = loadClaudePluginInfo(candidate.path);
|
|
534
|
+
if (pluginInfo) {
|
|
535
|
+
skill.source = { type: 'plugin', url: pluginInfo.homepage || null, subdir: null, ref: null, commit: null };
|
|
536
|
+
skill._sourcePlugin = pluginInfo.owner ? `${pluginInfo.owner}/${pluginInfo.name}` : pluginInfo.name;
|
|
537
|
+
} else if (/[/\\]\.system[/\\]/.test(candidate.path)) {
|
|
538
|
+
// Skills in .system/ directories are built-in agent skills → official
|
|
539
|
+
const agentSourceMap = { codex: 'openai/skills', claude: 'anthropics/skills' };
|
|
540
|
+
const agentSource = agentSourceMap[agent] || `${agent}/system-skills`;
|
|
541
|
+
skill.source = { type: 'builtin', url: null, subdir: null, ref: null, commit: null };
|
|
542
|
+
skill._sourcePlugin = agentSource;
|
|
543
|
+
} else {
|
|
544
|
+
// Fallback: check skill-state-with-plugin-cache.yaml for source info
|
|
545
|
+
const stateInfo = loadSkillState();
|
|
546
|
+
const stateEntry = stateInfo[slug] || stateInfo[name];
|
|
547
|
+
if (stateEntry && stateEntry.source && stateEntry.source !== 'local') {
|
|
548
|
+
skill.source = { type: 'git', url: stateEntry.sourceUrl || null, subdir: null, ref: null, commit: null };
|
|
549
|
+
skill._sourcePlugin = stateEntry.source || null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
skill.id = sha256(`${buildSkillIdentityKey(skill)}:${normalizePath(skill.location.path)}`);
|
|
556
|
+
return skill;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function scanRoots(roots, options = {}) {
|
|
560
|
+
const candidates = new Map();
|
|
561
|
+
for (const raw of roots) {
|
|
562
|
+
const root = normalizePath(raw);
|
|
563
|
+
if (!fs.existsSync(root)) continue;
|
|
564
|
+
for (const c of findSkillCandidates(root, root, 0, options.maxDepth || 6)) candidates.set(c.path, c);
|
|
565
|
+
}
|
|
566
|
+
return [...candidates.values()].map(parseSkillCandidate);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function makeFinding(skill, { type, severity, detectorId, ruleId, title, description, issueType, evidenceText, recommendation }) {
|
|
570
|
+
const participantKey = buildParticipantIdentityKey([skill]);
|
|
571
|
+
const evidence = [{ file: skill._scan.mainFile || skill.location.path, text: evidenceText || title, anchor: normalizeAnchor(evidenceText || title) }];
|
|
572
|
+
const signature = sha256(`${issueType}:${evidence[0].file}:${evidence[0].anchor}`);
|
|
573
|
+
const id = sha256(`${participantKey}:${type}:${detectorId}:${ruleId || ''}:${signature}`);
|
|
574
|
+
const at = nowIso();
|
|
575
|
+
return { finding: { id, type, severity, detectorId, ruleId, title, description, signature, evidence, recommendation, ignored: false, createdAt: at, updatedAt: at }, link: { findingId: id, skillId: skill.id, role: 'primary', addedAt: at } };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function descriptionQualityScore(skill) {
|
|
579
|
+
const desc = String(skill.description || '').trim();
|
|
580
|
+
if (!desc) return { score: 0, issues: ['missing-description'] };
|
|
581
|
+
const issues = [];
|
|
582
|
+
let score = 60; // has description
|
|
583
|
+
if (desc.length < 20) { score -= 20; issues.push('short-description'); }
|
|
584
|
+
const lower = desc.toLowerCase();
|
|
585
|
+
// Check for trigger/usage condition
|
|
586
|
+
if (!/\b(when|use |trigger|if |run |invoke|install|enable)\b/.test(lower)) { score -= 15; issues.push('no-trigger-condition'); }
|
|
587
|
+
// Check for I/O description
|
|
588
|
+
if (!/\b(input|output|return|result|respond|answer|generate|create|produce)\b/.test(lower)) { score -= 10; issues.push('no-io-description'); }
|
|
589
|
+
// Check for high-risk content in skill without risk description
|
|
590
|
+
const hasRisk = /\b(rm |delete|remove|exec|shell|subprocess|curl|wget|sudo|env|token|key|secret|password|credential)\b/.test(lower);
|
|
591
|
+
const hasRiskDesc = /\b(risk|danger|careful|caution|warning|safe|security|destructive|irreversible)\b/.test(lower);
|
|
592
|
+
if (hasRisk && !hasRiskDesc) { score -= 20; issues.push('risk-not-described'); }
|
|
593
|
+
return { score: Math.max(0, score), issues };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function detectPhase1Findings(skill) {
|
|
597
|
+
const out = [];
|
|
598
|
+
if (!skill._scan.hasSkillMd) out.push(makeFinding(skill, { type: 'scan_warning', severity: 'low', detectorId: 'scan-warning-detector', title: 'SKILL.md is missing', description: 'This skill candidate was detected from README.md or directory context, but no SKILL.md file was found.', issueType: 'missing-skill-md', evidenceText: skill.location.path, recommendation: 'Add SKILL.md to make the skill explicit and portable.' }));
|
|
599
|
+
if (skill._scan.frontmatterError) out.push(makeFinding(skill, { type: 'scan_warning', severity: 'medium', detectorId: 'scan-warning-detector', title: 'Frontmatter parse warning', description: `The main skill file has malformed frontmatter: ${skill._scan.frontmatterError}.`, issueType: 'frontmatter-warning', evidenceText: skill._scan.frontmatterError, recommendation: 'Fix YAML frontmatter delimiters and key/value format.' }));
|
|
600
|
+
|
|
601
|
+
const dq = descriptionQualityScore(skill);
|
|
602
|
+
if (dq.issues.includes('missing-description')) {
|
|
603
|
+
out.push(makeFinding(skill, { type: 'description_quality', severity: 'medium', detectorId: 'description-quality-detector', title: 'Description is missing', description: 'The skill has no usable description, which makes selection and governance harder.', issueType: 'missing-description', evidenceText: skill.name, recommendation: 'Add a clear description explaining when to use this skill.' }));
|
|
604
|
+
} else {
|
|
605
|
+
if (dq.issues.includes('short-description')) out.push(makeFinding(skill, { type: 'description_quality', severity: 'low', detectorId: 'description-quality-detector', title: 'Description is too short', description: 'The skill description is very short and may not explain trigger conditions or behavior.', issueType: 'short-description', evidenceText: skill.description, recommendation: 'Expand the description with use cases, inputs, outputs, and limitations.' }));
|
|
606
|
+
if (dq.issues.includes('no-trigger-condition')) out.push(makeFinding(skill, { type: 'description_quality', severity: 'info', detectorId: 'description-quality-detector', title: 'No trigger condition in description', description: 'The description does not explain when or how to invoke this skill.', issueType: 'no-trigger-condition', evidenceText: skill.description, recommendation: 'Add trigger conditions (e.g., "when reviewing code", "use this for X").' }));
|
|
607
|
+
if (dq.issues.includes('no-io-description')) out.push(makeFinding(skill, { type: 'description_quality', severity: 'info', detectorId: 'description-quality-detector', title: 'No input/output description', description: 'The description does not explain what the skill produces or returns.', issueType: 'no-io-description', evidenceText: skill.description, recommendation: 'Describe expected inputs, outputs, or side effects.' }));
|
|
608
|
+
if (dq.issues.includes('risk-not-described')) out.push(makeFinding(skill, { type: 'description_quality', severity: 'medium', detectorId: 'description-quality-detector', title: 'High-risk actions without risk description', description: 'The skill appears to contain risky operations (shell, file deletion, credentials) but the description does not mention risks.', issueType: 'risk-not-described', evidenceText: skill.description, recommendation: 'Add risk warnings and safety considerations to the description.' }));
|
|
609
|
+
}
|
|
610
|
+
return { findings: out.map(x => x.finding), links: out.map(x => x.link) };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function upsertSkill(db, skill) {
|
|
614
|
+
db.prepare(`INSERT INTO skill_records (id, upstream_skill_id, name, slug, description, source_type, source_url, source_ref, source_commit, source_tree_sha, local_path, root_type, agent, content_hash, normalized_hash, semantic_fingerprint_json, status, created_at, modified_at, last_seen_at, last_used_at, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, slug=excluded.slug, description=excluded.description, source_type=excluded.source_type, source_url=excluded.source_url, source_ref=excluded.source_ref, source_commit=excluded.source_commit, source_tree_sha=excluded.source_tree_sha, local_path=excluded.local_path, root_type=excluded.root_type, agent=excluded.agent, content_hash=excluded.content_hash, normalized_hash=excluded.normalized_hash, semantic_fingerprint_json=excluded.semantic_fingerprint_json, status=excluded.status, modified_at=excluded.modified_at, last_seen_at=excluded.last_seen_at, last_used_at=excluded.last_used_at, raw_json=excluded.raw_json`).run(skill.id, skill.upstreamSkillId || null, skill.name, skill.slug, skill.description || null, skill.source.type, skill.source.url || null, skill.sourceRef || skill.source.ref || null, skill.sourceCommit || skill.source.commit || null, skill.sourceTreeSha || null, skill.location.path, skill.location.rootType, skill.location.agent || null, skill.hashes.contentSha256, skill.hashes.normalizedTextSha256, JSON.stringify(skill.hashes.semanticFingerprint || {}), skill.status || 'unknown', skill.createdAt || null, skill.modifiedAt || null, skill.lastSeenAt, skill.lastUsedAt || null, JSON.stringify(skill));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function upsertFinding(db, finding, links) {
|
|
618
|
+
const at = nowIso();
|
|
619
|
+
const createdAt = finding.createdAt || at;
|
|
620
|
+
const updatedAt = finding.updatedAt || at;
|
|
621
|
+
const score = finding.score != null ? finding.score : null;
|
|
622
|
+
const existing = db.prepare('SELECT id FROM findings WHERE id = ?').get(finding.id);
|
|
623
|
+
if (existing) db.prepare('UPDATE findings SET type=?, severity=?, detector_id=?, rule_id=?, title=?, description=?, signature=?, evidence_json=?, recommendation=?, score=?, updated_at=? WHERE id=?').run(finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, score, updatedAt, finding.id);
|
|
624
|
+
else db.prepare('INSERT INTO findings (id, type, severity, detector_id, rule_id, title, description, signature, evidence_json, recommendation, score, ignored, ignored_reason, ignored_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)').run(finding.id, finding.type, finding.severity, finding.detectorId, finding.ruleId || null, finding.title, finding.description, finding.signature, JSON.stringify(finding.evidence || []), finding.recommendation || null, score, createdAt, updatedAt);
|
|
625
|
+
const stmt = db.prepare('INSERT OR IGNORE INTO finding_skills (finding_id, skill_id, role, added_at) VALUES (?, ?, ?, ?)');
|
|
626
|
+
for (const link of links) stmt.run(link.findingId || finding.id, link.skillId, link.role || 'primary', link.addedAt || at);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function upsertDuplicateGroup(db, group) {
|
|
630
|
+
const at = nowIso();
|
|
631
|
+
db.prepare('INSERT INTO duplicate_groups (id, strategy, confidence, canonical_skill_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET strategy=excluded.strategy, confidence=excluded.confidence, canonical_skill_id=excluded.canonical_skill_id, updated_at=excluded.updated_at')
|
|
632
|
+
.run(group.id, group.strategy, group.confidence, group.canonicalSkillId || null, at, at);
|
|
633
|
+
const stmt = db.prepare('INSERT OR REPLACE INTO duplicate_group_members (group_id, skill_id, role, confidence, added_at) VALUES (?, ?, ?, ?, ?)');
|
|
634
|
+
for (const member of group.members) stmt.run(group.id, member.skillId, member.role, member.confidence, at);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function duplicateFindingFromGroup(group) {
|
|
638
|
+
const at = nowIso();
|
|
639
|
+
return {
|
|
640
|
+
finding: {
|
|
641
|
+
id: sha256(`${phase2.buildParticipantIdentityKey(group.skills)}:duplicate:duplicate-detector:${group.strategy}:${group.id}`),
|
|
642
|
+
type: 'duplicate',
|
|
643
|
+
severity: group.strategy === 'exact_duplicate' ? 'medium' : 'low',
|
|
644
|
+
detectorId: 'duplicate-detector',
|
|
645
|
+
ruleId: group.strategy,
|
|
646
|
+
title: `${group.strategy.replaceAll('_', ' ')} detected`,
|
|
647
|
+
description: `Detected ${group.skills.length} related skills. Canonical suggestion: ${group.canonicalSkillId}.`,
|
|
648
|
+
signature: group.id,
|
|
649
|
+
evidence: [{ file: 'multiple-skills', text: group.skills.map(s => `${s.slug}: ${s.location?.path || s.local_path}`).join('\n'), anchor: group.strategy }],
|
|
650
|
+
recommendation: 'Review the group and keep one canonical skill before disabling duplicates.',
|
|
651
|
+
createdAt: at,
|
|
652
|
+
updatedAt: at,
|
|
653
|
+
},
|
|
654
|
+
links: group.members.map(m => ({ skillId: m.skillId, role: m.role })),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function driftFindingToDbFinding(drift) {
|
|
659
|
+
const at = nowIso();
|
|
660
|
+
return {
|
|
661
|
+
finding: {
|
|
662
|
+
id: drift.id,
|
|
663
|
+
type: 'version_drift',
|
|
664
|
+
severity: drift.severity,
|
|
665
|
+
detectorId: drift.detectorId,
|
|
666
|
+
ruleId: drift.ruleId,
|
|
667
|
+
title: drift.title,
|
|
668
|
+
description: drift.description,
|
|
669
|
+
signature: drift.id,
|
|
670
|
+
evidence: [{ file: 'multiple-skills', text: JSON.stringify(drift.evidence, null, 2), anchor: drift.id }],
|
|
671
|
+
recommendation: drift.recommendation,
|
|
672
|
+
createdAt: at,
|
|
673
|
+
updatedAt: at,
|
|
674
|
+
},
|
|
675
|
+
links: drift.links,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function runPhase2Analysis(db, skills) {
|
|
680
|
+
const groups = phase2.detectDuplicateGroups(skills);
|
|
681
|
+
const drifts = phase2.detectVersionDrift(skills);
|
|
682
|
+
for (const group of groups) {
|
|
683
|
+
upsertDuplicateGroup(db, group);
|
|
684
|
+
const made = duplicateFindingFromGroup(group);
|
|
685
|
+
upsertFinding(db, made.finding, made.links);
|
|
686
|
+
}
|
|
687
|
+
for (const drift of drifts) {
|
|
688
|
+
const made = driftFindingToDbFinding(drift);
|
|
689
|
+
upsertFinding(db, made.finding, made.links);
|
|
690
|
+
}
|
|
691
|
+
return { groups, drifts };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function recordRun(db, run) {
|
|
695
|
+
db.prepare('INSERT INTO doctor_runs (id, started_at, finished_at, status, skill_count, finding_count, duplicate_group_count, high_count, critical_count, config_json, summary_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(run.id, run.startedAt, run.finishedAt, run.status, run.skillCount, run.findingCount, 0, run.highCount, run.criticalCount, JSON.stringify(run.config || {}), JSON.stringify(run.summary || {}));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function listSkills(db) { return db.prepare('SELECT * FROM skill_records ORDER BY slug ASC').all(); }
|
|
699
|
+
function listFindings(db, includeIgnored) { return db.prepare(`SELECT * FROM findings ${includeIgnored ? '' : 'WHERE ignored = 0'} ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 WHEN 'info' THEN 4 ELSE 5 END, type ASC, title ASC`).all(); }
|
|
700
|
+
function listIgnored(db) { return db.prepare('SELECT * FROM findings WHERE ignored = 1 ORDER BY ignored_at DESC').all(); }
|
|
701
|
+
function getFindingSkills(db, findingId) { return db.prepare('SELECT fs.*, sr.slug, sr.name, sr.local_path FROM finding_skills fs JOIN skill_records sr ON sr.id = fs.skill_id WHERE fs.finding_id = ? ORDER BY fs.role ASC, sr.slug ASC').all(findingId); }
|
|
702
|
+
function setIgnored(db, id, ignored, reason, at) { return db.prepare('UPDATE findings SET ignored=?, ignored_reason=?, ignored_at=?, updated_at=? WHERE id=?').run(ignored ? 1 : 0, ignored ? (reason || null) : null, ignored ? at : null, at, id).changes || 0; }
|
|
703
|
+
function listFindingSkills(db) { return db.prepare('SELECT * FROM finding_skills ORDER BY finding_id ASC, role ASC, skill_id ASC').all(); }
|
|
704
|
+
function listDuplicateGroups(db) { return db.prepare('SELECT * FROM duplicate_groups ORDER BY confidence DESC, strategy ASC').all(); }
|
|
705
|
+
function listDuplicateGroupMembers(db) { return db.prepare('SELECT * FROM duplicate_group_members ORDER BY group_id ASC, role ASC, skill_id ASC').all(); }
|
|
706
|
+
|
|
707
|
+
function buildReportData(db, includeIgnored) {
|
|
708
|
+
const skills = listSkills(db);
|
|
709
|
+
const rows = listFindings(db, includeIgnored);
|
|
710
|
+
const findings = rows.map(row => ({ id: row.id, type: row.type, severity: row.severity, detectorId: row.detector_id, ruleId: row.rule_id, title: row.title, description: row.description, signature: row.signature, evidence: JSON.parse(row.evidence_json || '[]'), recommendation: row.recommendation, score: row.score, ignored: !!row.ignored, ignoredReason: row.ignored_reason, ignoredAt: row.ignored_at, createdAt: row.created_at, updatedAt: row.updated_at, skills: getFindingSkills(db, row.id) }));
|
|
711
|
+
const bySeverity = {}, byType = {};
|
|
712
|
+
for (const f of findings) { bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1; byType[f.type] = (byType[f.type] || 0) + 1; }
|
|
713
|
+
const duplicateGroups = listDuplicateGroups(db);
|
|
714
|
+
const duplicateGroupMembers = listDuplicateGroupMembers(db);
|
|
715
|
+
const findingSkills = listFindingSkills(db);
|
|
716
|
+
const scanDirs = [...new Set(skills.map(s => {
|
|
717
|
+
const raw = typeof s.raw_json === 'string' ? JSON.parse(s.raw_json) : (s.raw_json || {});
|
|
718
|
+
return raw.location?.root || '';
|
|
719
|
+
}).filter(Boolean))].length;
|
|
720
|
+
|
|
721
|
+
const summary = {
|
|
722
|
+
scanDirs,
|
|
723
|
+
totalSkills: skills.length,
|
|
724
|
+
totalFindings: findings.length,
|
|
725
|
+
duplicateGroups: duplicateGroups.length,
|
|
726
|
+
versionDriftFindings: findings.filter(f => f.type === 'version_drift').length,
|
|
727
|
+
riskFindings: findings.filter(f => f.type === 'risk').length,
|
|
728
|
+
conflictFindings: findings.filter(f => f.type === 'conflict').length,
|
|
729
|
+
zombieCandidates: findings.filter(f => f.type === 'zombie').length,
|
|
730
|
+
descriptionQualityFindings: findings.filter(f => f.type === 'description_quality').length,
|
|
731
|
+
duplicateFindings: findings.filter(f => f.type === 'duplicate').length,
|
|
732
|
+
ignoredFindings: rows.filter(row => row.ignored).length,
|
|
733
|
+
bySeverity,
|
|
734
|
+
byType,
|
|
735
|
+
};
|
|
736
|
+
return { summary, skills, findings, findingSkills, duplicateGroups, duplicateGroupMembers, optimizationPlan: {} };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderMarkdown(data, lang) {
|
|
740
|
+
const L = (key, ...args) => t(key, lang || 'en', ...args);
|
|
741
|
+
const lines = [
|
|
742
|
+
`# ${L('report.title')}`,
|
|
743
|
+
'',
|
|
744
|
+
`## ${L('report.summary')}`,
|
|
745
|
+
'',
|
|
746
|
+
`- ${L('report.totalSkills')}: ${data.summary.totalSkills}`,
|
|
747
|
+
`- ${L('report.totalFindings')}: ${data.summary.totalFindings}`,
|
|
748
|
+
`- ${L('report.duplicateGroups')}: ${data.summary.duplicateGroups}`,
|
|
749
|
+
`- ${L('report.versionDriftFindings')}: ${data.summary.versionDriftFindings}`,
|
|
750
|
+
`- ${L('report.conflictFindings')}: ${data.summary.conflictFindings}`,
|
|
751
|
+
`- ${L('report.riskFindings')}: ${data.summary.riskFindings}`,
|
|
752
|
+
`- ${L('report.zombieCandidates')}: ${data.summary.zombieCandidates}`,
|
|
753
|
+
`- ${L('report.ignoredFindings')}: ${data.summary.ignoredFindings}`,
|
|
754
|
+
`- ${L('report.bySeverity')}: ${JSON.stringify(data.summary.bySeverity)}`,
|
|
755
|
+
`- ${L('report.byType')}: ${JSON.stringify(data.summary.byType)}`,
|
|
756
|
+
'',
|
|
757
|
+
`## ${L('report.criticalRisks')}`,
|
|
758
|
+
'',
|
|
759
|
+
];
|
|
760
|
+
const criticalRisks = data.findings.filter(f => f.type === 'risk' && f.severity === 'critical');
|
|
761
|
+
if (!criticalRisks.length) lines.push(L('report.noCriticalRisks'), '');
|
|
762
|
+
for (const f of criticalRisks) lines.push(`- \`${f.id}\` ${f.title}`, '');
|
|
763
|
+
lines.push(`## ${L('report.duplicateGroupsSection')}`, '');
|
|
764
|
+
if (!data.duplicateGroups.length) lines.push(L('report.noDuplicateGroups'), '');
|
|
765
|
+
for (const group of data.duplicateGroups) lines.push(`- \`${group.id}\` ${group.strategy} confidence=${group.confidence}`);
|
|
766
|
+
lines.push('', `## ${L('report.findings')}`, '');
|
|
767
|
+
if (!data.findings.length) lines.push(L('report.noFindings'), '');
|
|
768
|
+
for (const f of data.findings) {
|
|
769
|
+
lines.push(`### ${t(`severity.${f.severity}`, lang)} - ${f.title}`, '', `- ${L('report.id')}: \`${f.id}\``, `- ${L('report.type')}: \`${f.type}\``, `- ${L('report.detector')}: \`${f.detectorId}\``);
|
|
770
|
+
if (f.ignored) lines.push(`- ${L('html.ignored')}: yes (${f.ignoredReason || 'no reason'})`);
|
|
771
|
+
if (f.skills?.length) lines.push(`- Skills: ${f.skills.map(s => `\`${s.slug}\``).join(', ')}`);
|
|
772
|
+
lines.push('', f.description, '');
|
|
773
|
+
if (f.recommendation) lines.push(`${L('report.recommendation')}: ${f.recommendation}`, '');
|
|
774
|
+
}
|
|
775
|
+
lines.push(`## ${L('report.skills')}`, '');
|
|
776
|
+
for (const s of data.skills) lines.push(`- \`${s.slug}\` - ${s.name} - ${s.local_path}`);
|
|
777
|
+
lines.push('');
|
|
778
|
+
return lines.join('\n');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function renderHtml(data, lang, reportPath) {
|
|
782
|
+
const severityColors = { critical: '#dc2626', high: '#ea580c', medium: '#ca8a04', low: '#2563eb', info: '#6b7280' };
|
|
783
|
+
const totalForBar = data.summary.totalFindings || 1;
|
|
784
|
+
|
|
785
|
+
// Dual-language helper: renders both en and zh spans
|
|
786
|
+
const D = (key, ...args) => {
|
|
787
|
+
const enText = escapeHtml(t(key, 'en', ...args));
|
|
788
|
+
const zhText = escapeHtml(t(key, 'zh', ...args));
|
|
789
|
+
if (enText === zhText) return enText;
|
|
790
|
+
return `<span data-lang="en">${enText}</span><span data-lang="zh">${zhText}</span>`;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Dual-language helper with newline-to-<br> conversion for <p> tags
|
|
794
|
+
const Dn = (key, ...args) => {
|
|
795
|
+
const enText = nl2br(t(key, 'en', ...args));
|
|
796
|
+
const zhText = nl2br(t(key, 'zh', ...args));
|
|
797
|
+
if (enText === zhText) return enText;
|
|
798
|
+
return `<span data-lang="en">${enText}</span><span data-lang="zh">${zhText}</span>`;
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// Translate finding title
|
|
802
|
+
const translateTitle = (title) => {
|
|
803
|
+
const key = `finding.${title}`;
|
|
804
|
+
const enText = t(key, 'en');
|
|
805
|
+
const zhText = t(key, 'zh');
|
|
806
|
+
const en = enText === key ? title : enText;
|
|
807
|
+
const zh = zhText === key ? title : zhText;
|
|
808
|
+
if (en === zh) return escapeHtml(en);
|
|
809
|
+
return `<span data-lang="en">${escapeHtml(en)}</span><span data-lang="zh">${escapeHtml(zh)}</span>`;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// Tooltip helper: renders a ? icon with hover tooltip
|
|
813
|
+
const tooltip = (titleKey, textKey) => {
|
|
814
|
+
return `<span class="tip-wrap"><span class="tip-icon">?</span><span class="tip-box"><strong>${D(titleKey)}</strong><br>${Dn(textKey)}</span></span>`;
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
// Skill classification
|
|
818
|
+
const OFFICIAL_ORGS = /github\.com\/(anthropics|openai|github|google-gemini)\//i;
|
|
819
|
+
const GITHUB_URL = /github\.com\//i;
|
|
820
|
+
|
|
821
|
+
function classifySkill(skill) {
|
|
822
|
+
const raw = typeof skill.raw_json === 'string' ? JSON.parse(skill.raw_json) : (skill.raw_json || {});
|
|
823
|
+
const sourceUrl = raw.source?.url || skill.source_url || '';
|
|
824
|
+
const rootType = raw.location?.rootType || skill.root_type || '';
|
|
825
|
+
const plugin = raw._sourcePlugin || '';
|
|
826
|
+
|
|
827
|
+
if (OFFICIAL_ORGS.test(sourceUrl)) return 'official';
|
|
828
|
+
if (plugin) {
|
|
829
|
+
if (/superpowers|using-superpowers/i.test(plugin)) return 'plugin';
|
|
830
|
+
if (/anthropics|openai|github|google/i.test(plugin)) return 'official';
|
|
831
|
+
return 'thirdParty';
|
|
832
|
+
}
|
|
833
|
+
if (rootType === 'project_local') return 'standalone';
|
|
834
|
+
// No "local" type — everything else is thirdParty
|
|
835
|
+
return 'thirdParty';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Build scan overview data
|
|
839
|
+
const skills = (data.skills || []).map(s => {
|
|
840
|
+
const raw = typeof s.raw_json === 'string' ? JSON.parse(s.raw_json) : (s.raw_json || {});
|
|
841
|
+
return {
|
|
842
|
+
name: s.name || raw.name || s.slug || '-',
|
|
843
|
+
slug: s.slug || raw.slug || '-',
|
|
844
|
+
path: s.local_path || raw.location?.path || '',
|
|
845
|
+
root: raw.location?.root || '',
|
|
846
|
+
rootType: raw.location?.rootType || s.root_type || '',
|
|
847
|
+
agent: raw.location?.agent || s.agent || '',
|
|
848
|
+
sourceUrl: raw.source?.url || s.source_url || '',
|
|
849
|
+
sourcePlugin: raw._sourcePlugin || '',
|
|
850
|
+
installedAt: raw._installedAt || '',
|
|
851
|
+
description: s.description || raw.description || '',
|
|
852
|
+
classification: classifySkill(s),
|
|
853
|
+
};
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Group skills by classification
|
|
857
|
+
const classOrder = ['official', 'plugin', 'standalone', 'thirdParty', 'unknown'];
|
|
858
|
+
const skillsByClass = {};
|
|
859
|
+
for (const cls of classOrder) skillsByClass[cls] = [];
|
|
860
|
+
for (const skill of skills) skillsByClass[skill.classification].push(skill);
|
|
861
|
+
|
|
862
|
+
// Group skills by root directory
|
|
863
|
+
const skillsByRoot = {};
|
|
864
|
+
for (const skill of skills) {
|
|
865
|
+
const root = skill.root || '(unknown root)';
|
|
866
|
+
if (!skillsByRoot[root]) skillsByRoot[root] = { rootType: skill.rootType, agent: skill.agent, skills: [] };
|
|
867
|
+
skillsByRoot[root].skills.push(skill);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Build scan overview HTML
|
|
871
|
+
const classColors = { official: '#10b981', plugin: '#8b5cf6', standalone: '#3b82f6', thirdParty: '#f59e0b', unknown: '#6b7280' };
|
|
872
|
+
const rootTypeLabel = (rt) => {
|
|
873
|
+
if (rt === 'central_library') return D('html.rootType.central');
|
|
874
|
+
if (rt === 'agent_global') return D('html.rootType.global');
|
|
875
|
+
if (rt === 'project_local') return D('html.rootType.project');
|
|
876
|
+
return rt || '-';
|
|
877
|
+
};
|
|
878
|
+
const classCounts = classOrder.map(cls => {
|
|
879
|
+
const count = skillsByClass[cls].length;
|
|
880
|
+
return count > 0 ? `<span class="legend-item"><span class="dot" style="background:${classColors[cls]}"></span>${D(`html.${cls}`)} (${count})</span>` : '';
|
|
881
|
+
}).filter(Boolean).join(' ');
|
|
882
|
+
|
|
883
|
+
const sortedRoots = Object.keys(skillsByRoot).sort();
|
|
884
|
+
const rootCards = sortedRoots.map(root => {
|
|
885
|
+
const group = skillsByRoot[root];
|
|
886
|
+
const agentTag = group.agent ? `<span class="tag">${escapeHtml(t(`agent.${group.agent}`, lang, group.agent))}</span>` : '';
|
|
887
|
+
const typeTag = `<span class="tag">${rootTypeLabel(group.rootType)}</span>`;
|
|
888
|
+
const tableRows = group.skills.map(s => {
|
|
889
|
+
const clsLabel = D(`html.${s.classification}`);
|
|
890
|
+
const srcCell = s.sourcePlugin ? `<code>${escapeHtml(s.sourcePlugin)}</code>` : (s.sourceUrl ? escapeHtml(s.sourceUrl) : clsLabel);
|
|
891
|
+
return `<tr><td><span class="badge" style="background:${classColors[s.classification]};font-size:0.65rem;padding:0.1rem 0.4rem">${clsLabel}</span></td><td class="skill-name">${escapeHtml(s.name)}</td><td>${srcCell}</td><td>${escapeHtml(s.description || '-').substring(0, 80)}</td></tr>`;
|
|
892
|
+
}).join('');
|
|
893
|
+
const table = `<table class="skill-table"><thead><tr><th>${D('report.type')}</th><th>${D('report.name')}</th><th>${D('html.source')}</th><th>${D('report.description')}</th></tr></thead><tbody>${tableRows}</tbody></table>`;
|
|
894
|
+
return `<details class="root-card"><summary><span class="root-path">${escapeHtml(root)}</span>${agentTag}${typeTag}<span class="tag">${group.skills.length}</span></summary><div class="root-body">${table}</div></details>`;
|
|
895
|
+
}).join('\n');
|
|
896
|
+
|
|
897
|
+
const scanOverviewHtml = `
|
|
898
|
+
<div class="scan-class-legend">${classCounts}</div>
|
|
899
|
+
${rootCards || `<p>${D('html.noSkills')}</p>`}
|
|
900
|
+
`;
|
|
901
|
+
|
|
902
|
+
// Summary cards: overview first, then actionable (red→orange→yellow→green), then informational (gray)
|
|
903
|
+
const scanRootCount = skillsByRoot ? Object.keys(skillsByRoot).length : 0;
|
|
904
|
+
const cardDefs = [
|
|
905
|
+
// Overview
|
|
906
|
+
{ key: 'report.scanDirs', descKey: 'dashboard.scanDirs.desc', value: scanRootCount, color: '#0ea5e9', tooltipTitle: null, tooltipText: null, filterFn: null },
|
|
907
|
+
{ key: 'report.scanSkills', descKey: 'dashboard.scanSkills.desc', value: data.summary.totalSkills, color: '#3b82f6', tooltipTitle: null, tooltipText: null, filterFn: null },
|
|
908
|
+
{ key: 'report.totalFindings', descKey: 'dashboard.totalFindings.desc', value: data.summary.totalFindings, color: '#8b5cf6', tooltipTitle: null, tooltipText: null, filterFn: null },
|
|
909
|
+
// Actionable — high priority (red)
|
|
910
|
+
{ key: 'report.conflictFindings', descKey: 'dashboard.conflictFindings.desc', value: data.summary.conflictFindings, color: '#ef4444', tooltipTitle: 'tooltip.conflict.title', tooltipText: 'tooltip.conflict.text', filterFn: f => f.type === 'conflict' },
|
|
911
|
+
// Actionable — medium priority (orange)
|
|
912
|
+
{ key: 'report.zombieCandidates', descKey: 'dashboard.zombieCandidates.desc', value: data.summary.zombieCandidates, color: '#f59e0b', tooltipTitle: 'tooltip.zombie.title', tooltipText: 'tooltip.zombie.text', filterFn: f => f.type === 'zombie' },
|
|
913
|
+
// Actionable — lower priority (yellow)
|
|
914
|
+
{ key: 'report.duplicateFindings', descKey: 'dashboard.duplicateFindings.desc', value: data.summary.duplicateFindings, color: '#eab308', tooltipTitle: 'tooltip.duplicate.title', tooltipText: 'tooltip.duplicate.text', filterFn: f => f.type === 'duplicate' },
|
|
915
|
+
{ key: 'report.versionDriftFindings', descKey: 'dashboard.versionDriftFindings.desc', value: data.summary.versionDriftFindings, color: '#84cc16', tooltipTitle: 'tooltip.versionDrift.title', tooltipText: 'tooltip.versionDrift.text', filterFn: f => f.type === 'version_drift' },
|
|
916
|
+
// Informational — not actionable (gray, with ? tooltip)
|
|
917
|
+
{ key: 'report.riskFindings', descKey: 'dashboard.riskFindings.desc', value: data.summary.riskFindings, color: '#6b7280', tooltipTitle: 'tooltip.risk.title', tooltipText: 'tooltip.risk.text', filterFn: f => f.type === 'risk', informational: true },
|
|
918
|
+
{ key: 'report.descriptionQualityFindings', descKey: 'dashboard.descriptionQualityFindings.desc', value: data.summary.descriptionQualityFindings, color: '#6b7280', tooltipTitle: 'tooltip.descriptionQuality.title', tooltipText: 'tooltip.descriptionQuality.text', filterFn: f => f.type === 'description_quality', informational: true },
|
|
919
|
+
];
|
|
920
|
+
|
|
921
|
+
const summaryCards = cardDefs.map(c => {
|
|
922
|
+
const tip = c.tooltipTitle ? tooltip(c.tooltipTitle, c.tooltipText) : '';
|
|
923
|
+
// Build collapsible detail list for this card type
|
|
924
|
+
let detailSection = '';
|
|
925
|
+
if (c.filterFn) {
|
|
926
|
+
const items = data.findings.filter(c.filterFn);
|
|
927
|
+
if (items.length > 0) {
|
|
928
|
+
const rows = items.map(f => {
|
|
929
|
+
const skills = (f.skills || []).map(s => escapeHtml(s.slug)).join(', ');
|
|
930
|
+
return `<div class="card-item"><span class="badge badge-${f.severity}" style="font-size:0.6rem;padding:0.1rem 0.35rem">${D(`severity.${f.severity}`)}</span> ${translateTitle(f.title)} <span class="card-item-skills">${skills ? `[${skills}]` : ''}</span></div>`;
|
|
931
|
+
}).join('');
|
|
932
|
+
detailSection = `<details class="card-details"><summary>${D('html.expandDetails')}</summary><div class="card-detail-list">${rows}</div></details>`;
|
|
933
|
+
}
|
|
934
|
+
} else if (c.key === 'report.zombieCandidates') {
|
|
935
|
+
detailSection = `<div class="card-detail"><strong>${D('tooltip.zombie.formula')}</strong><br><code>${Dn('tooltip.zombie.formulaText')}</code></div>`;
|
|
936
|
+
} else if (c.key === 'report.scanDirs' && skillsByRoot) {
|
|
937
|
+
const dirList = Object.keys(skillsByRoot).sort().map(r => `<div class="card-item"><code>${escapeHtml(r)}</code> <span class="card-item-skills">(${skillsByRoot[r].skills.length})</span></div>`).join('');
|
|
938
|
+
detailSection = `<details class="card-details"><summary>${D('html.expandDetails')}</summary><div class="card-detail-list">${dirList}</div></details>`;
|
|
939
|
+
}
|
|
940
|
+
const infoBadge = c.informational ? `<span style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#6b728033;color:var(--muted);font-size:0.6rem;font-weight:700;margin-left:0.25rem;cursor:help" title="${lang === 'zh' ? '仅供参考,不参与技能优化决策' : 'Informational only, not actionable'}">?</span>` : '';
|
|
941
|
+
const cardContent = `<div class="stat-value">${c.value}</div><div class="stat-label">${D(c.key)} ${tip}${infoBadge}</div><div class="stat-desc">${Dn(c.descKey)}</div>${detailSection}`;
|
|
942
|
+
return `<div class="stat-card" style="border-left:4px solid ${c.color}${c.informational ? ';opacity:0.75' : ''}">${cardContent}</div>`;
|
|
943
|
+
}).join('\n');
|
|
944
|
+
|
|
945
|
+
// Severity bar
|
|
946
|
+
const severityBar = ['critical', 'high', 'medium', 'low', 'info'].map(sev => {
|
|
947
|
+
const count = data.summary.bySeverity[sev] || 0;
|
|
948
|
+
const pct = (count / totalForBar * 100).toFixed(1);
|
|
949
|
+
return count > 0 ? `<div class="bar-seg bar-${sev}" style="width:${pct}%" title="${escapeHtml(t(`severity.${sev}`, 'en'))}/${escapeHtml(t(`severity.${sev}`, 'zh'))}: ${count}"></div>` : '';
|
|
950
|
+
}).join('');
|
|
951
|
+
|
|
952
|
+
// Severity legend
|
|
953
|
+
const severityLegend = ['critical', 'high', 'medium', 'low', 'info'].map(sev => {
|
|
954
|
+
const count = data.summary.bySeverity[sev] || 0;
|
|
955
|
+
return `<span class="legend-item"><span class="dot" style="background:${severityColors[sev]}"></span>${D(`severity.${sev}`)} (${count})</span>`;
|
|
956
|
+
}).join(' ');
|
|
957
|
+
|
|
958
|
+
// Remediation path — prioritized roadmap with specific recommendations
|
|
959
|
+
const reportPathForPrompt = reportPath ? reportPath.replace(/\\/g, '/') : '';
|
|
960
|
+
const pathHintEn = reportPathForPrompt ? ` The diagnostic report is at: ${reportPathForPrompt}` : '';
|
|
961
|
+
const pathHintZh = reportPathForPrompt ? ` 诊断报告路径:${reportPathForPrompt}` : '';
|
|
962
|
+
|
|
963
|
+
// Helper: build skill info map
|
|
964
|
+
const skillInfoMap = {};
|
|
965
|
+
for (const s of data.skills) skillInfoMap[s.id] = { name: s.name || s.slug, slug: s.slug, path: s.local_path || '' };
|
|
966
|
+
|
|
967
|
+
// Step 1: Conflicts
|
|
968
|
+
const conflictFindings = data.findings.filter(f => f.type === 'conflict');
|
|
969
|
+
// Step 2: Version drift + duplicates — build specific keep/remove recommendations
|
|
970
|
+
const driftFindings = data.findings.filter(f => f.type === 'version_drift');
|
|
971
|
+
const dupGroups = data.duplicateGroups || [];
|
|
972
|
+
const dupMembers = data.duplicateGroupMembers || [];
|
|
973
|
+
|
|
974
|
+
// For each duplicate group, recommend which to keep
|
|
975
|
+
const dupRecommendations = dupGroups.map(g => {
|
|
976
|
+
const members = dupMembers.filter(m => m.group_id === g.id).map(m => ({
|
|
977
|
+
...m, ...(skillInfoMap[m.skill_id] || { name: m.skill_id, slug: '', path: '' })
|
|
978
|
+
}));
|
|
979
|
+
// Heuristic: prefer agent_global > central_library > project_local; prefer canonical role
|
|
980
|
+
const rootPriority = { agent_global: 0, central_library: 1, project_local: 2, unknown: 3 };
|
|
981
|
+
const sorted = members.sort((a, b) => {
|
|
982
|
+
const skillA = data.skills.find(s => s.id === a.skill_id);
|
|
983
|
+
const skillB = data.skills.find(s => s.id === b.skill_id);
|
|
984
|
+
const rtA = rootPriority[skillA?.root_type] ?? 3;
|
|
985
|
+
const rtB = rootPriority[skillB?.root_type] ?? 3;
|
|
986
|
+
if (a.role === 'canonical' && b.role !== 'canonical') return -1;
|
|
987
|
+
if (b.role === 'canonical' && a.role !== 'canonical') return 1;
|
|
988
|
+
return rtA - rtB;
|
|
989
|
+
});
|
|
990
|
+
const keep = sorted[0];
|
|
991
|
+
const remove = sorted.slice(1);
|
|
992
|
+
return { group: g, keep, remove, members: sorted };
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// Step 3: Zombies — sorted by score descending
|
|
996
|
+
const zombieFindings = data.findings.filter(f => f.type === 'zombie');
|
|
997
|
+
const zombieBySkill = {};
|
|
998
|
+
for (const f of zombieFindings) {
|
|
999
|
+
for (const s of (f.skills || [])) {
|
|
1000
|
+
if (!zombieBySkill[s.slug]) zombieBySkill[s.slug] = { finding: f, skill: s };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const allZombies = Object.values(zombieBySkill).sort((a, b) => (b.finding.score || 0) - (a.finding.score || 0));
|
|
1004
|
+
const zombieHigh = allZombies.filter(z => ['critical', 'high'].includes(z.finding.severity));
|
|
1005
|
+
const zombieMedium = allZombies.filter(z => z.finding.severity === 'medium');
|
|
1006
|
+
const zombieLow = allZombies.filter(z => z.finding.severity === 'low');
|
|
1007
|
+
|
|
1008
|
+
// Step 4: Description quality
|
|
1009
|
+
const dqFindings = data.findings.filter(f => f.type === 'description_quality');
|
|
1010
|
+
// Step 5: Risk (informational only)
|
|
1011
|
+
const riskFindings = data.findings.filter(f => f.type === 'risk');
|
|
1012
|
+
|
|
1013
|
+
// Build remediation path HTML
|
|
1014
|
+
const stepStyle = 'padding:0.4rem 0.75rem;border-radius:6px;font-size:0.85rem;margin-bottom:0.5rem';
|
|
1015
|
+
|
|
1016
|
+
let pathHtml = '';
|
|
1017
|
+
|
|
1018
|
+
// Step 1: Conflicts
|
|
1019
|
+
const conflictCount = conflictFindings.length;
|
|
1020
|
+
pathHtml += `<div style="${stepStyle};background:${conflictCount > 0 ? 'var(--c-critical)' : '#10b981'}22;border-left:4px solid ${conflictCount > 0 ? 'var(--c-critical)' : '#10b981'}">
|
|
1021
|
+
<strong>${lang === 'zh' ? '第 1 步:冲突检测' : 'Step 1: Conflict Detection'}</strong> <span class="tag">${conflictCount}</span><br>
|
|
1022
|
+
<span style="color:var(--muted);font-size:0.8rem">${conflictCount === 0 ? (lang === 'zh' ? '无冲突,技能间指令一致。' : 'No conflicts found. Skills have consistent instructions.') : (lang === 'zh' ? '存在冲突指令,需要选择保留哪一方。' : 'Conflicting instructions found. Choose which to keep.')}</span>
|
|
1023
|
+
</div>`;
|
|
1024
|
+
|
|
1025
|
+
// Step 2: Duplicates + Version Drift
|
|
1026
|
+
const dupDriftCount = dupRecommendations.length + driftFindings.length;
|
|
1027
|
+
let dupDriftDetail = '';
|
|
1028
|
+
if (dupDriftCount > 0) {
|
|
1029
|
+
for (const rec of dupRecommendations) {
|
|
1030
|
+
const strat = { exact_duplicate: lang === 'zh' ? '完全重复' : 'Exact duplicate', same_source_duplicate: lang === 'zh' ? '同源重复' : 'Same source', same_name_duplicate: lang === 'zh' ? '同名重复' : 'Same name' }[rec.group.strategy] || rec.group.strategy;
|
|
1031
|
+
dupDriftDetail += `<div style="margin:0.5rem 0;padding:0.5rem;background:var(--bg);border-radius:6px;font-size:0.8rem">
|
|
1032
|
+
<strong>${strat}</strong> (${lang === 'zh' ? '置信度' : 'Confidence'}: ${rec.group.confidence})<br>`;
|
|
1033
|
+
for (const m of rec.members) {
|
|
1034
|
+
const isKeep = m === rec.keep;
|
|
1035
|
+
dupDriftDetail += `<div style="margin:0.2rem 0">${isKeep ? '<span style="color:#10b981;font-weight:700">✓ ' + (lang === 'zh' ? '保留' : 'KEEP') + '</span>' : '<span style="color:#ef4444;font-weight:700">✗ ' + (lang === 'zh' ? '移除' : 'REMOVE') + '</span>'} <code>${escapeHtml(m.slug || m.name)}</code> <small style="color:var(--muted)">${escapeHtml(m.path)}</small></div>`;
|
|
1036
|
+
}
|
|
1037
|
+
dupDriftDetail += `</div>`;
|
|
1038
|
+
}
|
|
1039
|
+
for (const f of driftFindings) {
|
|
1040
|
+
const driftSkills = (f.skills || []).map(s => ({ ...s, ...(skillInfoMap[s.id] || {}) }));
|
|
1041
|
+
// Prefer agent_global version
|
|
1042
|
+
const sorted = driftSkills.sort((a, b) => {
|
|
1043
|
+
const rtA = a.root_type === 'agent_global' ? 0 : 1;
|
|
1044
|
+
const rtB = b.root_type === 'agent_global' ? 0 : 1;
|
|
1045
|
+
return rtA - rtB;
|
|
1046
|
+
});
|
|
1047
|
+
dupDriftDetail += `<div style="margin:0.5rem 0;padding:0.5rem;background:var(--bg);border-radius:6px;font-size:0.8rem">
|
|
1048
|
+
<strong>${lang === 'zh' ? '版本漂移' : 'Version Drift'}</strong>: <code>${escapeHtml(sorted[0]?.slug || '')}</code><br>`;
|
|
1049
|
+
for (const s of sorted) {
|
|
1050
|
+
const isKeep = s === sorted[0];
|
|
1051
|
+
dupDriftDetail += `<div style="margin:0.2rem 0">${isKeep ? '<span style="color:#10b981;font-weight:700">✓ ' + (lang === 'zh' ? '保留' : 'KEEP') + '</span>' : '<span style="color:#ef4444;font-weight:700">✗ ' + (lang === 'zh' ? '移除' : 'REMOVE') + '</span>'} <code>${escapeHtml(s.slug || s.name)}</code> <small style="color:var(--muted)">${escapeHtml(s.path || s.local_path || '')}</small></div>`;
|
|
1052
|
+
}
|
|
1053
|
+
dupDriftDetail += `</div>`;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
pathHtml += `<div style="${stepStyle};background:${dupDriftCount > 0 ? '#f59e0b' : '#10b981'}22;border-left:4px solid ${dupDriftCount > 0 ? '#f59e0b' : '#10b981'}">
|
|
1057
|
+
<strong>${lang === 'zh' ? '第 2 步:重复技能 & 版本漂移' : 'Step 2: Duplicates & Version Drift'}</strong> <span class="tag">${dupDriftCount}</span><br>
|
|
1058
|
+
<span style="color:var(--muted);font-size:0.8rem">${dupDriftCount === 0 ? (lang === 'zh' ? '无重复或漂移。' : 'No duplicates or drift found.') : (lang === 'zh' ? '保留推荐版本,移除冗余副本。' : 'Keep recommended versions, remove redundant copies.')}</span>
|
|
1059
|
+
${dupDriftDetail ? `<details style="margin-top:0.5rem"><summary style="cursor:pointer;font-size:0.8rem;color:var(--muted)">${lang === 'zh' ? '查看详情' : 'View details'}</summary>${dupDriftDetail}</details>` : ''}
|
|
1060
|
+
</div>`;
|
|
1061
|
+
|
|
1062
|
+
// Step 3: Zombies (sorted by score desc)
|
|
1063
|
+
const zombieTotal = zombieFindings.length;
|
|
1064
|
+
let zombieDetail = '';
|
|
1065
|
+
if (zombieTotal > 0) {
|
|
1066
|
+
const showZombies = allZombies.slice(0, 15);
|
|
1067
|
+
zombieDetail = showZombies.map(z => {
|
|
1068
|
+
const score = (z.finding.score || 0).toFixed(2);
|
|
1069
|
+
const desc = z.finding.description || '';
|
|
1070
|
+
const factorsMatch = desc.match(/Factors:\s*(.+?)\.?\s*$/);
|
|
1071
|
+
const factors = factorsMatch ? factorsMatch[1] : '';
|
|
1072
|
+
return `<div style="margin:0.3rem 0;font-size:0.8rem"><span class="badge badge-${z.finding.severity}" style="font-size:0.6rem;padding:0.1rem 0.35rem">${D(`severity.${z.finding.severity}`)}</span> <code>${escapeHtml(z.skill?.slug || z.skill?.name || '')}</code> <span style="color:var(--muted)">(score: ${score})</span>${factors ? `<br><span style="font-size:0.75rem;color:var(--muted);padding-left:1.5rem">${escapeHtml(factors)}</span>` : ''}</div>`;
|
|
1073
|
+
}).join('');
|
|
1074
|
+
if (zombieTotal > 15) zombieDetail += `<div style="font-size:0.75rem;color:var(--muted)">... ${lang === 'zh' ? '还有' : 'and'} ${zombieTotal - 15} ${lang === 'zh' ? '个' : 'more'}</div>`;
|
|
1075
|
+
}
|
|
1076
|
+
pathHtml += `<div style="${stepStyle};background:${zombieTotal > 0 ? '#f59e0b' : '#10b981'}22;border-left:4px solid ${zombieTotal > 0 ? '#f59e0b' : '#10b981'}">
|
|
1077
|
+
<strong>${lang === 'zh' ? '第 3 步:僵尸技能' : 'Step 3: Zombie Skills'}</strong> <span class="tag">${zombieTotal}</span><br>
|
|
1078
|
+
<span style="color:var(--muted);font-size:0.8rem">${zombieTotal === 0 ? (lang === 'zh' ? '无僵尸技能。' : 'No zombie skills found.') : (lang === 'zh' ? '优先清理高分僵尸,低分的可暂保留。' : 'Prioritize high-score zombies. Low-score ones can be kept for now.')}</span>
|
|
1079
|
+
${zombieDetail ? `<details style="margin-top:0.5rem"><summary style="cursor:pointer;font-size:0.8rem;color:var(--muted)">${lang === 'zh' ? '查看详情' : 'View details'}</summary>${zombieDetail}</details>` : ''}
|
|
1080
|
+
</div>`;
|
|
1081
|
+
|
|
1082
|
+
// Step 4: Description quality
|
|
1083
|
+
const dqCount = dqFindings.length;
|
|
1084
|
+
pathHtml += `<div style="${stepStyle};background:${dqCount > 0 ? '#a855f7' : '#10b981'}22;border-left:4px solid ${dqCount > 0 ? '#a855f7' : '#10b981'}">
|
|
1085
|
+
<strong>${lang === 'zh' ? '第 4 步:描述质量' : 'Step 4: Description Quality'}</strong> <span class="tag">${dqCount}</span><br>
|
|
1086
|
+
<span style="color:var(--muted);font-size:0.8rem">${dqCount === 0 ? (lang === 'zh' ? '所有技能描述质量合格。' : 'All skill descriptions pass quality check.') : (lang === 'zh' ? '可交给 Agent 自动补充描述,优先级较低。' : 'Can be auto-improved by Agent. Lower priority.')}</span>
|
|
1087
|
+
</div>`;
|
|
1088
|
+
|
|
1089
|
+
// Step 5: Risk (informational)
|
|
1090
|
+
const riskCount = riskFindings.length;
|
|
1091
|
+
pathHtml += `<div style="${stepStyle};background:#6b728022;border-left:4px solid #6b7280">
|
|
1092
|
+
<strong>${lang === 'zh' ? '第 5 步:风险发现(仅供参考)' : 'Step 5: Risk Findings (Informational)'}</strong> <span class="tag">${riskCount}</span><br>
|
|
1093
|
+
<span style="color:var(--muted);font-size:0.8rem">${riskCount === 0 ? (lang === 'zh' ? '无风险发现。' : 'No risk findings.') : (lang === 'zh' ? '风险是技能本身需要的权限,无需修复,仅作标注。' : 'Risks are inherent permissions required by skills. No fix needed, just awareness.')}</span>
|
|
1094
|
+
</div>`;
|
|
1095
|
+
|
|
1096
|
+
// Agent prompt for duplicates/drift cleanup
|
|
1097
|
+
let dupDriftAgentPrompt = '';
|
|
1098
|
+
if (dupDriftCount > 0) {
|
|
1099
|
+
const removeList = [];
|
|
1100
|
+
for (const rec of dupRecommendations) {
|
|
1101
|
+
for (const m of rec.remove) removeList.push(m.path || m.slug || m.name);
|
|
1102
|
+
}
|
|
1103
|
+
for (const f of driftFindings) {
|
|
1104
|
+
const driftSkills = (f.skills || []).sort((a, b) => (a.root_type === 'agent_global' ? 0 : 1) - (b.root_type === 'agent_global' ? 0 : 1));
|
|
1105
|
+
for (const s of driftSkills.slice(1)) removeList.push(s.path || s.local_path || s.slug);
|
|
1106
|
+
}
|
|
1107
|
+
if (removeList.length > 0) {
|
|
1108
|
+
const promptEn = `Please remove the following redundant skill copies:${pathHintEn}\n\nRemove these paths:\n${removeList.map(p => `- ${p}`).join('\n')}\n\nKeep the recommended versions listed in the diagnostic report.`;
|
|
1109
|
+
const promptZh = `请移除以下冗余技能副本:${pathHintZh}\n\n移除以下路径:\n${removeList.map(p => `- ${p}`).join('\n')}\n\n保留诊断报告中推荐的版本。`;
|
|
1110
|
+
dupDriftAgentPrompt = `<h3 style="font-size:0.95rem;margin:1rem 0 0.5rem;color:var(--muted)">${lang === 'zh' ? '重复/漂移清理提示词' : 'Duplicates/Drift Cleanup Prompt'}</h3><div class="prompt-block"><pre class="prompt"><span data-lang="en">${escapeHtml(promptEn)}</span><span data-lang="zh">${escapeHtml(promptZh)}</span></pre><button class="copy-btn" onclick="copyPrompt(this)">${D('fix.copyAgentPrompt')}</button></div>`;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Agent prompt for zombie cleanup (sorted by score desc)
|
|
1115
|
+
let zombieAgentPrompt = '';
|
|
1116
|
+
if (zombieTotal > 0) {
|
|
1117
|
+
const zombieList = allZombies.map(z => {
|
|
1118
|
+
const score = (z.finding.score || 0).toFixed(2);
|
|
1119
|
+
const p = z.skill?.path || z.skill?.local_path || z.skill?.slug || z.skill?.name || '';
|
|
1120
|
+
return `- ${p} (score: ${score})`;
|
|
1121
|
+
});
|
|
1122
|
+
const promptEn = `Please review the following zombie skills and decide which to remove or archive:${pathHintEn}\n\nFor each skill:\n- score >= 0.7: recommend removing\n- score 0.4-0.7: review and decide\n- Keep skills you actively use or plan to use\n\nZombie skills (sorted by score, higher = more likely zombie):\n${zombieList.join('\n')}`;
|
|
1123
|
+
const promptZh = `请审查以下僵尸技能,决定哪些需要移除或归档:${pathHintZh}\n\n对于每个技能:\n- 评分 >= 0.7:建议移除\n- 评分 0.4-0.7:审查后决定\n- 保留你正在使用或计划使用的技能\n\n僵尸技能(按评分排序,分数越高越可能是僵尸):\n${zombieList.join('\n')}`;
|
|
1124
|
+
zombieAgentPrompt = `<h3 style="font-size:0.95rem;margin:1rem 0 0.5rem;color:var(--muted)">${lang === 'zh' ? '僵尸技能清理提示词' : 'Zombie Skills Cleanup Prompt'}</h3><div class="prompt-block"><pre class="prompt"><span data-lang="en">${escapeHtml(promptEn)}</span><span data-lang="zh">${escapeHtml(promptZh)}</span></pre><button class="copy-btn" onclick="copyPrompt(this)">${D('fix.copyAgentPrompt')}</button></div>`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const remediationPathHtml = `<div style="margin-bottom:1.5rem">${pathHtml}</div>${dupDriftAgentPrompt}${zombieAgentPrompt}`;
|
|
1128
|
+
|
|
1129
|
+
// Per-finding detail (grouped by type)
|
|
1130
|
+
const findingsByType = {};
|
|
1131
|
+
for (const f of data.findings) {
|
|
1132
|
+
if (!findingsByType[f.type]) findingsByType[f.type] = {};
|
|
1133
|
+
const skillSlugs = (f.skills || []).map(s => s.slug);
|
|
1134
|
+
if (skillSlugs.length === 0) skillSlugs.push('<unknown>');
|
|
1135
|
+
for (const slug of skillSlugs) {
|
|
1136
|
+
if (!findingsByType[f.type][slug]) findingsByType[f.type][slug] = [];
|
|
1137
|
+
findingsByType[f.type][slug].push(f);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const perFindingHtml = data.findings.length === 0
|
|
1142
|
+
? `<p>${D('fix.noRisks')}</p>`
|
|
1143
|
+
: Object.entries(findingsByType).map(([type, skillMap]) => {
|
|
1144
|
+
const allFindings = Object.values(skillMap).flat();
|
|
1145
|
+
const count = allFindings.length;
|
|
1146
|
+
// Sort zombie entries by score descending
|
|
1147
|
+
const sortedEntries = type === 'zombie'
|
|
1148
|
+
? Object.entries(skillMap).sort((a, b) => ((b[1][0]?.score || 0) - (a[1][0]?.score || 0)))
|
|
1149
|
+
: Object.entries(skillMap);
|
|
1150
|
+
const skillRows = sortedEntries.map(([slug, findings]) => {
|
|
1151
|
+
const findingItems = findings.map(f => {
|
|
1152
|
+
const evidenceList = (f.evidence || []).map(e => `<small style="color:var(--muted)">${escapeHtml(e.file || '')}${e.lineStart ? ':' + e.lineStart : ''}</small>`).join(', ');
|
|
1153
|
+
const scoreTag = type === 'zombie' && f.score != null ? ` <span style="color:var(--muted);font-size:0.75rem">(score: ${Number(f.score).toFixed(2)})</span>` : '';
|
|
1154
|
+
return `<li><span class="badge badge-${f.severity}" style="font-size:0.6rem;padding:0.1rem 0.35rem">${D(`severity.${f.severity}`)}</span>${scoreTag} ${translateTitle(f.title)} — ${escapeHtml(f.description || '')} ${evidenceList}</li>`;
|
|
1155
|
+
}).join('');
|
|
1156
|
+
return `<div class="card-item"><strong>${escapeHtml(slug)}</strong><ul style="margin:0.25rem 0 0.5rem 1.5rem">${findingItems}</ul></div>`;
|
|
1157
|
+
}).join('');
|
|
1158
|
+
const agentLines = Object.entries(skillMap).map(([slug, findings]) => {
|
|
1159
|
+
const detailLines = findings.map(f =>
|
|
1160
|
+
` - [${f.severity}] ${f.title}: ${f.description || ''}${f.recommendation ? ' | ' + f.recommendation : ''}`
|
|
1161
|
+
).join('\n');
|
|
1162
|
+
return `技能: ${slug}\n${detailLines}`;
|
|
1163
|
+
}).join('\n\n');
|
|
1164
|
+
const agentLinesEn = Object.entries(skillMap).map(([slug, findings]) => {
|
|
1165
|
+
const detailLines = findings.map(f =>
|
|
1166
|
+
` - [${f.severity}] ${f.title}: ${f.description || ''}${f.recommendation ? ' | ' + f.recommendation : ''}`
|
|
1167
|
+
).join('\n');
|
|
1168
|
+
return `Skill: ${slug}\n${detailLines}`;
|
|
1169
|
+
}).join('\n\n');
|
|
1170
|
+
return `
|
|
1171
|
+
<details class="finding-card">
|
|
1172
|
+
<summary>
|
|
1173
|
+
<span class="tag">${D(`type.${type}`)}</span>
|
|
1174
|
+
<span class="finding-title">${D(`guide.${type}.title`)}</span>
|
|
1175
|
+
<span class="tag">${count}</span>
|
|
1176
|
+
</summary>
|
|
1177
|
+
<div class="finding-body">
|
|
1178
|
+
${skillRows}
|
|
1179
|
+
<div class="fix-versions">
|
|
1180
|
+
<div class="fix-ver">
|
|
1181
|
+
<h4>${D('fix.humanVersion')}</h4>
|
|
1182
|
+
<pre class="steps">${D(`guide.${type}.steps`)}</pre>
|
|
1183
|
+
</div>
|
|
1184
|
+
<div class="fix-ver">
|
|
1185
|
+
<h4>${D('fix.agentVersion')}</h4>
|
|
1186
|
+
<div class="prompt-block">
|
|
1187
|
+
<pre class="prompt"><span data-lang="en">${escapeHtml(`Please fix all ${type} issues:${pathHintEn}\n\n${agentLinesEn}`)}</span><span data-lang="zh">${escapeHtml(`请修复以下 ${type} 类型的所有问题:${pathHintZh}\n\n${agentLines}`)}</span></pre>
|
|
1188
|
+
<button class="copy-btn" onclick="copyPrompt(this)">${D('fix.copyAgentPrompt')}</button>
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
</div>
|
|
1193
|
+
</details>`;
|
|
1194
|
+
}).join('\n');
|
|
1195
|
+
|
|
1196
|
+
// Findings (risk list)
|
|
1197
|
+
const findingsHtml = data.findings.map(f => {
|
|
1198
|
+
const skillsList = (f.skills || []).map(s => `<code>${escapeHtml(s.slug)}</code>`).join(', ');
|
|
1199
|
+
const evidenceList = (f.evidence || []).map(e => `<li>${escapeHtml(e.file || '')}${e.lineStart ? `:${e.lineStart}` : ''} — <code>${escapeHtml(e.text || e.anchor || '')}</code></li>`).join('');
|
|
1200
|
+
return `
|
|
1201
|
+
<details class="finding-card">
|
|
1202
|
+
<summary>
|
|
1203
|
+
<span class="badge badge-${f.severity}">${D(`severity.${f.severity}`)}</span>
|
|
1204
|
+
<span class="finding-title">${translateTitle(f.title)}</span>
|
|
1205
|
+
<span class="tag">${D(`type.${f.type}`)}</span>
|
|
1206
|
+
${f.ignored ? `<span class="tag tag-ignored">${D('html.ignored')}</span>` : ''}
|
|
1207
|
+
</summary>
|
|
1208
|
+
<div class="finding-body">
|
|
1209
|
+
<p>${escapeHtml(f.description)}</p>
|
|
1210
|
+
${evidenceList ? `<details><summary>${D('report.evidence')}</summary><ul>${evidenceList}</ul></details>` : ''}
|
|
1211
|
+
${f.recommendation ? `<p><strong>${D('report.recommendation')}:</strong> ${escapeHtml(f.recommendation)}</p>` : ''}
|
|
1212
|
+
${skillsList ? `<p><strong>${D('html.skillsInvolved')}:</strong> ${skillsList}</p>` : ''}
|
|
1213
|
+
<p class="finding-id"><small>ID: <code>${escapeHtml(f.id)}</code></small></p>
|
|
1214
|
+
</div>
|
|
1215
|
+
</details>`;
|
|
1216
|
+
}).join('\n');
|
|
1217
|
+
|
|
1218
|
+
return `<!DOCTYPE html>
|
|
1219
|
+
<html lang="${lang || 'en'}">
|
|
1220
|
+
<head>
|
|
1221
|
+
<meta charset="utf-8">
|
|
1222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1223
|
+
<title>${t('report.title', lang || 'en')}</title>
|
|
1224
|
+
<style>
|
|
1225
|
+
:root{--c-critical:#dc2626;--c-high:#ea580c;--c-medium:#ca8a04;--c-low:#2563eb;--c-info:#6b7280;--bg:#f8fafc;--card:#fff;--text:#1e293b;--border:#e2e8f0;--muted:#64748b}
|
|
1226
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0f172a;--card:#1e293b;--text:#e2e8f0;--border:#334155;--muted:#94a3b8}}
|
|
1227
|
+
*{box-sizing:border-box;margin:0}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;padding:1.5rem}
|
|
1228
|
+
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;flex-wrap:wrap;gap:0.5rem}h1{font-size:1.5rem}h2{font-size:1.2rem;margin:1.5rem 0 0.75rem;padding-bottom:0.5rem;border-bottom:2px solid var(--border)}
|
|
1229
|
+
.lang-btn{padding:0.4rem 1rem;border:1px solid var(--border);border-radius:6px;background:var(--card);cursor:pointer;font-size:0.85rem;color:var(--text)}
|
|
1230
|
+
.dashboard{display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem;margin-bottom:1.5rem}@media(max-width:900px){.dashboard{grid-template-columns:repeat(3,1fr)}}@media(max-width:500px){.dashboard{grid-template-columns:repeat(2,1fr)}}
|
|
1231
|
+
.stat-card{background:var(--card);border-radius:8px;padding:1rem;box-shadow:0 1px 3px rgba(0,0,0,0.08)}.stat-value{font-size:1.75rem;font-weight:700}.stat-label{font-size:0.8rem;color:var(--muted);margin-top:0.25rem;display:flex;align-items:center;gap:0.3rem}.stat-desc{font-size:0.7rem;color:var(--muted);margin-top:0.35rem;line-height:1.4}
|
|
1232
|
+
.card-detail{margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid var(--border);font-size:0.75rem;color:var(--muted)}.card-detail code{font-size:0.7rem;white-space:pre-wrap}
|
|
1233
|
+
.card-details{margin-top:0.5rem}.card-details summary{font-size:0.7rem;color:var(--muted);cursor:pointer;padding:0.2rem 0;display:flex}.card-details summary:hover{color:var(--text)}
|
|
1234
|
+
.card-detail-list{max-height:200px;overflow-y:auto;padding:0.25rem 0}.card-item{font-size:0.75rem;padding:0.2rem 0;display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap}.card-item-skills{color:var(--muted);font-size:0.7rem}
|
|
1235
|
+
.severity-bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:var(--border);margin:0.75rem 0}.bar-seg{height:100%}.bar-critical{background:var(--c-critical)}.bar-high{background:var(--c-high)}.bar-medium{background:var(--c-medium)}.bar-low{background:var(--c-low)}.bar-info{background:var(--c-info)}
|
|
1236
|
+
.legend{display:flex;flex-wrap:wrap;gap:0.75rem;font-size:0.8rem;color:var(--muted);margin-bottom:1rem}.legend-item{display:flex;align-items:center;gap:0.3rem}.dot{width:10px;height:10px;border-radius:50%;display:inline-block}
|
|
1237
|
+
.finding-card{background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:0.5rem;overflow:hidden}summary{padding:0.75rem 1rem;cursor:pointer;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}summary:hover{background:rgba(0,0,0,0.02)}
|
|
1238
|
+
.badge{font-size:0.7rem;padding:0.15rem 0.5rem;border-radius:4px;color:#fff;font-weight:600;text-transform:uppercase}.badge-critical{background:var(--c-critical)}.badge-high{background:var(--c-high)}.badge-medium{background:var(--c-medium)}.badge-low{background:var(--c-low)}.badge-info{background:var(--c-info)}
|
|
1239
|
+
.finding-title{font-weight:600;flex:1}.tag{font-size:0.7rem;padding:0.1rem 0.4rem;border-radius:3px;background:var(--border);color:var(--muted)}.tag-ignored{background:#fef3c7;color:#92400e}
|
|
1240
|
+
.finding-body{padding:0.75rem 1rem;border-top:1px solid var(--border);font-size:0.9rem}.finding-body p{margin:0.5rem 0}.finding-body ul{margin:0.5rem 0 0.5rem 1.5rem}.finding-id{color:var(--muted);margin-top:0.5rem}
|
|
1241
|
+
.fix-versions{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:0.75rem}.fix-ver h4{font-size:0.8rem;color:var(--muted);margin-bottom:0.5rem;text-transform:uppercase}
|
|
1242
|
+
.dupe-table{width:100%;border-collapse:collapse;font-size:0.85rem;margin-top:0.5rem}th,td{padding:0.5rem 0.75rem;border:1px solid var(--border);text-align:left}th{background:var(--card);font-weight:600}
|
|
1243
|
+
.guide-card{background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:0.5rem}summary{font-weight:600}.guide-body{padding:0.75rem 1rem;border-top:1px solid var(--border);font-size:0.9rem}
|
|
1244
|
+
.steps{background:var(--bg);padding:0.75rem;border-radius:6px;white-space:pre-wrap;font-size:0.85rem;margin:0.5rem 0}
|
|
1245
|
+
.prompt-block{position:relative;margin-top:0.5rem}.prompt{background:#1e293b;color:#e2e8f0;padding:0.75rem;border-radius:6px;font-size:0.8rem;white-space:pre-wrap;overflow-x:auto}
|
|
1246
|
+
.copy-btn{position:absolute;top:0.5rem;right:0.5rem;padding:0.25rem 0.6rem;font-size:0.75rem;border:none;border-radius:4px;background:rgba(255,255,255,0.15);color:#e2e8f0;cursor:pointer}.copy-btn:hover{background:rgba(255,255,255,0.25)}
|
|
1247
|
+
.info-card{background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:1rem}summary{font-weight:600}.info-body{padding:0.75rem 1rem;border-top:1px solid var(--border);font-size:0.9rem}.info-body p{margin:0.5rem 0;padding-left:1rem;border-left:3px solid var(--border)}
|
|
1248
|
+
.guide-section{margin:0.75rem 0}.guide-section h4{font-size:0.85rem;color:var(--muted);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.5px}
|
|
1249
|
+
.cause{background:var(--bg);padding:0.75rem;border-radius:6px;white-space:pre-wrap;font-size:0.85rem;margin:0.5rem 0;border-left:3px solid var(--c-medium)}
|
|
1250
|
+
.example{background:var(--bg);padding:0.75rem;border-radius:6px;white-space:pre-wrap;font-size:0.85rem;margin:0.5rem 0;border-left:3px solid var(--c-low)}
|
|
1251
|
+
.tip-wrap{position:relative;display:inline-flex;align-items:center}.tip-icon{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;background:var(--border);color:var(--muted);font-size:0.65rem;font-weight:700;cursor:help;margin-left:0.25rem}
|
|
1252
|
+
.tip-box{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:0.75rem;font-size:0.8rem;width:320px;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,0.12);line-height:1.5;white-space:normal}
|
|
1253
|
+
.tip-wrap:hover .tip-box{display:block}
|
|
1254
|
+
.scan-class-legend{display:flex;flex-wrap:wrap;gap:0.75rem;font-size:0.8rem;color:var(--muted);margin-bottom:0.75rem}
|
|
1255
|
+
.root-card{background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:0.5rem;overflow:hidden}.root-card summary{padding:0.6rem 1rem;cursor:pointer;font-size:0.85rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}.root-card summary:hover{background:var(--bg)}.root-card .root-path{font-family:monospace;font-size:0.8rem;font-weight:600;word-break:break-all}.root-card .tag{font-size:0.65rem}.root-body{padding:0 1rem 0.75rem;border-top:1px solid var(--border)}.root-empty{padding:0.6rem 1rem;font-size:0.8rem;color:var(--muted);font-style:italic}
|
|
1256
|
+
.skill-table{width:100%;border-collapse:collapse;font-size:0.8rem;margin-top:0.5rem}th,td{padding:0.4rem 0.6rem;border:1px solid var(--border);text-align:left}th{background:var(--bg);font-weight:600;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.3px}.skill-table code{font-size:0.75rem;word-break:break-all}.skill-table .skill-name{font-weight:600}
|
|
1257
|
+
footer{text-align:center;color:var(--muted);font-size:0.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid var(--border)}
|
|
1258
|
+
</style>
|
|
1259
|
+
</head>
|
|
1260
|
+
<body>
|
|
1261
|
+
<header>
|
|
1262
|
+
<h1>${D('report.title')}</h1>
|
|
1263
|
+
<button class="lang-btn" onclick="toggleLang()"><span data-lang="en">中文</span><span data-lang="zh">EN</span></button>
|
|
1264
|
+
</header>
|
|
1265
|
+
|
|
1266
|
+
<h2>${D('html.dashboard')}</h2>
|
|
1267
|
+
<div class="dashboard">${summaryCards}</div>
|
|
1268
|
+
<div class="severity-bar">${severityBar}</div>
|
|
1269
|
+
<div class="legend">${severityLegend}</div>
|
|
1270
|
+
|
|
1271
|
+
<h2>${D('html.scanOverview')}</h2>
|
|
1272
|
+
${scanOverviewHtml}
|
|
1273
|
+
|
|
1274
|
+
<details class="info-card">
|
|
1275
|
+
<summary>${D('severity.explanation.title')}</summary>
|
|
1276
|
+
<div class="info-body">
|
|
1277
|
+
<p>${D('severity.explanation.critical')}</p>
|
|
1278
|
+
<p>${D('severity.explanation.high')}</p>
|
|
1279
|
+
<p>${D('severity.explanation.medium')}</p>
|
|
1280
|
+
<p>${D('severity.explanation.low')}</p>
|
|
1281
|
+
<p>${D('severity.explanation.info')}</p>
|
|
1282
|
+
</div>
|
|
1283
|
+
</details>
|
|
1284
|
+
|
|
1285
|
+
<h2>${D('html.remediationGuide')}</h2>
|
|
1286
|
+
${remediationPathHtml}
|
|
1287
|
+
|
|
1288
|
+
<h3 style="font-size:1rem;margin:1.5rem 0 0.5rem;color:var(--muted)">${D('fix.perFinding')}</h3>
|
|
1289
|
+
${perFindingHtml}
|
|
1290
|
+
|
|
1291
|
+
<footer>${D('html.generatedAt')}: ${new Date().toISOString()}</footer>
|
|
1292
|
+
|
|
1293
|
+
<script>
|
|
1294
|
+
function setLang(lang){
|
|
1295
|
+
document.documentElement.lang=lang;
|
|
1296
|
+
document.querySelectorAll('[data-lang]').forEach(function(e){
|
|
1297
|
+
if(e.dataset.lang===lang){
|
|
1298
|
+
e.style.removeProperty('display');
|
|
1299
|
+
}else{
|
|
1300
|
+
e.style.display='none';
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
localStorage.setItem('asd-lang',lang);
|
|
1304
|
+
}
|
|
1305
|
+
function toggleLang(){
|
|
1306
|
+
var cur=document.documentElement.lang;
|
|
1307
|
+
setLang(cur==='en'?'zh':'en');
|
|
1308
|
+
}
|
|
1309
|
+
function copyPrompt(btn){
|
|
1310
|
+
var lang=document.documentElement.lang;
|
|
1311
|
+
var pre=btn.previousElementSibling;
|
|
1312
|
+
var text=pre.querySelector('[data-lang="'+lang+'"]')||pre;
|
|
1313
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
1314
|
+
navigator.clipboard.writeText(text.textContent).then(function(){
|
|
1315
|
+
var orig=btn.innerHTML;
|
|
1316
|
+
btn.innerHTML=lang==='en'?'Copied!':'已复制!';
|
|
1317
|
+
setTimeout(function(){btn.innerHTML=orig},2000);
|
|
1318
|
+
});
|
|
1319
|
+
} else {
|
|
1320
|
+
var ta=document.createElement('textarea');ta.value=text.textContent;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);
|
|
1321
|
+
var orig=btn.innerHTML;
|
|
1322
|
+
btn.innerHTML=lang==='en'?'Copied!':'已复制!';
|
|
1323
|
+
setTimeout(function(){btn.innerHTML=orig},2000);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
(function(){
|
|
1327
|
+
var initial='${(lang === 'zh' ? 'zh' : 'en')}';
|
|
1328
|
+
var saved=localStorage.getItem('asd-lang');
|
|
1329
|
+
var active=saved||initial;
|
|
1330
|
+
setLang(active);
|
|
1331
|
+
})();
|
|
1332
|
+
</script>
|
|
1333
|
+
</body>
|
|
1334
|
+
</html>`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function writeReport(db, { format, output, includeIgnored, reportsDir, lang }) {
|
|
1338
|
+
const data = buildReportData(db, includeIgnored);
|
|
1339
|
+
const ext = format === 'json' ? 'json' : format === 'html' ? 'html' : 'md';
|
|
1340
|
+
const outPath = output || path.join(reportsDir, `skill-doctor-report-${Date.now()}.${ext}`);
|
|
1341
|
+
let content;
|
|
1342
|
+
if (format === 'json') {
|
|
1343
|
+
content = JSON.stringify(data, null, 2);
|
|
1344
|
+
} else if (format === 'html') {
|
|
1345
|
+
content = renderHtml(data, lang, outPath);
|
|
1346
|
+
} else {
|
|
1347
|
+
content = renderMarkdown(data, lang);
|
|
1348
|
+
}
|
|
1349
|
+
ensureDir(path.dirname(outPath));
|
|
1350
|
+
fs.writeFileSync(outPath, content, 'utf8');
|
|
1351
|
+
const reportId = sha256(`${outPath}:${nowIso()}`);
|
|
1352
|
+
db.prepare('INSERT OR IGNORE INTO reports (id, format, path, created_at, summary_json) VALUES (?, ?, ?, ?, ?)')
|
|
1353
|
+
.run(reportId, format, outPath, nowIso(), JSON.stringify(data.summary));
|
|
1354
|
+
return { path: outPath, data };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function usage() {
|
|
1358
|
+
console.log(`agent-skill-doctor CLI
|
|
1359
|
+
|
|
1360
|
+
Commands:
|
|
1361
|
+
scan [--full] [--rebuild-index] [--root <path>] [--json] [--lang en|zh]
|
|
1362
|
+
diagnose [--json] [--ci] [--fail-on high|critical|medium] [--rules <dir>] [--include-ignored] [--lang en|zh]
|
|
1363
|
+
report [--format md|json|html] [--output <path>] [--include-ignored] [--lang en|zh]
|
|
1364
|
+
guide [--lang en|zh]
|
|
1365
|
+
fix [--type <type>] [--severity <level>] [--lang en|zh]
|
|
1366
|
+
duplicates [--json]
|
|
1367
|
+
risks [--json] [--ci] [--fail-on high|critical|medium]
|
|
1368
|
+
conflicts [--json] [--ci] [--fail-on high|critical|medium]
|
|
1369
|
+
zombies [--json] [--ci] [--fail-on high|critical|medium]
|
|
1370
|
+
plan [--safe|--normal|--aggressive] [--json] [--output <path>]
|
|
1371
|
+
apply <plan.json> --dry-run [--json]
|
|
1372
|
+
ignore <finding-id> [--reason <text>]
|
|
1373
|
+
unignore <finding-id>
|
|
1374
|
+
ignored list
|
|
1375
|
+
help
|
|
1376
|
+
|
|
1377
|
+
Fix types: risk, zombie, duplicate, conflict, version_drift, description_quality, scan_warning
|
|
1378
|
+
Fix severity: critical, high, medium, low, info
|
|
1379
|
+
`);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function open() { const config = loadConfig(); return { config, db: openDb(config.dbPath) }; }
|
|
1383
|
+
|
|
1384
|
+
function severityRank(severity) {
|
|
1385
|
+
return { info: 0, low: 1, medium: 2, high: 3, critical: 4 }[severity] || 0;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function exitCodeForFindings(findings, failOn, includeIgnored) {
|
|
1389
|
+
const threshold = severityRank(failOn || 'high');
|
|
1390
|
+
let maxSeverity = 0;
|
|
1391
|
+
for (const f of findings) {
|
|
1392
|
+
if (f.ignored && !includeIgnored) continue;
|
|
1393
|
+
const rank = severityRank(f.severity);
|
|
1394
|
+
if (rank >= threshold) maxSeverity = Math.max(maxSeverity, rank);
|
|
1395
|
+
}
|
|
1396
|
+
if (maxSeverity >= 4) return 2; // critical
|
|
1397
|
+
if (maxSeverity >= threshold) return 1; // at or above threshold
|
|
1398
|
+
return 0;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function runScan(args) {
|
|
1402
|
+
const { config, db } = open();
|
|
1403
|
+
const startedAt = nowIso();
|
|
1404
|
+
const roots = args.root ? [expandHome(args.root)] : config.roots;
|
|
1405
|
+
if (args['rebuild-index']) {
|
|
1406
|
+
db.exec('DELETE FROM duplicate_group_members; DELETE FROM duplicate_groups; DELETE FROM finding_skills; DELETE FROM findings; DELETE FROM skill_records;');
|
|
1407
|
+
}
|
|
1408
|
+
const skills = scanRoots(roots, { maxDepth: config.scan.maxDepth, full: !!args.full });
|
|
1409
|
+
let findingCount = 0, highCount = 0, criticalCount = 0;
|
|
1410
|
+
for (const skill of skills) {
|
|
1411
|
+
upsertSkill(db, skill);
|
|
1412
|
+
const { findings, links } = detectPhase1Findings(skill);
|
|
1413
|
+
for (const finding of findings) {
|
|
1414
|
+
upsertFinding(db, finding, links.filter(l => l.findingId === finding.id));
|
|
1415
|
+
findingCount++;
|
|
1416
|
+
if (finding.severity === 'high') highCount++;
|
|
1417
|
+
if (finding.severity === 'critical') criticalCount++;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const summary = { roots, skills: skills.length, phase1Findings: findingCount, dbPath: config.dbPath };
|
|
1421
|
+
recordRun(db, { id: sha256(`${startedAt}:${Math.random()}`), startedAt, finishedAt: nowIso(), status: 'ok', skillCount: skills.length, findingCount, highCount, criticalCount, config: { roots }, summary });
|
|
1422
|
+
if (args.silent) return; // Called from runDiagnose, don't print
|
|
1423
|
+
if (args.json) console.log(JSON.stringify({ summary, skills: skills.map(s => ({ id: s.id, slug: s.slug, name: s.name, path: s.location.path, contentHash: s.hashes.contentSha256 })) }, null, 2));
|
|
1424
|
+
else console.log(t('cli.scanned', args.lang || 'en', skills.length, findingCount, config.dbPath));
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function rowToSkill(row) {
|
|
1428
|
+
const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
|
|
1429
|
+
return {
|
|
1430
|
+
...raw,
|
|
1431
|
+
id: row.id,
|
|
1432
|
+
upstreamSkillId: row.upstream_skill_id,
|
|
1433
|
+
name: row.name,
|
|
1434
|
+
slug: row.slug,
|
|
1435
|
+
description: row.description,
|
|
1436
|
+
source: raw.source || { type: row.source_type, url: row.source_url, ref: row.source_ref, commit: row.source_commit },
|
|
1437
|
+
location: raw.location || { path: row.local_path, root: path.dirname(row.local_path), rootType: row.root_type, agent: row.agent },
|
|
1438
|
+
hashes: raw.hashes || { contentSha256: row.content_hash, normalizedTextSha256: row.normalized_hash },
|
|
1439
|
+
files: raw.files || [],
|
|
1440
|
+
tags: raw.tags || [],
|
|
1441
|
+
usage: raw.usage || { installedInAgents: [], installedInProjects: [], presetCount: 0, hasRecentModification: false, manuallyPinned: false },
|
|
1442
|
+
frontmatter: raw.frontmatter || {},
|
|
1443
|
+
_scan: raw._scan || {},
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function listSkillObjects(db) {
|
|
1448
|
+
return db.prepare('SELECT * FROM skill_records ORDER BY slug ASC').all().map(rowToSkill);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function runDiagnose(args) {
|
|
1452
|
+
runScan({ ...args, json: false, silent: true });
|
|
1453
|
+
const { config, db } = open();
|
|
1454
|
+
const skills = listSkillObjects(db);
|
|
1455
|
+
|
|
1456
|
+
// Risk scan
|
|
1457
|
+
const rulesDir = args.rules ? path.resolve(expandHome(args.rules)) : path.join(process.cwd(), 'rules/default');
|
|
1458
|
+
let riskFindings = [];
|
|
1459
|
+
if (fs.existsSync(rulesDir)) {
|
|
1460
|
+
const rules = loadJsonRules(rulesDir);
|
|
1461
|
+
for (const skill of skills) {
|
|
1462
|
+
const found = scanSkillForRisks(skill, rules);
|
|
1463
|
+
for (const f of found) upsertFinding(db, f, [{ findingId: f.id, skillId: skill.id, role: 'primary' }]);
|
|
1464
|
+
riskFindings.push(...found);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Conflict detection
|
|
1469
|
+
const conflictFindings = detectConflicts(skills, DEFAULT_CONFLICT_RULES);
|
|
1470
|
+
for (const f of conflictFindings) upsertFinding(db, f, f.links || []);
|
|
1471
|
+
|
|
1472
|
+
// Zombie detection
|
|
1473
|
+
const zombieFindings = detectZombies(skills);
|
|
1474
|
+
for (const f of zombieFindings) upsertFinding(db, f, f.links || []);
|
|
1475
|
+
|
|
1476
|
+
// Duplicate and version drift detection
|
|
1477
|
+
const phase2Result = runPhase2Analysis(db, skills);
|
|
1478
|
+
|
|
1479
|
+
const data = buildReportData(db, !!args['include-ignored']);
|
|
1480
|
+
const summary = {
|
|
1481
|
+
...data.summary,
|
|
1482
|
+
riskFindings: riskFindings.length,
|
|
1483
|
+
conflictFindings: conflictFindings.length,
|
|
1484
|
+
zombieCandidates: zombieFindings.length,
|
|
1485
|
+
duplicateGroups: phase2Result.groups.length,
|
|
1486
|
+
versionDriftFindings: phase2Result.drifts.length,
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
if (args.json) console.log(JSON.stringify({ ...data, summary }, null, 2));
|
|
1490
|
+
else {
|
|
1491
|
+
const lang = args.lang || 'en';
|
|
1492
|
+
console.log(t('cli.skills', lang, summary.totalSkills));
|
|
1493
|
+
console.log(t('cli.findings', lang, summary.totalFindings));
|
|
1494
|
+
console.log(t('cli.riskFindings', lang, summary.riskFindings));
|
|
1495
|
+
console.log(t('cli.conflictFindings', lang, summary.conflictFindings));
|
|
1496
|
+
console.log(t('cli.zombieCandidates', lang, summary.zombieCandidates));
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (args.ci) {
|
|
1500
|
+
const rows = listFindings(db, !!args['include-ignored']);
|
|
1501
|
+
process.exit(exitCodeForFindings(rows, args['fail-on'] || 'high', !!args['include-ignored']));
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function runReport(args) {
|
|
1506
|
+
const { config, db } = open();
|
|
1507
|
+
const result = writeReport(db, { format: args.format || 'md', output: args.output ? path.resolve(expandHome(args.output)) : null, includeIgnored: !!args['include-ignored'], reportsDir: config.reportsDir, lang: args.lang || 'en' });
|
|
1508
|
+
console.log(t('cli.reportWritten', args.lang || 'en', result.path));
|
|
1509
|
+
if (args.ci) {
|
|
1510
|
+
const rows = listFindings(db, !!args['include-ignored']);
|
|
1511
|
+
process.exit(exitCodeForFindings(rows, args['fail-on'] || 'high', !!args['include-ignored']));
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function outputJsonOrText(args, jsonValue, textLines) {
|
|
1516
|
+
if (args.json) console.log(JSON.stringify(jsonValue, null, 2));
|
|
1517
|
+
else console.log(textLines.join('\n'));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function runDuplicates(args) {
|
|
1521
|
+
const { db } = open();
|
|
1522
|
+
const groups = listDuplicateGroups(db).map(group => ({
|
|
1523
|
+
...group,
|
|
1524
|
+
members: db.prepare('SELECT dgm.*, sr.slug, sr.name, sr.local_path FROM duplicate_group_members dgm JOIN skill_records sr ON sr.id = dgm.skill_id WHERE dgm.group_id = ? ORDER BY role ASC, sr.slug ASC').all(group.id),
|
|
1525
|
+
}));
|
|
1526
|
+
if (!groups.length) return outputJsonOrText(args, [], [t('cli.noDuplicateGroups', args.lang || 'en')]);
|
|
1527
|
+
outputJsonOrText(args, groups, groups.flatMap(group => [
|
|
1528
|
+
`${group.id} ${group.strategy} confidence=${group.confidence}`,
|
|
1529
|
+
...group.members.map(member => ` - ${member.role}: ${member.slug} ${member.local_path}`),
|
|
1530
|
+
]));
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function runFindingsByType(args, type) {
|
|
1534
|
+
const { db } = open();
|
|
1535
|
+
const rows = db.prepare('SELECT * FROM findings WHERE type = ? AND ignored = 0 ORDER BY CASE severity WHEN \'critical\' THEN 0 WHEN \'high\' THEN 1 WHEN \'medium\' THEN 2 WHEN \'low\' THEN 3 WHEN \'info\' THEN 4 ELSE 5 END, updated_at DESC').all(type);
|
|
1536
|
+
const findings = rows.map(row => ({
|
|
1537
|
+
id: row.id,
|
|
1538
|
+
type: row.type,
|
|
1539
|
+
severity: row.severity,
|
|
1540
|
+
ruleId: row.rule_id,
|
|
1541
|
+
title: row.title,
|
|
1542
|
+
description: row.description,
|
|
1543
|
+
evidence: JSON.parse(row.evidence_json || '[]'),
|
|
1544
|
+
skills: getFindingSkills(db, row.id),
|
|
1545
|
+
}));
|
|
1546
|
+
outputJsonOrText(
|
|
1547
|
+
args,
|
|
1548
|
+
findings,
|
|
1549
|
+
findings.length ? findings.map(f => `${f.id} ${f.severity} ${f.ruleId || ''} ${f.title}`) : [t('cli.noFindings', args.lang || 'en', type)]
|
|
1550
|
+
);
|
|
1551
|
+
if (args.ci) process.exit(exitCodeForFindings(rows, args['fail-on'] || 'high', false));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function expectedStateForSkill(row) {
|
|
1555
|
+
return {
|
|
1556
|
+
upstreamSkillId: row.upstream_skill_id || null,
|
|
1557
|
+
localPath: row.local_path,
|
|
1558
|
+
contentHash: row.content_hash,
|
|
1559
|
+
normalizedHash: row.normalized_hash,
|
|
1560
|
+
sourceCommit: row.source_commit || null,
|
|
1561
|
+
modifiedAt: row.modified_at || null,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function buildOptimizationPlan(db, mode) {
|
|
1566
|
+
const at = nowIso();
|
|
1567
|
+
const actions = [];
|
|
1568
|
+
const duplicateMembers = db.prepare(`
|
|
1569
|
+
SELECT dgm.*, dg.strategy, dg.canonical_skill_id, sr.upstream_skill_id, sr.slug, sr.name, sr.local_path, sr.content_hash, sr.normalized_hash, sr.source_commit, sr.modified_at
|
|
1570
|
+
FROM duplicate_group_members dgm
|
|
1571
|
+
JOIN duplicate_groups dg ON dg.id = dgm.group_id
|
|
1572
|
+
JOIN skill_records sr ON sr.id = dgm.skill_id
|
|
1573
|
+
WHERE dgm.role = 'candidate'
|
|
1574
|
+
ORDER BY dg.confidence DESC, sr.slug ASC
|
|
1575
|
+
`).all();
|
|
1576
|
+
|
|
1577
|
+
for (const member of duplicateMembers) {
|
|
1578
|
+
const actionType = mode === 'aggressive' ? 'remove_from_preset' : mode === 'normal' ? 'disable' : 'tag';
|
|
1579
|
+
actions.push({
|
|
1580
|
+
id: sha256(`${member.group_id}:${member.skill_id}:${actionType}`),
|
|
1581
|
+
type: actionType,
|
|
1582
|
+
targetSkillId: member.skill_id,
|
|
1583
|
+
reason: `Duplicate candidate in ${member.strategy}; canonical skill is ${member.canonical_skill_id}.`,
|
|
1584
|
+
risk: actionType === 'tag' ? 'safe' : 'needs_review',
|
|
1585
|
+
expectedState: expectedStateForSkill(member),
|
|
1586
|
+
dryRunCommand: `agent-skill-doctor apply plan.json --dry-run --target ${member.skill_id}`,
|
|
1587
|
+
executeCommand: null,
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
return {
|
|
1592
|
+
id: sha256(`plan:${at}:${actions.map(a => a.id).join('\n')}`),
|
|
1593
|
+
createdAt: at,
|
|
1594
|
+
mode,
|
|
1595
|
+
summary: `Generated ${actions.length} safe governance action(s).`,
|
|
1596
|
+
actions,
|
|
1597
|
+
estimatedImpact: {
|
|
1598
|
+
skillsToDisable: actions.filter(a => a.type === 'disable').length,
|
|
1599
|
+
skillsToRemove: 0,
|
|
1600
|
+
duplicatesToMerge: actions.length,
|
|
1601
|
+
riskySkills: db.prepare("SELECT COUNT(*) AS count FROM findings WHERE type = 'risk' AND ignored = 0").get().count,
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function runPlan(args) {
|
|
1607
|
+
const { config, db } = open();
|
|
1608
|
+
const mode = args.aggressive ? 'aggressive' : args.normal ? 'normal' : 'safe';
|
|
1609
|
+
const plan = buildOptimizationPlan(db, mode);
|
|
1610
|
+
const outPath = args.output ? path.resolve(expandHome(args.output)) : null;
|
|
1611
|
+
if (outPath) {
|
|
1612
|
+
ensureDir(path.dirname(outPath));
|
|
1613
|
+
fs.writeFileSync(outPath, JSON.stringify(plan, null, 2), 'utf8');
|
|
1614
|
+
}
|
|
1615
|
+
if (args.json) console.log(JSON.stringify(plan, null, 2));
|
|
1616
|
+
else {
|
|
1617
|
+
if (outPath) console.log(`Plan written: ${outPath}`);
|
|
1618
|
+
console.log(plan.summary);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function currentStateForPath(localPath) {
|
|
1623
|
+
if (!localPath || !fs.existsSync(localPath) || !fs.statSync(localPath).isDirectory()) {
|
|
1624
|
+
return { exists: false, contentHash: null, normalizedHash: null, modifiedAt: null };
|
|
1625
|
+
}
|
|
1626
|
+
const hashes = computeHashes(localPath);
|
|
1627
|
+
const stat = fs.statSync(localPath);
|
|
1628
|
+
return {
|
|
1629
|
+
exists: true,
|
|
1630
|
+
contentHash: hashes.contentSha256,
|
|
1631
|
+
normalizedHash: hashes.normalizedTextSha256,
|
|
1632
|
+
modifiedAt: stat.mtime?.toISOString?.() || null,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function actionIsStale(action) {
|
|
1637
|
+
const expected = action.expectedState || {};
|
|
1638
|
+
const current = currentStateForPath(expected.localPath);
|
|
1639
|
+
if (!current.exists) return true;
|
|
1640
|
+
return Boolean(
|
|
1641
|
+
expected.contentHash && current.contentHash !== expected.contentHash
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function runApply(args) {
|
|
1646
|
+
const lang = args.lang || 'en';
|
|
1647
|
+
const planPath = args._[1] ? path.resolve(expandHome(args._[1])) : null;
|
|
1648
|
+
if (!planPath) { console.error(t('cli.applyRequiresPlan', lang)); process.exit(3); }
|
|
1649
|
+
if (!args['dry-run']) { console.error(t('cli.dryRunOnly', lang)); process.exit(3); }
|
|
1650
|
+
const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'));
|
|
1651
|
+
const actions = (plan.actions || []).map(action => ({
|
|
1652
|
+
id: action.id,
|
|
1653
|
+
type: action.type,
|
|
1654
|
+
targetSkillId: action.targetSkillId,
|
|
1655
|
+
status: actionIsStale(action) ? 'stale_action' : 'dry_run',
|
|
1656
|
+
reason: action.reason,
|
|
1657
|
+
}));
|
|
1658
|
+
const result = { planId: plan.id, dryRun: true, actions };
|
|
1659
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
1660
|
+
else {
|
|
1661
|
+
for (const action of actions) console.log(`${action.status} ${action.type} ${action.targetSkillId}`);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function runIgnore(args, ignored) {
|
|
1666
|
+
const lang = args.lang || 'en';
|
|
1667
|
+
const findingId = args._[1];
|
|
1668
|
+
if (!findingId) { console.error(t('cli.ignoreRequiresId', lang, ignored ? 'ignore' : 'unignore')); process.exit(3); }
|
|
1669
|
+
const { db } = open();
|
|
1670
|
+
const changes = setIgnored(db, findingId, ignored, args.reason || null, nowIso());
|
|
1671
|
+
if (!changes) { console.error(t('cli.findingNotFound', lang, findingId)); process.exit(3); }
|
|
1672
|
+
console.log(t(ignored ? 'cli.ignoredFinding' : 'cli.unignoredFinding', lang, ignored ? 'Ignored' : 'Unignored', findingId));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function runIgnored(args) {
|
|
1676
|
+
const lang = args.lang || 'en';
|
|
1677
|
+
if (args._[1] !== 'list') { console.error(t('cli.ignoredListUsage', lang)); process.exit(3); }
|
|
1678
|
+
const { db } = open();
|
|
1679
|
+
const rows = listIgnored(db);
|
|
1680
|
+
if (args.json) return console.log(JSON.stringify(rows, null, 2));
|
|
1681
|
+
if (!rows.length) return console.log(t('cli.noIgnored', args.lang || 'en'));
|
|
1682
|
+
for (const r of rows) console.log(`${r.id} ${r.severity} ${r.type} ${r.title} reason=${r.ignored_reason || ''}`);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function runGuide(args) {
|
|
1686
|
+
const lang = args.lang || 'en';
|
|
1687
|
+
const types = ['risk', 'zombie', 'duplicate', 'conflict', 'version_drift', 'description_quality', 'scan_warning'];
|
|
1688
|
+
const lines = [];
|
|
1689
|
+
for (const type of types) {
|
|
1690
|
+
lines.push(`=== ${t(`guide.${type}.title`, lang)} ===`);
|
|
1691
|
+
lines.push('');
|
|
1692
|
+
lines.push(t(`guide.${type}.definition`, lang));
|
|
1693
|
+
lines.push('');
|
|
1694
|
+
lines.push(t(`guide.${type}.cause`, lang));
|
|
1695
|
+
lines.push('');
|
|
1696
|
+
lines.push(t(`guide.${type}.severity`, lang));
|
|
1697
|
+
lines.push('');
|
|
1698
|
+
lines.push(t(`guide.${type}.steps`, lang));
|
|
1699
|
+
lines.push('');
|
|
1700
|
+
lines.push(`${lang === 'zh' ? '示例提示词' : 'Example prompt'}:`);
|
|
1701
|
+
lines.push(` ${t(`guide.${type}.prompt`, lang, '<skill-path>')}`);
|
|
1702
|
+
lines.push('');
|
|
1703
|
+
lines.push(`${lang === 'zh' ? 'Agent 交互示例' : 'Agent Interaction Example'}:`);
|
|
1704
|
+
lines.push(t(`guide.${type}.agentExample`, lang));
|
|
1705
|
+
lines.push('');
|
|
1706
|
+
}
|
|
1707
|
+
console.log(lines.join('\n'));
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function runFix(args) {
|
|
1711
|
+
const { db } = open();
|
|
1712
|
+
const lang = args.lang || 'en';
|
|
1713
|
+
const typeFilter = args.type || null;
|
|
1714
|
+
const severityFilter = args.severity || null;
|
|
1715
|
+
|
|
1716
|
+
// Validate severity
|
|
1717
|
+
const validSeverities = ['critical', 'high', 'medium', 'low', 'info'];
|
|
1718
|
+
if (severityFilter && !validSeverities.includes(severityFilter)) {
|
|
1719
|
+
console.error(`${lang === 'zh' ? '无效的严重程度' : 'Invalid severity'}: ${severityFilter}. ${lang === 'zh' ? '可选值' : 'Valid values'}: ${validSeverities.join(', ')}`);
|
|
1720
|
+
process.exit(3);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Validate type
|
|
1724
|
+
const validTypes = ['risk', 'zombie', 'duplicate', 'conflict', 'version_drift', 'description_quality', 'scan_warning'];
|
|
1725
|
+
if (typeFilter && !validTypes.includes(typeFilter)) {
|
|
1726
|
+
console.error(`${lang === 'zh' ? '无效的问题类型' : 'Invalid type'}: ${typeFilter}. ${lang === 'zh' ? '可选值' : 'Valid values'}: ${validTypes.join(', ')}`);
|
|
1727
|
+
process.exit(3);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Get findings from database
|
|
1731
|
+
let rows = listFindings(db, !!args['include-ignored']);
|
|
1732
|
+
|
|
1733
|
+
// Apply filters
|
|
1734
|
+
if (typeFilter) {
|
|
1735
|
+
rows = rows.filter(r => r.type === typeFilter);
|
|
1736
|
+
}
|
|
1737
|
+
if (severityFilter) {
|
|
1738
|
+
const minRank = severityRank(severityFilter);
|
|
1739
|
+
rows = rows.filter(r => severityRank(r.severity) >= minRank);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (rows.length === 0) {
|
|
1743
|
+
console.log(t('fix.noFindings', lang));
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Group findings by type
|
|
1748
|
+
const byType = {};
|
|
1749
|
+
for (const row of rows) {
|
|
1750
|
+
if (!byType[row.type]) byType[row.type] = [];
|
|
1751
|
+
byType[row.type].push(row);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const lines = [];
|
|
1755
|
+
lines.push(`=== ${t('fix.title', lang)} ===`);
|
|
1756
|
+
lines.push('');
|
|
1757
|
+
|
|
1758
|
+
// Generate prompts for each type
|
|
1759
|
+
for (const [type, findings] of Object.entries(byType)) {
|
|
1760
|
+
const skillMap = {};
|
|
1761
|
+
for (const f of findings) {
|
|
1762
|
+
const fs = getFindingSkills(db, f.id);
|
|
1763
|
+
for (const s of fs) {
|
|
1764
|
+
if (!skillMap[s.slug]) skillMap[s.slug] = { path: s.local_path, findings: [] };
|
|
1765
|
+
skillMap[s.slug].findings.push(f);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
lines.push(`--- ${t(`guide.${type}.title`, lang)} (${findings.length}) ---`);
|
|
1770
|
+
lines.push('');
|
|
1771
|
+
lines.push(t(`guide.${type}.definition`, lang));
|
|
1772
|
+
lines.push('');
|
|
1773
|
+
|
|
1774
|
+
// List each skill with its specific findings
|
|
1775
|
+
for (const [slug, info] of Object.entries(skillMap)) {
|
|
1776
|
+
lines.push(`${lang === 'zh' ? '技能' : 'Skill'}: ${slug}`);
|
|
1777
|
+
lines.push(`${lang === 'zh' ? '路径' : 'Path'}: ${info.path}`);
|
|
1778
|
+
lines.push(`${lang === 'zh' ? '问题' : 'Issues'}:`);
|
|
1779
|
+
for (const f of info.findings) {
|
|
1780
|
+
lines.push(` - [${f.severity.toUpperCase()}] ${f.title}`);
|
|
1781
|
+
if (f.description) lines.push(` ${f.description.substring(0, 100)}...`);
|
|
1782
|
+
lines.push(` ID: ${f.id}`);
|
|
1783
|
+
}
|
|
1784
|
+
lines.push('');
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
lines.push(`${lang === 'zh' ? '修复步骤' : 'Fix Steps'}:`);
|
|
1788
|
+
lines.push(t(`guide.${type}.steps`, lang));
|
|
1789
|
+
lines.push('');
|
|
1790
|
+
|
|
1791
|
+
// Generate targeted prompt
|
|
1792
|
+
const allSkills = Object.keys(skillMap);
|
|
1793
|
+
lines.push(`${lang === 'zh' ? '复制以下提示词给 Agent' : 'Copy this prompt to Agent'}:`);
|
|
1794
|
+
lines.push('---');
|
|
1795
|
+
lines.push(`${lang === 'zh' ? '请修复以下技能的问题' : 'Please fix the following skills'}:`);
|
|
1796
|
+
for (const [slug, info] of Object.entries(skillMap)) {
|
|
1797
|
+
lines.push(`\n技能: ${slug} (${info.path})`);
|
|
1798
|
+
for (const f of info.findings) {
|
|
1799
|
+
lines.push(`- [${f.severity}] ${f.title}: ${f.recommendation || t(`guide.${type}.steps`, lang).split('\n')[0]}`);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
lines.push('---');
|
|
1803
|
+
lines.push('');
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
console.log(lines.join('\n'));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
function main() {
|
|
1810
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1811
|
+
const command = args._[0] || 'help';
|
|
1812
|
+
try {
|
|
1813
|
+
if (command === 'scan') return runScan(args);
|
|
1814
|
+
if (command === 'diagnose') return runDiagnose(args);
|
|
1815
|
+
if (command === 'report') return runReport(args);
|
|
1816
|
+
if (command === 'guide') return runGuide(args);
|
|
1817
|
+
if (command === 'fix') return runFix(args);
|
|
1818
|
+
if (command === 'duplicates') return runDuplicates(args);
|
|
1819
|
+
if (command === 'risks') return runFindingsByType(args, 'risk');
|
|
1820
|
+
if (command === 'conflicts') return runFindingsByType(args, 'conflict');
|
|
1821
|
+
if (command === 'zombies') return runFindingsByType(args, 'zombie');
|
|
1822
|
+
if (command === 'plan') return runPlan(args);
|
|
1823
|
+
if (command === 'apply') return runApply(args);
|
|
1824
|
+
if (command === 'ignore') return runIgnore(args, true);
|
|
1825
|
+
if (command === 'unignore') return runIgnore(args, false);
|
|
1826
|
+
if (command === 'ignored') return runIgnored(args);
|
|
1827
|
+
return usage();
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
console.error(err && err.stack ? err.stack : String(err));
|
|
1830
|
+
process.exit(3);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
main();
|