@nseng-ai/areg 0.1.1
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/package.json +29 -0
- package/src/cli.ts +109 -0
- package/src/context.ts +59 -0
- package/src/fake-gateways.ts +1009 -0
- package/src/gateways/command-constants.ts +1 -0
- package/src/gateways/errors.ts +5 -0
- package/src/gateways/fs-utils.ts +26 -0
- package/src/gateways/github-gateway.ts +111 -0
- package/src/gateways/host-gateway.ts +35 -0
- package/src/gateways/mutation-policy.ts +94 -0
- package/src/gateways/npx-skills-gateway.ts +53 -0
- package/src/gateways/project-fs.ts +320 -0
- package/src/gateways/project-gateway.ts +801 -0
- package/src/gateways/prompt-gateway.ts +21 -0
- package/src/gateways/skill-kind-classification.ts +39 -0
- package/src/gateways/skillx-workspace-gateway.ts +220 -0
- package/src/gateways.ts +361 -0
- package/src/index.ts +48 -0
- package/src/operations/check.ts +559 -0
- package/src/operations/doctor-skills-report.ts +109 -0
- package/src/operations/doctor-skills-severity.ts +9 -0
- package/src/operations/doctor-skills.ts +471 -0
- package/src/operations/file-state.ts +77 -0
- package/src/operations/frontmatter.ts +151 -0
- package/src/operations/init.ts +548 -0
- package/src/operations/lockfile.ts +107 -0
- package/src/operations/managed-markdown-block.ts +47 -0
- package/src/operations/pi-replacement.ts +33 -0
- package/src/operations/pi-settings.ts +60 -0
- package/src/operations/project-agents.ts +115 -0
- package/src/operations/project-inspection.ts +165 -0
- package/src/operations/project-mutations.ts +383 -0
- package/src/operations/project-resolution.ts +53 -0
- package/src/operations/skill-find.ts +295 -0
- package/src/operations/skill-kind-apply-plan.ts +531 -0
- package/src/operations/skill-kind-frontmatter.ts +55 -0
- package/src/operations/skill-kind-inference.ts +391 -0
- package/src/operations/skill-kind.ts +525 -0
- package/src/operations/skill-mirror-conventions.ts +126 -0
- package/src/operations/skillx.ts +305 -0
- package/src/operations/toml-section.ts +53 -0
- package/src/operations/update-skills.ts +325 -0
- package/src/real-gateways.ts +6 -0
- package/src/skill-lookup.ts +254 -0
- package/src/sort.ts +7 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { failure, ok, negative, type ClinkrExit } from "@nseng-ai/clinkr";
|
|
2
|
+
import type { Result } from "@nseng-ai/foundation/result";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import type { AregCliContext } from "../context.ts";
|
|
6
|
+
import { missingCheckSkillInspection } from "../gateways.ts";
|
|
7
|
+
import type { AregCheckSkillInspection } from "../gateways.ts";
|
|
8
|
+
import { sortStrings } from "../sort.ts";
|
|
9
|
+
import { isPathStateError } from "./file-state.ts";
|
|
10
|
+
import { parseSkillFrontmatterBlock } from "./frontmatter.ts";
|
|
11
|
+
import {
|
|
12
|
+
parseInspectedLockfile,
|
|
13
|
+
parseLockfileData,
|
|
14
|
+
type LockfileSkill,
|
|
15
|
+
type SkillsLockfile,
|
|
16
|
+
} from "./lockfile.ts";
|
|
17
|
+
import { verifyPiReplacement } from "./pi-replacement.ts";
|
|
18
|
+
import { parsePiSettings } from "./pi-settings.ts";
|
|
19
|
+
import {
|
|
20
|
+
expectedAgentsSkillSymlinkTarget,
|
|
21
|
+
expectedClaudeSkillSymlinkTarget,
|
|
22
|
+
isAgentsSkillMirror,
|
|
23
|
+
isClaudeSkillMirror,
|
|
24
|
+
} from "./skill-mirror-conventions.ts";
|
|
25
|
+
import {
|
|
26
|
+
inferSkillKindRecord,
|
|
27
|
+
inspectSkillFrontmatter,
|
|
28
|
+
isManagedOpenaiPolicyContent,
|
|
29
|
+
isUnlistedCandidate,
|
|
30
|
+
type SkillKindRecord,
|
|
31
|
+
} from "./skill-kind-inference.ts";
|
|
32
|
+
import { inspectCheckProject, type AregCheckProjectInspection } from "./project-inspection.ts";
|
|
33
|
+
|
|
34
|
+
const CHECK_ISSUE_CODES = [
|
|
35
|
+
"invalid-lock-hash",
|
|
36
|
+
"missing-skills-dir",
|
|
37
|
+
"skills-dir-is-symlink",
|
|
38
|
+
"invalid-local-lock-source",
|
|
39
|
+
"agents-not-symlink",
|
|
40
|
+
"agents-wrong-target",
|
|
41
|
+
"agents-missing",
|
|
42
|
+
"claude-not-symlink",
|
|
43
|
+
"claude-wrong-target",
|
|
44
|
+
"claude-missing",
|
|
45
|
+
"invalid-skill-md",
|
|
46
|
+
"invoke-only-missing-openai-policy",
|
|
47
|
+
"openai-policy-without-invoke-only",
|
|
48
|
+
"non-managed-openai-policy",
|
|
49
|
+
"command-converted-missing-pi-exclusion",
|
|
50
|
+
"command-converted-missing-pi-replacement",
|
|
51
|
+
"unlisted-mirrors-present",
|
|
52
|
+
"agents-not-real-dir",
|
|
53
|
+
"unexpected-skills-dir",
|
|
54
|
+
"orphan-in-skills",
|
|
55
|
+
"orphan-in-agents",
|
|
56
|
+
"dangling-lockfile",
|
|
57
|
+
"claude-md-missing-peer",
|
|
58
|
+
"agents-md-missing-peer",
|
|
59
|
+
"claude-md-missing-agents-ref",
|
|
60
|
+
"pi-settings-unusable",
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
const MAX_SKILL_DESCRIPTION_CHARS = 1024;
|
|
64
|
+
const PLACEHOLDER_HASH = "PENDING_REGEN";
|
|
65
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/u;
|
|
66
|
+
type CheckIssueCode = (typeof CHECK_ISSUE_CODES)[number];
|
|
67
|
+
|
|
68
|
+
interface CheckIssue {
|
|
69
|
+
skill: string;
|
|
70
|
+
code: CheckIssueCode;
|
|
71
|
+
message: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { parseLockfileData };
|
|
75
|
+
|
|
76
|
+
const checkIssueSchema = z.object({
|
|
77
|
+
skill: z.string(),
|
|
78
|
+
code: z.enum(CHECK_ISSUE_CODES),
|
|
79
|
+
message: z.string(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const checkReportSchema = z.object({
|
|
83
|
+
ok: z.boolean(),
|
|
84
|
+
projectDir: z.string(),
|
|
85
|
+
issueCount: z.number().int().nonnegative(),
|
|
86
|
+
issues: z.array(checkIssueSchema),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const checkRequestSchema = z.object({
|
|
90
|
+
path: z
|
|
91
|
+
.string()
|
|
92
|
+
.default(".")
|
|
93
|
+
.describe("Project directory to check (default: current directory)."),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const checkResultSchema = checkReportSchema;
|
|
97
|
+
|
|
98
|
+
export type CheckRequest = z.infer<typeof checkRequestSchema>;
|
|
99
|
+
export type CheckReport = z.infer<typeof checkReportSchema>;
|
|
100
|
+
|
|
101
|
+
type CheckProjectInspection = AregCheckProjectInspection;
|
|
102
|
+
|
|
103
|
+
export async function runCheck(
|
|
104
|
+
ctx: AregCliContext,
|
|
105
|
+
request: CheckRequest,
|
|
106
|
+
): Promise<ClinkrExit<CheckReport>> {
|
|
107
|
+
const inspection = await inspectCheckProject(ctx, request.path);
|
|
108
|
+
if (inspection.projectPathState.type !== "directory") {
|
|
109
|
+
return failure("invalid-project", `${inspection.projectDir} is not a directory`);
|
|
110
|
+
}
|
|
111
|
+
const lockfileResult = parseInspectedLockfile(inspection);
|
|
112
|
+
if (!lockfileResult.ok) {
|
|
113
|
+
return failure("lockfile-invalid", lockfileResult.error.message);
|
|
114
|
+
}
|
|
115
|
+
const hasManagedSkills = lockfileResult.value.skills.length > 0;
|
|
116
|
+
const piSettings = hasManagedSkills
|
|
117
|
+
? parsePiSettings(inspection.piDir, inspection.piSettings)
|
|
118
|
+
: { ok: true as const, value: { exclusions: [] } };
|
|
119
|
+
if (!piSettings.ok) {
|
|
120
|
+
if (!isPathStateError(piSettings.error))
|
|
121
|
+
return failure("pi-settings-invalid", piSettings.error.message);
|
|
122
|
+
const report = piSettingsPathFailureReport(inspection.projectDir, piSettings.error.message);
|
|
123
|
+
return negative(formatCheckReport(report), { data: report });
|
|
124
|
+
}
|
|
125
|
+
const report = buildCheckReport(inspection, lockfileResult.value, piSettings.value.exclusions);
|
|
126
|
+
if (report.ok) return ok(report);
|
|
127
|
+
return negative(formatCheckReport(report), { data: report });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderCheck(report: CheckReport): string {
|
|
131
|
+
if (report.ok) return "All skills OK.";
|
|
132
|
+
return formatCheckReport(report);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildCheckReport(
|
|
136
|
+
inspection: CheckProjectInspection,
|
|
137
|
+
lockfile: SkillsLockfile,
|
|
138
|
+
piExclusions: readonly string[] = [],
|
|
139
|
+
): CheckReport {
|
|
140
|
+
const issues: CheckIssue[] = [];
|
|
141
|
+
const byName = new Map(inspection.skills.map((skill) => [skill.name, skill]));
|
|
142
|
+
for (const entry of lockfile.skills) {
|
|
143
|
+
const inspected = byName.get(entry.name) ?? missingCheckSkillInspection(entry.name);
|
|
144
|
+
// A missing or unparseable SKILL.md yields no record, which is treated as
|
|
145
|
+
// not-unlisted so the mirror assertions below still fail red.
|
|
146
|
+
const record = entrySkillKindRecord({ entry, inspected, inspection, piExclusions });
|
|
147
|
+
const isUnlisted = record?.kind === "unlisted";
|
|
148
|
+
if (entry.sourceType === "local") issues.push(...checkLocalSkill(entry, inspected, isUnlisted));
|
|
149
|
+
if (entry.sourceType !== "local") issues.push(...checkRemoteSkill(entry, inspected));
|
|
150
|
+
issues.push(...checkSkillMd(entry, inspected));
|
|
151
|
+
issues.push(...checkSkillInvocationKind(entry, inspected, record));
|
|
152
|
+
}
|
|
153
|
+
issues.push(...checkLockfileHashes(lockfile));
|
|
154
|
+
issues.push(...checkOrphansAndDangling(lockfile, inspection));
|
|
155
|
+
issues.push(...checkPairing(inspection));
|
|
156
|
+
return {
|
|
157
|
+
ok: issues.length === 0,
|
|
158
|
+
projectDir: inspection.projectDir,
|
|
159
|
+
issueCount: issues.length,
|
|
160
|
+
issues,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseSkillFrontmatterText(text: string): Result<Readonly<Record<string, string>>> {
|
|
165
|
+
const parsed = parseSkillFrontmatterBlock(text);
|
|
166
|
+
if (!parsed.ok) return parsed;
|
|
167
|
+
return { ok: true, value: parsed.value.fields };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function formatCheckReport(report: Pick<CheckReport, "issues">): string {
|
|
171
|
+
const grouped = new Map<string, CheckIssue[]>();
|
|
172
|
+
for (const issue of report.issues) {
|
|
173
|
+
const existing = grouped.get(issue.skill) ?? [];
|
|
174
|
+
existing.push(issue);
|
|
175
|
+
grouped.set(issue.skill, existing);
|
|
176
|
+
}
|
|
177
|
+
const lines: string[] = [];
|
|
178
|
+
for (const skill of sortStrings([...grouped.keys()])) {
|
|
179
|
+
lines.push("", `${skill}:`);
|
|
180
|
+
for (const issue of grouped.get(skill) ?? []) lines.push(` ${issue.message}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push("", `${report.issues.length} error(s)`);
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkLocalSkill(
|
|
187
|
+
entry: LockfileSkill,
|
|
188
|
+
inspected: AregCheckSkillInspection,
|
|
189
|
+
isUnlisted: boolean,
|
|
190
|
+
): CheckIssue[] {
|
|
191
|
+
const issues: CheckIssue[] = [];
|
|
192
|
+
const expectedSource = `skills/${entry.name}`;
|
|
193
|
+
if (entry.source !== expectedSource) {
|
|
194
|
+
issues.push(
|
|
195
|
+
issue(
|
|
196
|
+
entry.name,
|
|
197
|
+
"invalid-local-lock-source",
|
|
198
|
+
`Local skill lockfile source must be '${expectedSource}', found '${entry.source}'`,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (inspected.skillsPath.type === "missing") {
|
|
203
|
+
issues.push(
|
|
204
|
+
issue(
|
|
205
|
+
entry.name,
|
|
206
|
+
"missing-skills-dir",
|
|
207
|
+
`Local skill missing canonical source: skills/${entry.name}/ does not exist`,
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
} else if (inspected.skillsPath.type === "symlink") {
|
|
211
|
+
issues.push(
|
|
212
|
+
issue(
|
|
213
|
+
entry.name,
|
|
214
|
+
"skills-dir-is-symlink",
|
|
215
|
+
`skills/${entry.name} is a symlink but should be a real directory (canonical source)`,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (isUnlisted) return issues;
|
|
220
|
+
const expectedAgentsTarget = expectedAgentsSkillSymlinkTarget(entry.name);
|
|
221
|
+
if (inspected.agentsPath.type === "missing") {
|
|
222
|
+
issues.push(issue(entry.name, "agents-missing", `.agents/skills/${entry.name} does not exist`));
|
|
223
|
+
} else if (inspected.agentsPath.type !== "symlink") {
|
|
224
|
+
issues.push(
|
|
225
|
+
issue(
|
|
226
|
+
entry.name,
|
|
227
|
+
"agents-not-symlink",
|
|
228
|
+
`.agents/skills/${entry.name} is a real directory, expected symlink to ${expectedAgentsTarget}`,
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
} else if (!isAgentsSkillMirror(inspected.agentsPath, entry.name)) {
|
|
232
|
+
issues.push(
|
|
233
|
+
issue(
|
|
234
|
+
entry.name,
|
|
235
|
+
"agents-wrong-target",
|
|
236
|
+
`.agents/skills/${entry.name} symlink points to ${inspected.agentsPath.target}, expected ${expectedAgentsTarget}`,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
issues.push(...checkClaudeSymlink(entry.name, inspected));
|
|
241
|
+
return issues;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function checkRemoteSkill(entry: LockfileSkill, inspected: AregCheckSkillInspection): CheckIssue[] {
|
|
245
|
+
const issues: CheckIssue[] = [];
|
|
246
|
+
if (inspected.agentsPath.type === "missing") {
|
|
247
|
+
issues.push(
|
|
248
|
+
issue(entry.name, "agents-not-real-dir", `.agents/skills/${entry.name}/ does not exist`),
|
|
249
|
+
);
|
|
250
|
+
} else if (inspected.agentsPath.type === "symlink") {
|
|
251
|
+
issues.push(
|
|
252
|
+
issue(
|
|
253
|
+
entry.name,
|
|
254
|
+
"agents-not-real-dir",
|
|
255
|
+
`.agents/skills/${entry.name} is a symlink but should be a real directory (vendored)`,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
issues.push(...checkClaudeSymlink(entry.name, inspected));
|
|
260
|
+
if (inspected.skillsPath.type !== "missing")
|
|
261
|
+
issues.push(
|
|
262
|
+
issue(
|
|
263
|
+
entry.name,
|
|
264
|
+
"unexpected-skills-dir",
|
|
265
|
+
`GitHub-sourced skill should not have skills/${entry.name}/ entry`,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
return issues;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function checkClaudeSymlink(name: string, inspected: AregCheckSkillInspection): CheckIssue[] {
|
|
272
|
+
const expectedTarget = expectedClaudeSkillSymlinkTarget(name);
|
|
273
|
+
if (inspected.claudePath.type === "missing")
|
|
274
|
+
return [issue(name, "claude-missing", `.claude/skills/${name} does not exist`)];
|
|
275
|
+
if (inspected.claudePath.type !== "symlink")
|
|
276
|
+
return [
|
|
277
|
+
issue(
|
|
278
|
+
name,
|
|
279
|
+
"claude-not-symlink",
|
|
280
|
+
`.claude/skills/${name} is a real directory, expected symlink to ${expectedTarget}`,
|
|
281
|
+
),
|
|
282
|
+
];
|
|
283
|
+
if (!isClaudeSkillMirror(inspected.claudePath, name))
|
|
284
|
+
return [
|
|
285
|
+
issue(
|
|
286
|
+
name,
|
|
287
|
+
"claude-wrong-target",
|
|
288
|
+
`.claude/skills/${name} symlink points to ${inspected.claudePath.target}, expected ${expectedTarget}`,
|
|
289
|
+
),
|
|
290
|
+
];
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function checkSkillMd(entry: LockfileSkill, inspected: AregCheckSkillInspection): CheckIssue[] {
|
|
295
|
+
const relativePath =
|
|
296
|
+
entry.sourceType === "local"
|
|
297
|
+
? `skills/${entry.name}/SKILL.md`
|
|
298
|
+
: `.agents/skills/${entry.name}/SKILL.md`;
|
|
299
|
+
const skillMd = entry.sourceType === "local" ? inspected.localSkillMd : inspected.remoteSkillMd;
|
|
300
|
+
if (skillMd.type !== "file")
|
|
301
|
+
return [issue(entry.name, "invalid-skill-md", `${relativePath} does not exist`)];
|
|
302
|
+
const frontmatter = parseSkillFrontmatterText(skillMd.text);
|
|
303
|
+
if (!frontmatter.ok)
|
|
304
|
+
return [
|
|
305
|
+
issue(
|
|
306
|
+
entry.name,
|
|
307
|
+
"invalid-skill-md",
|
|
308
|
+
`${relativePath} invalid frontmatter: ${frontmatter.error.message}`,
|
|
309
|
+
),
|
|
310
|
+
];
|
|
311
|
+
const description = frontmatter.value.description;
|
|
312
|
+
if (description !== undefined && description.length > MAX_SKILL_DESCRIPTION_CHARS) {
|
|
313
|
+
return [
|
|
314
|
+
issue(
|
|
315
|
+
entry.name,
|
|
316
|
+
"invalid-skill-md",
|
|
317
|
+
`${relativePath} invalid description: exceeds maximum length of 1024 characters (got ${description.length})`,
|
|
318
|
+
),
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface CheckSkillInvocationKindOptions {
|
|
325
|
+
entry: LockfileSkill;
|
|
326
|
+
inspected: AregCheckSkillInspection;
|
|
327
|
+
inspection: CheckProjectInspection;
|
|
328
|
+
piExclusions: readonly string[];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function skillBaseRelativePath(entry: LockfileSkill): string {
|
|
332
|
+
return entry.sourceType === "local" ? `skills/${entry.name}` : `.agents/skills/${entry.name}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function entrySkillKindRecord(
|
|
336
|
+
options: CheckSkillInvocationKindOptions,
|
|
337
|
+
): SkillKindRecord | undefined {
|
|
338
|
+
const { entry, inspected, inspection, piExclusions } = options;
|
|
339
|
+
const relativePath =
|
|
340
|
+
entry.sourceType === "local"
|
|
341
|
+
? `skills/${entry.name}/SKILL.md`
|
|
342
|
+
: `.agents/skills/${entry.name}/SKILL.md`;
|
|
343
|
+
const skillMd = entry.sourceType === "local" ? inspected.localSkillMd : inspected.remoteSkillMd;
|
|
344
|
+
if (skillMd.type !== "file") return undefined;
|
|
345
|
+
const frontmatter = inspectSkillFrontmatter(skillMd.text, relativePath);
|
|
346
|
+
if (!frontmatter.ok) return undefined;
|
|
347
|
+
const isPiExcluded = piExclusions.includes(`-skills/${entry.name}`);
|
|
348
|
+
const replacement = verifyPiReplacement(entry.name, inspection.replacement);
|
|
349
|
+
return inferSkillKindRecord({
|
|
350
|
+
skillName: entry.name,
|
|
351
|
+
frontmatter: frontmatter.value,
|
|
352
|
+
hasCodexSidecar: inspected.openaiPolicy.type === "file",
|
|
353
|
+
isPiExcluded,
|
|
354
|
+
hasAgentsMirror: inspected.agentsPath.type !== "missing",
|
|
355
|
+
hasClaudeMirror: inspected.claudePath.type !== "missing",
|
|
356
|
+
replacement,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function checkSkillInvocationKind(
|
|
361
|
+
entry: LockfileSkill,
|
|
362
|
+
inspected: AregCheckSkillInspection,
|
|
363
|
+
record: SkillKindRecord | undefined,
|
|
364
|
+
): CheckIssue[] {
|
|
365
|
+
if (record === undefined) return [];
|
|
366
|
+
const issues: CheckIssue[] = [];
|
|
367
|
+
if (record.artifacts.isModelInvocationDisabled && !record.artifacts.hasCodexSidecar)
|
|
368
|
+
issues.push(
|
|
369
|
+
issue(
|
|
370
|
+
entry.name,
|
|
371
|
+
"invoke-only-missing-openai-policy",
|
|
372
|
+
`${skillBaseRelativePath(entry)}/agents/openai.yaml missing for invoke-only skill`,
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
if (!record.artifacts.isModelInvocationDisabled && record.artifacts.hasCodexSidecar)
|
|
376
|
+
issues.push(
|
|
377
|
+
issue(
|
|
378
|
+
entry.name,
|
|
379
|
+
"openai-policy-without-invoke-only",
|
|
380
|
+
`${skillBaseRelativePath(entry)}/agents/openai.yaml exists but SKILL.md does not set disable-model-invocation: true`,
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
if (
|
|
384
|
+
inspected.openaiPolicy.type === "file" &&
|
|
385
|
+
!isManagedOpenaiPolicyContent(inspected.openaiPolicy.text)
|
|
386
|
+
)
|
|
387
|
+
issues.push(
|
|
388
|
+
issue(
|
|
389
|
+
entry.name,
|
|
390
|
+
"non-managed-openai-policy",
|
|
391
|
+
`${skillBaseRelativePath(entry)}/agents/openai.yaml exists with non-managed content`,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
if (
|
|
395
|
+
record.artifacts.isModelInvocationDisabled &&
|
|
396
|
+
record.artifacts.hasCodexSidecar &&
|
|
397
|
+
record.replacement.verified &&
|
|
398
|
+
!record.artifacts.isPiExcluded
|
|
399
|
+
) {
|
|
400
|
+
issues.push(
|
|
401
|
+
issue(
|
|
402
|
+
entry.name,
|
|
403
|
+
"command-converted-missing-pi-exclusion",
|
|
404
|
+
`.pi/settings.json missing -skills/${entry.name} for command-backed skill`,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (record.artifacts.isPiExcluded && !record.replacement.verified && record.kind !== "unlisted") {
|
|
409
|
+
if (
|
|
410
|
+
isUnlistedCandidate(record.artifacts, record.replacement.surface) &&
|
|
411
|
+
(record.artifacts.hasAgentsMirror || record.artifacts.hasClaudeMirror)
|
|
412
|
+
) {
|
|
413
|
+
const presentMirrors = [
|
|
414
|
+
...(record.artifacts.hasAgentsMirror ? [`.agents/skills/${entry.name}`] : []),
|
|
415
|
+
...(record.artifacts.hasClaudeMirror ? [`.claude/skills/${entry.name}`] : []),
|
|
416
|
+
].join(" and ");
|
|
417
|
+
issues.push(
|
|
418
|
+
issue(
|
|
419
|
+
entry.name,
|
|
420
|
+
"unlisted-mirrors-present",
|
|
421
|
+
`${presentMirrors} still exist(s) for unlisted-candidate skill ${entry.name}; run areg skill apply unlisted ${entry.name} to remove the mirror symlinks, or restore the COMMAND_BACKED_SKILL_REGISTRY entry and its verified replacement to return to command-backed`,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
} else {
|
|
425
|
+
const expected =
|
|
426
|
+
record.replacement.surface === undefined
|
|
427
|
+
? "a registered command-backed replacement"
|
|
428
|
+
: `/${record.replacement.surface}`;
|
|
429
|
+
issues.push(
|
|
430
|
+
issue(
|
|
431
|
+
entry.name,
|
|
432
|
+
"command-converted-missing-pi-replacement",
|
|
433
|
+
`Pi skill is excluded but no verified replacement command exists; expected ${expected}`,
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return issues;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function checkLockfileHashes(lockfile: SkillsLockfile): CheckIssue[] {
|
|
442
|
+
const issues: CheckIssue[] = [];
|
|
443
|
+
for (const entry of lockfile.skills) {
|
|
444
|
+
if (entry.computedHash === PLACEHOLDER_HASH) {
|
|
445
|
+
issues.push(
|
|
446
|
+
issue(
|
|
447
|
+
entry.name,
|
|
448
|
+
"invalid-lock-hash",
|
|
449
|
+
`skills-lock.json entry for ${entry.name} has placeholder computedHash ${PLACEHOLDER_HASH}; regenerate or normalize the lockfile before relying on areg check.`,
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
} else if (!SHA256_HEX_RE.test(entry.computedHash)) {
|
|
453
|
+
issues.push(
|
|
454
|
+
issue(
|
|
455
|
+
entry.name,
|
|
456
|
+
"invalid-lock-hash",
|
|
457
|
+
`skills-lock.json entry for ${entry.name} has invalid computedHash '${entry.computedHash}'; expected 64 lowercase hex characters.`,
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return issues;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function checkOrphansAndDangling(
|
|
466
|
+
lockfile: SkillsLockfile,
|
|
467
|
+
inspection: CheckProjectInspection,
|
|
468
|
+
): CheckIssue[] {
|
|
469
|
+
const lockNames = new Set(lockfile.skills.map((skill) => skill.name));
|
|
470
|
+
const excluded = new Set(inspection.excludedSkillNames);
|
|
471
|
+
const byName = new Map(inspection.skills.map((skill) => [skill.name, skill]));
|
|
472
|
+
const issues: CheckIssue[] = [];
|
|
473
|
+
for (const name of sortStrings(inspection.skillsDirectoryNames)) {
|
|
474
|
+
if (!lockNames.has(name) && !excluded.has(name))
|
|
475
|
+
issues.push(
|
|
476
|
+
issue(
|
|
477
|
+
name,
|
|
478
|
+
"orphan-in-skills",
|
|
479
|
+
`Orphaned directory skills/${name}/ has no entry in skills-lock.json`,
|
|
480
|
+
),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
for (const name of sortStrings(inspection.agentsSkillNames)) {
|
|
484
|
+
if (!lockNames.has(name) && !excluded.has(name))
|
|
485
|
+
issues.push(
|
|
486
|
+
issue(
|
|
487
|
+
name,
|
|
488
|
+
"orphan-in-agents",
|
|
489
|
+
`Orphaned directory .agents/skills/${name}/ has no entry in skills-lock.json`,
|
|
490
|
+
),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
for (const name of sortStrings([...lockNames])) {
|
|
494
|
+
const inspected = byName.get(name) ?? missingCheckSkillInspection(name);
|
|
495
|
+
if (
|
|
496
|
+
inspected.skillsPath.type === "missing" &&
|
|
497
|
+
inspected.agentsPath.type === "missing" &&
|
|
498
|
+
inspected.claudePath.type === "missing"
|
|
499
|
+
) {
|
|
500
|
+
issues.push(
|
|
501
|
+
issue(
|
|
502
|
+
name,
|
|
503
|
+
"dangling-lockfile",
|
|
504
|
+
`Dangling lockfile entry: no directories found on disk for ${name}`,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return issues;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function checkPairing(inspection: CheckProjectInspection): CheckIssue[] {
|
|
513
|
+
const issues: CheckIssue[] = [];
|
|
514
|
+
for (const directory of inspection.pairingDirectories) {
|
|
515
|
+
const agentsRel =
|
|
516
|
+
directory.relativeDir.length === 0 ? "AGENTS.md" : `${directory.relativeDir}/AGENTS.md`;
|
|
517
|
+
const claudeRel =
|
|
518
|
+
directory.relativeDir.length === 0 ? "CLAUDE.md" : `${directory.relativeDir}/CLAUDE.md`;
|
|
519
|
+
if (directory.hasAgents && !directory.hasClaude) {
|
|
520
|
+
issues.push(
|
|
521
|
+
issue(
|
|
522
|
+
agentsRel,
|
|
523
|
+
"claude-md-missing-peer",
|
|
524
|
+
`AGENTS.md at ${agentsRel} has no peer CLAUDE.md in the same directory`,
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
} else if (directory.hasClaude && !directory.hasAgents) {
|
|
528
|
+
issues.push(
|
|
529
|
+
issue(
|
|
530
|
+
claudeRel,
|
|
531
|
+
"agents-md-missing-peer",
|
|
532
|
+
`CLAUDE.md at ${claudeRel} has no peer AGENTS.md in the same directory`,
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
} else if (
|
|
536
|
+
directory.hasAgents &&
|
|
537
|
+
directory.hasClaude &&
|
|
538
|
+
!directory.claudeText?.includes("@AGENTS.md")
|
|
539
|
+
) {
|
|
540
|
+
issues.push(
|
|
541
|
+
issue(
|
|
542
|
+
claudeRel,
|
|
543
|
+
"claude-md-missing-agents-ref",
|
|
544
|
+
`CLAUDE.md at ${claudeRel} does not include peer AGENTS.md via @AGENTS.md syntax`,
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return issues;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function piSettingsPathFailureReport(projectDir: string, message: string): CheckReport {
|
|
553
|
+
const issues = [issue(".pi/settings.json", "pi-settings-unusable", message)];
|
|
554
|
+
return { ok: false, projectDir: projectDir, issueCount: issues.length, issues };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function issue(skill: string, code: CheckIssueCode, message: string): CheckIssue {
|
|
558
|
+
return { skill, code, message };
|
|
559
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { displayWidth } from "@nseng-ai/foundation/text-table";
|
|
2
|
+
|
|
3
|
+
import { uniqueSortedStrings } from "../sort.ts";
|
|
4
|
+
import type { DoctorSkillFinding, DoctorSkillsResult } from "./doctor-skills.ts";
|
|
5
|
+
import {
|
|
6
|
+
DOCTOR_SKILL_SEVERITY_RANK,
|
|
7
|
+
type DoctorSkillFindingSeverity,
|
|
8
|
+
} from "./doctor-skills-severity.ts";
|
|
9
|
+
|
|
10
|
+
interface FindingGroup {
|
|
11
|
+
code: string;
|
|
12
|
+
severity: DoctorSkillFindingSeverity;
|
|
13
|
+
remediation: string;
|
|
14
|
+
findings: readonly DoctorSkillFinding[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Findings with this many or fewer occurrences show each finding's specific
|
|
18
|
+
// message; larger groups collapse to a wrapped list of affected labels so a
|
|
19
|
+
// single templated code (e.g. skill-root-shadowed) cannot bury the report.
|
|
20
|
+
const DETAIL_THRESHOLD = 3;
|
|
21
|
+
const AFFECTED_CAP = 12;
|
|
22
|
+
const AFFECTED_WRAP_WIDTH = 78;
|
|
23
|
+
|
|
24
|
+
export function renderDoctorSkills(result: DoctorSkillsResult): string {
|
|
25
|
+
if (result.findings.length === 0) return "No skill registry drift found.";
|
|
26
|
+
const blocks = groupFindingsByType(result.findings).map(renderFindingBlock);
|
|
27
|
+
return [
|
|
28
|
+
`Skill doctor: ${result.summary.status} (${formatFindingCounts(result.summary.findingCounts)})`,
|
|
29
|
+
`Project: ${result.projectDir}`,
|
|
30
|
+
"",
|
|
31
|
+
blocks.join("\n\n"),
|
|
32
|
+
"",
|
|
33
|
+
"Run `areg doctor skills --format json` for full machine-readable evidence.",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderFindingBlock(group: FindingGroup): string {
|
|
38
|
+
const lines = [
|
|
39
|
+
`${group.severity} ${group.code} (${group.findings.length})`,
|
|
40
|
+
` Fix: ${group.remediation}`,
|
|
41
|
+
];
|
|
42
|
+
if (group.findings.length <= DETAIL_THRESHOLD) {
|
|
43
|
+
for (const finding of group.findings) {
|
|
44
|
+
lines.push(` ${affectedLabel(finding)}: ${finding.message}`);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(...renderAffectedList(uniqueSortedStrings(group.findings.map(affectedLabel))));
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function affectedLabel(finding: DoctorSkillFinding): string {
|
|
53
|
+
return finding.skill ?? finding.path ?? finding.surface ?? "project";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderAffectedList(labels: readonly string[]): string[] {
|
|
57
|
+
const shown = labels.slice(0, AFFECTED_CAP);
|
|
58
|
+
const remaining = labels.length - shown.length;
|
|
59
|
+
const lines = wrapCommaList(shown, " ", AFFECTED_WRAP_WIDTH);
|
|
60
|
+
if (remaining > 0) {
|
|
61
|
+
lines.push(` (+${remaining} more; run with --format json for the full list)`);
|
|
62
|
+
}
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function wrapCommaList(items: readonly string[], indent: string, maxWidth: number): string[] {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
let current = "";
|
|
69
|
+
items.forEach((item, index) => {
|
|
70
|
+
const piece = index === items.length - 1 ? item : `${item},`;
|
|
71
|
+
if (current === "") {
|
|
72
|
+
current = indent + piece;
|
|
73
|
+
} else {
|
|
74
|
+
const candidate = `${current} ${piece}`;
|
|
75
|
+
if (displayWidth(candidate) > maxWidth) {
|
|
76
|
+
lines.push(current);
|
|
77
|
+
current = indent + piece;
|
|
78
|
+
} else {
|
|
79
|
+
current = candidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (current !== "") lines.push(current);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function groupFindingsByType(findings: readonly DoctorSkillFinding[]): readonly FindingGroup[] {
|
|
88
|
+
const groups = new Map<string, FindingGroup>();
|
|
89
|
+
for (const finding of findings) {
|
|
90
|
+
const key = `${finding.severity}\0${finding.code}\0${finding.remediation}`;
|
|
91
|
+
const group = groups.get(key);
|
|
92
|
+
groups.set(key, {
|
|
93
|
+
code: finding.code,
|
|
94
|
+
severity: finding.severity,
|
|
95
|
+
remediation: finding.remediation,
|
|
96
|
+
findings: group === undefined ? [finding] : [...group.findings, finding],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return [...groups.values()].sort(
|
|
100
|
+
(left, right) =>
|
|
101
|
+
DOCTOR_SKILL_SEVERITY_RANK[left.severity] - DOCTOR_SKILL_SEVERITY_RANK[right.severity] ||
|
|
102
|
+
left.code.localeCompare(right.code) ||
|
|
103
|
+
left.remediation.localeCompare(right.remediation),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatFindingCounts(counts: Record<DoctorSkillFindingSeverity, number>): string {
|
|
108
|
+
return `${counts.error} error, ${counts.warning} warning, ${counts.info} info`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const doctorSkillFindingSeverities = ["error", "warning", "info"] as const;
|
|
2
|
+
|
|
3
|
+
export type DoctorSkillFindingSeverity = (typeof doctorSkillFindingSeverities)[number];
|
|
4
|
+
|
|
5
|
+
export const DOCTOR_SKILL_SEVERITY_RANK: Record<DoctorSkillFindingSeverity, number> = {
|
|
6
|
+
error: 0,
|
|
7
|
+
warning: 1,
|
|
8
|
+
info: 2,
|
|
9
|
+
};
|