feat: add skill health size check
This commit is contained in:
@@ -25,6 +25,7 @@ import { isGhContentRoute, runGhContentRoute } from "./src/gh-route";
|
||||
import { runGitToolsCommand } from "./src/git-tools";
|
||||
import { runCommanderCommand } from "./src/commander";
|
||||
import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help";
|
||||
import { healthHelp, runHealthCommand } from "./src/health";
|
||||
import { runServerCleanupCommand } from "./src/server-cleanup";
|
||||
import { runGcCommand } from "./src/gc";
|
||||
import { runPlatformDbCommand } from "./src/platform-db";
|
||||
@@ -245,6 +246,11 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "health" && isHelpToken(sub)) {
|
||||
emitJson(commandName, healthHelp());
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "server" && (isHelpToken(sub) || args.slice(2).some(isHelpToken))) {
|
||||
emitJson(commandName, serverHelp(isHelpToken(sub) ? undefined : sub));
|
||||
return;
|
||||
@@ -415,6 +421,19 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "health") {
|
||||
const result = runHealthCommand(args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args);
|
||||
if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) {
|
||||
|
||||
@@ -29,6 +29,7 @@ const syntaxFiles = [
|
||||
"scripts/src/docker.ts",
|
||||
"scripts/src/e2e.ts",
|
||||
"scripts/src/help.ts",
|
||||
"scripts/src/health.ts",
|
||||
"scripts/src/commander.ts",
|
||||
"scripts/src/platform-db.ts",
|
||||
"scripts/src/recovery-guardrails.ts",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
const DEFAULT_SKILLS_DIR = ".agents/skills";
|
||||
const DEFAULT_SKILL_MD_LIMIT_BYTES = 10 * 1024;
|
||||
|
||||
interface HealthOptions {
|
||||
skillsDir: string;
|
||||
thresholdBytes: number;
|
||||
json: boolean;
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
interface SkillHealthItem {
|
||||
skillId: string;
|
||||
skillPath: string;
|
||||
skillMdPath: string;
|
||||
referencesDir: string;
|
||||
bytes: number;
|
||||
lines: number;
|
||||
limitBytes: number;
|
||||
overBytes: number;
|
||||
status: "OK" | "ALERT";
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface SkillHealthResult {
|
||||
ok: boolean;
|
||||
command: "health";
|
||||
check: "skill-md-size";
|
||||
thresholdBytes: number;
|
||||
skillsDir: string;
|
||||
summary: {
|
||||
totalSkills: number;
|
||||
okSkills: number;
|
||||
alertSkills: number;
|
||||
maxBytes: number;
|
||||
maxSkillId: string | null;
|
||||
};
|
||||
alerts: SkillHealthItem[];
|
||||
skills: SkillHealthItem[];
|
||||
policy: {
|
||||
limitBytes: number;
|
||||
splitTarget: ".agents/skills/<skill_id>/references/*.md";
|
||||
skillMdRole: string;
|
||||
};
|
||||
next: string[];
|
||||
}
|
||||
|
||||
export function healthHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "health",
|
||||
output: "text by default; use --json, --full, or --raw for structured output",
|
||||
usage: [
|
||||
"bun scripts/cli.ts health",
|
||||
"bun scripts/cli.ts health --json",
|
||||
"bun scripts/cli.ts health --skills-dir .agents/skills --threshold-bytes 10240",
|
||||
],
|
||||
checks: [
|
||||
{
|
||||
name: "skill-md-size",
|
||||
defaultThresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES,
|
||||
scope: ".agents/skills/*/SKILL.md",
|
||||
failure: "SKILL.md over threshold",
|
||||
action: "move lower-frequency detail into .agents/skills/<skill_id>/references/*.md and leave SKILL.md as high-frequency routing/workflow guidance plus reference links",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function runHealthCommand(args: string[]): SkillHealthResult | RenderedCliResult {
|
||||
const options = parseHealthOptions(args);
|
||||
const result = scanSkillHealth(options);
|
||||
if (options.json || options.full) return result;
|
||||
return {
|
||||
ok: result.ok,
|
||||
command: "health",
|
||||
contentType: "text/plain",
|
||||
renderedText: renderSkillHealth(result),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHealthOptions(args: string[]): HealthOptions {
|
||||
const options: HealthOptions = {
|
||||
skillsDir: DEFAULT_SKILLS_DIR,
|
||||
thresholdBytes: DEFAULT_SKILL_MD_LIMIT_BYTES,
|
||||
json: false,
|
||||
full: false,
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") {
|
||||
options.json = true;
|
||||
} else if (arg === "--full" || arg === "--raw") {
|
||||
options.full = true;
|
||||
options.json = true;
|
||||
} else if (arg === "--skills-dir") {
|
||||
options.skillsDir = stringArg(arg, args[index + 1]);
|
||||
index += 1;
|
||||
} else if (arg === "--threshold-bytes") {
|
||||
options.thresholdBytes = positiveIntArg(arg, args[index + 1], 1024 * 1024);
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(`unknown health option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function stringArg(name: string, raw: string | undefined): string {
|
||||
if (raw === undefined || raw.trim().length === 0) throw new Error(`${name} requires a non-empty value`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function positiveIntArg(name: string, raw: string | undefined, max: number): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return Math.min(value, max);
|
||||
}
|
||||
|
||||
function resolvePath(path: string): string {
|
||||
return isAbsolute(path) ? path : rootPath(path);
|
||||
}
|
||||
|
||||
function displayPath(path: string): string {
|
||||
const rel = relative(repoRoot, path);
|
||||
return rel.length > 0 && !rel.startsWith("..") ? rel : path;
|
||||
}
|
||||
|
||||
function scanSkillHealth(options: HealthOptions): SkillHealthResult {
|
||||
const skillsRoot = resolvePath(options.skillsDir);
|
||||
if (!existsSync(skillsRoot)) throw new Error(`health skills directory not found: ${displayPath(skillsRoot)}`);
|
||||
const skillDirs = readdirSync(skillsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(skillsRoot, entry.name))
|
||||
.filter((dir) => existsSync(join(dir, "SKILL.md")))
|
||||
.sort();
|
||||
|
||||
const skills = skillDirs.map((skillPath) => skillHealthItem(skillPath, options.thresholdBytes));
|
||||
const alerts = skills.filter((item) => item.status === "ALERT");
|
||||
const max = skills.reduce<SkillHealthItem | null>((current, item) => current === null || item.bytes > current.bytes ? item : current, null);
|
||||
const ok = skills.length > 0 && alerts.length === 0;
|
||||
return {
|
||||
ok,
|
||||
command: "health",
|
||||
check: "skill-md-size",
|
||||
thresholdBytes: options.thresholdBytes,
|
||||
skillsDir: displayPath(skillsRoot),
|
||||
summary: {
|
||||
totalSkills: skills.length,
|
||||
okSkills: skills.length - alerts.length,
|
||||
alertSkills: alerts.length,
|
||||
maxBytes: max?.bytes ?? 0,
|
||||
maxSkillId: max?.skillId ?? null,
|
||||
},
|
||||
alerts,
|
||||
skills,
|
||||
policy: {
|
||||
limitBytes: options.thresholdBytes,
|
||||
splitTarget: ".agents/skills/<skill_id>/references/*.md",
|
||||
skillMdRole: "Keep SKILL.md under the limit with only trigger-critical, highest-frequency workflow guidance; move lower-frequency detail into directly linked references.",
|
||||
},
|
||||
next: skills.length === 0
|
||||
? [`No SKILL.md files were found under ${displayPath(skillsRoot)}; check --skills-dir.`]
|
||||
: alerts.length === 0
|
||||
? ["No skill split is required."]
|
||||
: [
|
||||
"Move lower-frequency detail into .agents/skills/<skill_id>/references/*.md.",
|
||||
"Keep SKILL.md as a compact high-frequency workflow and navigation index.",
|
||||
"Rerun: bun scripts/cli.ts health",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function skillHealthItem(skillPath: string, limitBytes: number): SkillHealthItem {
|
||||
const skillMdPath = join(skillPath, "SKILL.md");
|
||||
const text = readFileSync(skillMdPath, "utf8");
|
||||
const bytes = statSync(skillMdPath).size;
|
||||
const skillId = skillPath.split(/[\\/]/u).pop() ?? displayPath(skillPath);
|
||||
const referencesDir = join(skillPath, "references");
|
||||
const overBytes = Math.max(0, bytes - limitBytes);
|
||||
return {
|
||||
skillId,
|
||||
skillPath: displayPath(skillPath),
|
||||
skillMdPath: displayPath(skillMdPath),
|
||||
referencesDir: displayPath(referencesDir),
|
||||
bytes,
|
||||
lines: text.split(/\r?\n/u).length,
|
||||
limitBytes,
|
||||
overBytes,
|
||||
status: overBytes > 0 ? "ALERT" : "OK",
|
||||
action: overBytes > 0 ? `split lower-frequency detail to ${displayPath(referencesDir)}` : "-",
|
||||
};
|
||||
}
|
||||
|
||||
function renderSkillHealth(result: SkillHealthResult): string {
|
||||
const lines = [
|
||||
`skill health (${result.ok ? "ok" : "alert"})`,
|
||||
"",
|
||||
renderTable(["SUMMARY", "VALUE"], [
|
||||
["skills", String(result.summary.totalSkills)],
|
||||
["limit", `${result.thresholdBytes} bytes`],
|
||||
["alerts", String(result.summary.alertSkills)],
|
||||
["largest", result.summary.maxSkillId === null ? "-" : `${result.summary.maxSkillId} ${result.summary.maxBytes} bytes`],
|
||||
]),
|
||||
"",
|
||||
];
|
||||
if (result.alerts.length > 0) {
|
||||
lines.push(
|
||||
"ALERTS",
|
||||
renderTable(["SKILL", "BYTES", "OVER", "LINES", "ACTION"], result.alerts.map((item) => [
|
||||
item.skillId,
|
||||
String(item.bytes),
|
||||
String(item.overBytes),
|
||||
String(item.lines),
|
||||
item.action,
|
||||
])),
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
lines.push("ALERTS", " none", "");
|
||||
}
|
||||
lines.push(
|
||||
"Policy:",
|
||||
` SKILL.md limit: ${result.thresholdBytes} bytes`,
|
||||
" Split target: .agents/skills/<skill_id>/references/*.md",
|
||||
" Keep SKILL.md to highest-frequency workflow/routing content and direct reference links.",
|
||||
"",
|
||||
"Next:",
|
||||
...result.next.map((item) => ` ${item}`),
|
||||
" bun scripts/cli.ts health --full",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const normalized = rows.length === 0 ? [headers.map(() => "-")] : rows;
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...normalized.map((row) => (row[index] ?? "").length)));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...normalized.map(renderRow)].join("\n");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ghHelp, ghScopedHelp } from "./gh";
|
||||
import { authBrokerHelp } from "./auth-broker";
|
||||
import { platformDbHelp } from "./platform-db";
|
||||
import { secretsHelp } from "./secrets";
|
||||
import { healthHelp } from "./health";
|
||||
|
||||
export function rootHelp(): unknown {
|
||||
return {
|
||||
@@ -14,6 +15,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "--main-server-ip <ip> <command>", description: "Run selected commands through the public frontend API; use --main-server-key only for legacy SSH transport." },
|
||||
{ command: "config show", description: "Validate and print config.json as the single source of truth." },
|
||||
{ command: "check [--full|--files|--scripts-typecheck|--scripts-typecheck-timeout-ms N|--check-heartbeat-ms N|--components|--compose|--logs|--recovery-guardrails|--rust] | check recovery-guardrails", description: "Run the lightweight default syntax/config gate or the low-noise read-only D601 recovery guardrails; long command checks emit progress heartbeats and bounded timeout details." },
|
||||
{ command: "health [--json|--full|--skills-dir path|--threshold-bytes N]", description: "Check repo skill SKILL.md size health; files over 10 KiB alert and must move lower-frequency detail into .agents/skills/<skill_id>/references/." },
|
||||
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." },
|
||||
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
|
||||
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
|
||||
@@ -755,6 +757,7 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
|
||||
const [top, sub] = args;
|
||||
if (!args.slice(1).some(isHelpToken)) return null;
|
||||
if (top === "config") return configHelp();
|
||||
if (top === "health") return healthHelp();
|
||||
if (top === "microservice") return microserviceHelp();
|
||||
if (top === "decision" || top === "decision-center") return decisionHelp();
|
||||
if (top === "provider") return providerHelp();
|
||||
|
||||
Reference in New Issue
Block a user