Files
APAW/scripts/validate-agents.cjs
¨NW¨ 80dca09ae0 fix: unquoted color, duplicate key, GLM downgrade + cross-platform validator
- Fix security-auditor.md color bare hex to quoted
- Fix orchestrator.md duplicate devops-engineer key
- Fix .kilo/kilo.jsonc: orchestrator + root model to kimi-k2.6:cloud
- Update agent-frontmatter-validation.md with diagnostic guide
- Update global.md with YAML frontmatter rules for all agents
- Update agent-architect.md + workflow-architect.md with color checklist
- Add scripts/validate-agents.cjs: zero-dependency, cross-platform, --fix flag, scans worktrees
2026-05-04 22:01:45 +01:00

348 lines
9.4 KiB
JavaScript

// Agent Frontmatter Validation Script - Cross-Platform, Zero-Deps
// Checks ONLY .kilo/agents/.md files and worktrees for YAML frontmatter correctness.
//
// Usage: node scripts/validate-agents.cjs
// node scripts/validate-agents.cjs --fix (auto-quote unquoted colors)
// node scripts/validate-agents.cjs --verbose
const fs = require("fs");
const path = require("path");
const FIX_MODE = process.argv.includes("--fix");
const VERBOSE = process.argv.includes("--verbose");
let errors = [];
let warnings = [];
let fixes = [];
// Config
const ROOT = process.cwd();
const AGENT_DIRS = [".kilo/agents"];
const VALID_MODES = new Set(["subagent", "all"]);
// Utils
function log(...args) {
console.log(...args);
}
function globMd(dirPath) {
const results = [];
function walk(current) {
if (!fs.existsSync(current)) return;
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const e of entries) {
const full = path.join(current, e.name);
if (e.isDirectory()) walk(full);
else if (e.name.endsWith(".md")) results.push(full);
}
}
walk(dirPath);
return results;
}
function findWorktrees() {
const worktreesDir = path.join(ROOT, ".kilo", "worktrees");
if (!fs.existsSync(worktreesDir)) return [];
try {
return fs
.readdirSync(worktreesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => path.join(worktreesDir, d.name));
} catch {
return [];
}
}
function loadValidModels() {
const metaPath = path.join(ROOT, "kilo-meta.json");
if (!fs.existsSync(metaPath)) return new Set();
try {
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
const models = new Set();
if (meta.agents) {
for (const a of Object.values(meta.agents)) {
if (a.model) models.add(a.model);
}
}
return models;
} catch {
return new Set();
}
}
// Lightweight YAML Frontmatter Parser
function parseYamlFrontmatter(content) {
if (!content.startsWith("---")) return null;
const end = content.indexOf("---", 3);
if (end === -1) return null;
const fmText = content.slice(3, end).trim();
const lines = fmText.split(/\r?\n/);
const result = {};
let currentObj = null;
for (const raw of lines) {
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const indent = raw.match(/^(\s*)/)[1].length;
const topMatch = raw.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
if (topMatch && indent === 0) {
const key = topMatch[1];
let val = topMatch[2].trim();
if (val.startsWith('"') || val.startsWith("'")) {
const q = val[0];
const endIdx = val.slice(1).indexOf(q);
result[key] = endIdx >= 0 ? val.slice(1, endIdx + 1) : val;
currentObj = null;
} else if (val === "") {
result[key] = {};
currentObj = result[key];
} else {
result[key] = val;
currentObj = null;
}
continue;
}
if (currentObj !== null && indent > 0) {
const nestedMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
if (nestedMatch) {
const nKey = nestedMatch[1];
let nVal = nestedMatch[2].trim();
if (nVal.startsWith('"') || nVal.startsWith("'")) {
const q = nVal[0];
const endIdx = nVal.slice(1).indexOf(q);
currentObj[nKey] = endIdx >= 0 ? nVal.slice(1, endIdx + 1) : nVal;
} else if (nVal === "") {
currentObj[nKey] = {};
} else {
currentObj[nKey] = nVal;
}
} else if (trimmed.startsWith('"*"')) {
const nextColon = trimmed.indexOf(":");
if (nextColon > 0) {
const k = trimmed
.slice(0, nextColon)
.replace(/"/g, "")
.trim();
const v = trimmed.slice(nextColon + 1).trim();
currentObj[k] = v.replace(/"/g, "");
}
}
}
}
return result;
}
// Validation
function validateAgentFile(filePath, validModels) {
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
errors.push({ file: filePath, msg: "Cannot read file" });
return;
}
if (!content.startsWith("---")) {
errors.push({
file: filePath,
msg: "Missing YAML frontmatter (must start with ---)",
});
return;
}
const allLines = content.split(/\r?\n/);
// Find frontmatter boundaries
let fmEnd = -1;
for (let i = 1; i < allLines.length; i++) {
if (allLines[i].trim() === "---") {
fmEnd = i;
break;
}
}
if (fmEnd === -1) {
errors.push({ file: filePath, msg: "Unclosed YAML frontmatter" });
return;
}
const fmLines = allLines.slice(0, fmEnd);
// --- Color MUST be double-quoted ---
const colorLineIdx = fmLines.findIndex((l) => l.match(/^color\s*:/));
if (colorLineIdx >= 0) {
const colorRaw = fmLines[colorLineIdx];
const colorMatch = colorRaw.match(/^color\s*:\s*(.+)$/);
if (colorMatch) {
const colorVal = colorMatch[1].trim();
const isValid = colorVal.match(/^"#[0-9A-Fa-f]{6}"$/);
if (!isValid && colorVal !== "") {
errors.push({
file: filePath,
line: colorLineIdx + 1,
msg: `color must be double-quoted hex (e.g. "#DC2626"), got: ${colorVal}`,
});
const isBareHex = colorVal.match(/^#[0-9A-Fa-f]{6}$/);
if (FIX_MODE && isBareHex) {
allLines[colorLineIdx] = `color: "${colorVal}"`;
fs.writeFileSync(filePath, allLines.join("\n"), "utf8");
fixes.push({ file: filePath, msg: `Quoted color: ${colorVal}` });
}
}
}
}
const fm = parseYamlFrontmatter(content);
if (!fm) {
errors.push({ file: filePath, msg: "Failed to parse YAML frontmatter" });
return;
}
// Check for duplicate keys ONLY inside frontmatter
const rawKeys = [];
for (let i = 0; i < fmEnd; i++) {
const l = fmLines[i];
if (l.match(/^(\w[\w-]*)\s*:/)) {
const key = l.match(/^(\w[\w-]*)\s*:/)[1];
if (rawKeys.includes(key)) {
errors.push({
file: filePath,
line: i + 1,
msg: `Duplicate key: ${key}`,
});
}
rawKeys.push(key);
}
}
// mode
if (fm.mode) {
const modeLineIdx = fmLines.findIndex((l) => l.match(/^mode\s*:/)) + 1;
if (!VALID_MODES.has(fm.mode)) {
errors.push({
file: filePath,
line: modeLineIdx,
msg: `Invalid mode: "${fm.mode}". Must be "subagent" or "all"`,
});
}
}
// model
if (fm.model) {
const modelLineIdx = fmLines.findIndex((l) => l.match(/^model\s*:/)) + 1;
if (!fm.model.includes("/")) {
errors.push({
file: filePath,
line: modelLineIdx,
msg: `Model must include provider prefix, got: "${fm.model}"`,
});
}
}
// description
if (!fm.description || fm.description.trim() === "") {
const descLineIdx = fmLines.findIndex((l) => l.match(/^description\s*:/)) + 1;
errors.push({
file: filePath,
line: descLineIdx,
msg: "description is required and must be non-empty",
});
}
// permissions - check block exists with at least some permissions
if (fm.permission) {
// Only warn about missing keys, don't error - some agents are intentionally limited
const RECOMMENDED_KEYS = ["read", "edit", "write", "bash", "glob", "grep", "task"];
const missing = RECOMMENDED_KEYS.filter((k) => !(k in fm.permission));
if (missing.length > 0) {
const permLineIdx = fmLines.findIndex((l) => l.match(/^permission\s*:/)) + 1;
warnings.push({
file: filePath,
line: permLineIdx,
msg: `Missing optional permission keys: ${missing.join(", ")}`,
});
}
} else {
const permLineIdx = fmLines.findIndex((l) => l.match(/^permission\s*:/)) + 1;
errors.push({
file: filePath,
line: permLineIdx,
msg: "permission block is required",
});
}
if (VERBOSE && errors.length === 0 && warnings.length === 0) {
log(` OK ${path.relative(ROOT, filePath)}`);
}
}
// Main
const validModels = loadValidModels();
const worktrees = findWorktrees();
let totalFiles = 0;
function scanAgents(dirPath, label) {
const files = globMd(dirPath).filter((f) =>
f.includes(path.sep + "agents" + path.sep)
);
if (!files.length) return;
if (VERBOSE) log(`\n[${label}]`);
for (const file of files) {
totalFiles++;
validateAgentFile(file, validModels);
}
}
for (const d of AGENT_DIRS) {
scanAgents(path.join(ROOT, d), "MAIN");
}
for (const wt of worktrees) {
for (const d of AGENT_DIRS) {
scanAgents(path.join(wt, d), `WORKTREE:${path.basename(wt)}`);
}
}
// Report
log("\n" + "=".repeat(60));
log(`Checked ${totalFiles} agent files`);
if (fixes.length > 0) {
log(`\n✅ Auto-fixed ${fixes.length} issue(s):`);
for (const f of fixes) {
log(` ${path.relative(ROOT, f.file)}: ${f.msg}`);
}
}
if (warnings.length > 0) {
log(`\n⚠️ ${warnings.length} warning(s):`);
for (const w of warnings) {
const rel = path.relative(ROOT, w.file);
const lineStr = w.line ? `:${w.line}` : "";
log(` ${rel}${lineStr} ${w.msg}`);
}
}
if (errors.length === 0) {
log("\n🎉 All agent frontmatters valid!\n");
process.exit(0);
} else {
log(`\n${errors.length} error(s):\n`);
for (const e of errors) {
const rel = path.relative(ROOT, e.file);
const lineStr = e.line ? `:${e.line}` : "";
log(` ${rel}${lineStr} ${e.msg}`);
}
if (!FIX_MODE && errors.some((e) => e.msg.includes("color"))) {
log("\n💡 Run with --fix to auto-quote colors.\n");
}
log("");
process.exit(1);
}