/** * skill-bill — size per level for one agent skill, and its growth since the last diet. * * Levels: the frontmatter description (every session), SKILL.md (every use), its template when it * has one, and each file under sections/ or references/ (read on demand). The last diet is the * newest commit whose message carries a "Skill-Diet: " trailer; growth past DUE_PCT since * then means the skill is due for another diet. Read-only: runs git, never writes. */ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { estimateTokens } from "./context-bill"; export const DUE_PCT = 10; export type SkillKind = "gstack-checkout" | "gstack-install" | "user"; export interface LevelRow { level: string; path: string; bytes: number; tokens: number; generated: boolean } export interface Baseline { status: "none" | "unknown" | "found"; reason?: string; sha?: string; date?: string; bytes?: number; growthPct?: number; due?: boolean } export interface SkillBillReport { name: string; dir: string; kind: SkillKind; authored: string; rows: LevelRow[]; baseline: Baseline; evalTasks?: EvalTasks | null; warnings: string[]; } export interface EvalTasks { path: string; count: number; problems: string[] } const EVAL_TASK_KEYS = new Set(["id", "prompt", "setup", "must", "must_not", "check", "safety"]); /** Problems in a tasks.json against the task format in docs/designs/SKILL_EVAL_CONTRACT.md. */ export function checkEvalTasks(file: string): EvalTasks { let tasks: unknown; try { tasks = JSON.parse(readFileSync(file, "utf-8")); } catch (err) { return { path: file, count: 0, problems: [`not valid JSON: ${(err as Error).message}`] }; } if (!Array.isArray(tasks)) return { path: file, count: 0, problems: ["must be a JSON array of tasks"] }; const problems: string[] = []; const ids = new Set(); for (const t of tasks as Record[]) { if (!t || typeof t !== "object" || Array.isArray(t)) { problems.push(`?: task must be an object, got ${JSON.stringify(t)}`); continue; } const where = String(t.id ?? "?"); for (const k of Object.keys(t ?? {})) if (!EVAL_TASK_KEYS.has(k)) problems.push(`${where}: unknown key ${k}`); if (typeof t?.id !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(t.id)) problems.push(`${where}: id must be kebab-case`); else if (ids.has(t.id)) problems.push(`${where}: duplicate id`); else ids.add(t.id); if (typeof t?.prompt !== "string" || !t.prompt.trim()) problems.push(`${where}: prompt must be a non-empty string`); for (const k of ["setup", "check"]) if (t && k in t && typeof t[k] !== "string") problems.push(`${where}: ${k} must be a string`); for (const k of ["must", "must_not"]) { if (!t || !(k in t)) continue; const v = t[k]; if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) { problems.push(`${where}: ${k} must be a string array`); continue; } for (const re of v) { try { new RegExp(re); } catch { problems.push(`${where}: ${k} pattern /${re}/ does not compile`); } } } if (t && "safety" in t && typeof t.safety !== "boolean") problems.push(`${where}: safety must be a boolean`); if (!t || (!("must" in t) && !("check" in t))) problems.push(`${where}: needs must or check, or nothing can fail it`); if (typeof t?.setup === "string" && t.setup.includes("seed.sh") && !existsSync(join(dirname(file), "seed.sh"))) problems.push(`${where}: setup runs seed.sh but it is missing`); } return { path: file, count: tasks.length, problems }; } export class SkillBillError extends Error { constructor(readonly code: "SKILL_NOT_FOUND" | "SKILL_AMBIGUOUS" | "SKILL_MD_MISSING", message: string, readonly fix: string) { super(message); } } const GENERATED = "