@supersuit/superskill 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 +18 -0
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/SPEC.md +239 -0
- package/bin/superskill.mjs +56 -0
- package/package.json +15 -0
- package/src/args.mjs +30 -0
- package/src/changed.mjs +52 -0
- package/src/collection.mjs +139 -0
- package/src/commands/approve.mjs +41 -0
- package/src/commands/collection.mjs +56 -0
- package/src/commands/common.mjs +19 -0
- package/src/commands/doctor.mjs +63 -0
- package/src/commands/fix.mjs +29 -0
- package/src/commands/import.mjs +42 -0
- package/src/commands/init.mjs +74 -0
- package/src/commands/miss.mjs +24 -0
- package/src/commands/snippet.mjs +14 -0
- package/src/context.mjs +60 -0
- package/src/doctor.mjs +65 -0
- package/src/evals.mjs +44 -0
- package/src/frontmatter.mjs +109 -0
- package/src/goldens.mjs +34 -0
- package/src/ledger.mjs +44 -0
- package/src/levels.mjs +38 -0
- package/src/misses.mjs +88 -0
- package/src/report.mjs +27 -0
- package/src/rules/define.mjs +9 -0
- package/src/rules/index.mjs +7 -0
- package/src/rules/interop.mjs +13 -0
- package/src/rules/skill.mjs +359 -0
- package/src/rules/superskill.mjs +113 -0
- package/src/rules/tested.mjs +52 -0
- package/src/run/claude.mjs +36 -0
- package/src/run/codex.mjs +32 -0
- package/src/run/fake.mjs +28 -0
- package/src/run/grade.mjs +56 -0
- package/src/run/index.mjs +130 -0
- package/src/run/workspace.mjs +25 -0
- package/src/session.mjs +34 -0
- package/src/snippet.md +16 -0
package/src/evals.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Readers for evals/evals.json and evals/triggers.json. Both follow Anthropic's
|
|
2
|
+
// skill-creator formats: evals.json is {skill_name, evals: [{id, prompt, expected_output,
|
|
3
|
+
// files, expectations}]}; triggers.json is [{query, should_trigger}]. On read we also
|
|
4
|
+
// accept `assertions` for `expectations` and a bare array for evals.json.
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
function readJson(path) {
|
|
9
|
+
if (!existsSync(path)) return { missing: true };
|
|
10
|
+
try { return { value: JSON.parse(readFileSync(path, "utf8")) }; }
|
|
11
|
+
catch (e) { return { error: `${e.message}` }; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readEvals(dir) {
|
|
15
|
+
const r = readJson(join(dir, "evals", "evals.json"));
|
|
16
|
+
if (r.missing) return { missing: true, cases: [] };
|
|
17
|
+
if (r.error) return { error: `evals/evals.json is not valid JSON: ${r.error}`, cases: [] };
|
|
18
|
+
const list = Array.isArray(r.value) ? r.value : Array.isArray(r.value?.evals) ? r.value.evals : null;
|
|
19
|
+
if (!list) return { error: "evals/evals.json has no evals array", cases: [] };
|
|
20
|
+
const cases = list.map((c, i) => ({
|
|
21
|
+
id: c?.id ?? i + 1,
|
|
22
|
+
prompt: typeof c?.prompt === "string" ? c.prompt : "",
|
|
23
|
+
expected_output: c?.expected_output ?? "",
|
|
24
|
+
files: Array.isArray(c?.files) ? c.files : [],
|
|
25
|
+
assertions: Array.isArray(c?.expectations) ? c.expectations : Array.isArray(c?.assertions) ? c.assertions : [],
|
|
26
|
+
}));
|
|
27
|
+
return { cases, skill_name: r.value?.skill_name };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readTriggers(dir) {
|
|
31
|
+
const r = readJson(join(dir, "evals", "triggers.json"));
|
|
32
|
+
if (r.missing) return { missing: true, triggers: [] };
|
|
33
|
+
if (r.error) return { error: `evals/triggers.json is not valid JSON: ${r.error}`, triggers: [] };
|
|
34
|
+
const list = Array.isArray(r.value) ? r.value : Array.isArray(r.value?.triggers) ? r.value.triggers : null;
|
|
35
|
+
if (!list) return { error: "evals/triggers.json is not a list of {query, should_trigger}", triggers: [] };
|
|
36
|
+
return { triggers: list.filter((t) => t && typeof t.query === "string").map((t) => ({ query: t.query, should_trigger: t.should_trigger === true })) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readLatestRun(dir) {
|
|
40
|
+
const r = readJson(join(dir, "evals", "results", "latest.json"));
|
|
41
|
+
if (r.missing) return { missing: true };
|
|
42
|
+
if (r.error) return { error: `evals/results/latest.json is not valid JSON: ${r.error}` };
|
|
43
|
+
return { run: r.value };
|
|
44
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// A small YAML frontmatter reader for the subset SKILL.md files use: scalars, quoted
|
|
2
|
+
// strings, folded (>) and literal (|) blocks, one-level maps, inline and block lists.
|
|
3
|
+
// Values stay strings; rules decide what a string means. Zero dependencies on purpose.
|
|
4
|
+
|
|
5
|
+
export function parseSkillFile(text) {
|
|
6
|
+
const src = String(text).replace(/\r\n?/g, "\n");
|
|
7
|
+
const lines = src.split("\n");
|
|
8
|
+
if (lines[0].trim() !== "---") return { data: {}, body: src, bodyStartLine: 1, error: "no frontmatter" };
|
|
9
|
+
let end = -1;
|
|
10
|
+
for (let i = 1; i < lines.length; i++) {
|
|
11
|
+
if (lines[i].trim() === "---") { end = i; break; }
|
|
12
|
+
}
|
|
13
|
+
if (end === -1) return { data: {}, body: "", bodyStartLine: 1, error: "unterminated frontmatter" };
|
|
14
|
+
let data;
|
|
15
|
+
try {
|
|
16
|
+
data = parseYamlSubset(lines.slice(1, end));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return { data: {}, body: lines.slice(end + 1).join("\n"), bodyStartLine: end + 2, error: `frontmatter: ${e.message}` };
|
|
19
|
+
}
|
|
20
|
+
return { data, body: lines.slice(end + 1).join("\n"), bodyStartLine: end + 2 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
24
|
+
|
|
25
|
+
export function parseYamlSubset(lines) {
|
|
26
|
+
const out = {};
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < lines.length) {
|
|
29
|
+
const line = lines[i];
|
|
30
|
+
if (!line.trim() || line.trimStart().startsWith("#")) { i++; continue; }
|
|
31
|
+
if (indentOf(line) > 0) { i++; continue; } // stray indented line: tolerate
|
|
32
|
+
const m = line.match(/^([^:\s][^:]*?)\s*:(?:\s+(.*)|\s*)$/);
|
|
33
|
+
if (!m) throw new Error(`cannot read line ${i + 1}: ${line.slice(0, 60)}`);
|
|
34
|
+
const key = m[1].trim().replace(/^["']|["']$/g, "");
|
|
35
|
+
const rest = (m[2] ?? "").trim();
|
|
36
|
+
i++;
|
|
37
|
+
if (/^[>|][+-]?$/.test(rest)) {
|
|
38
|
+
const block = [];
|
|
39
|
+
while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > 0)) { block.push(lines[i]); i++; }
|
|
40
|
+
while (block.length && !block[block.length - 1].trim()) block.pop();
|
|
41
|
+
const min = Math.min(...block.filter((l) => l.trim()).map(indentOf));
|
|
42
|
+
const stripped = block.map((l) => l.slice(Number.isFinite(min) ? min : 0));
|
|
43
|
+
out[key] = rest[0] === "|" ? stripped.join("\n") : foldLines(stripped);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (rest === "") {
|
|
47
|
+
const child = [];
|
|
48
|
+
while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > 0 || lines[i].trimStart().startsWith("- "))) {
|
|
49
|
+
if (indentOf(lines[i]) === 0 && lines[i].trim() && !lines[i].startsWith("- ")) break;
|
|
50
|
+
child.push(lines[i]); i++;
|
|
51
|
+
}
|
|
52
|
+
const items = child.filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
53
|
+
if (!items.length) out[key] = "";
|
|
54
|
+
else if (items[0].trim().startsWith("- ") || items[0].trim() === "-") out[key] = items.map((l) => scalar(l.trim().replace(/^-\s*/, "")));
|
|
55
|
+
else {
|
|
56
|
+
const map = {};
|
|
57
|
+
const base = indentOf(items[0]);
|
|
58
|
+
for (const l of items) {
|
|
59
|
+
if (indentOf(l) !== base) continue; // deeper nesting is outside the subset; ignored
|
|
60
|
+
const mm = l.trim().match(/^([^:]+?)\s*:\s*(.*)$/);
|
|
61
|
+
if (mm) map[mm[1].trim().replace(/^["']|["']$/g, "")] = scalar(mm[2]);
|
|
62
|
+
}
|
|
63
|
+
out[key] = map;
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (rest.startsWith("[") && rest.endsWith("]")) {
|
|
68
|
+
out[key] = splitInline(rest.slice(1, -1)).map(scalar).filter((s) => s !== "");
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out[key] = scalar(rest);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function foldLines(lines) {
|
|
77
|
+
let s = "";
|
|
78
|
+
for (const l of lines) {
|
|
79
|
+
if (!l.trim()) s += "\n";
|
|
80
|
+
else s += (s && !s.endsWith("\n") ? " " : "") + l.trim();
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitInline(s) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
let cur = "", q = null;
|
|
88
|
+
for (const ch of s) {
|
|
89
|
+
if (q) { cur += ch; if (ch === q) q = null; }
|
|
90
|
+
else if (ch === '"' || ch === "'") { q = ch; cur += ch; }
|
|
91
|
+
else if (ch === ",") { parts.push(cur); cur = ""; }
|
|
92
|
+
else cur += ch;
|
|
93
|
+
}
|
|
94
|
+
parts.push(cur);
|
|
95
|
+
return parts.map((p) => p.trim());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scalar(raw) {
|
|
99
|
+
const s = String(raw).trim();
|
|
100
|
+
if (s.startsWith('"')) {
|
|
101
|
+
const end = s.lastIndexOf('"');
|
|
102
|
+
try { return JSON.parse(s.slice(0, end + 1)); } catch { return s.slice(1, end > 0 ? end : undefined); }
|
|
103
|
+
}
|
|
104
|
+
if (s.startsWith("'")) {
|
|
105
|
+
const end = s.lastIndexOf("'");
|
|
106
|
+
return s.slice(1, end > 0 ? end : undefined).replace(/''/g, "'");
|
|
107
|
+
}
|
|
108
|
+
return s.replace(/\s+#.*$/, "");
|
|
109
|
+
}
|
package/src/goldens.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// goldens/<id>/: input.md, the approved output (output.md, or any other non-input file),
|
|
2
|
+
// and APPROVAL.json {approved_by, approved_at, skill_sha, note} written by a person.
|
|
3
|
+
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function readGoldens(dir) {
|
|
7
|
+
const root = join(dir, "goldens");
|
|
8
|
+
if (!existsSync(root)) return [];
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const id of readdirSync(root).sort()) {
|
|
11
|
+
const gdir = join(root, id);
|
|
12
|
+
try { if (!statSync(gdir).isDirectory()) continue; } catch { continue; }
|
|
13
|
+
const files = readdirSync(gdir);
|
|
14
|
+
const inputName = files.find((f) => /^input\./i.test(f));
|
|
15
|
+
const outputName = files.find((f) => /^output\./i.test(f)) || files.find((f) => f !== inputName && f !== "APPROVAL.json" && !f.startsWith("."));
|
|
16
|
+
let approval = null, approvalError = null;
|
|
17
|
+
if (files.includes("APPROVAL.json")) {
|
|
18
|
+
try { approval = JSON.parse(readFileSync(join(gdir, "APPROVAL.json"), "utf8")); }
|
|
19
|
+
catch (e) { approvalError = e.message; }
|
|
20
|
+
}
|
|
21
|
+
out.push({
|
|
22
|
+
id,
|
|
23
|
+
dir: gdir,
|
|
24
|
+
input: inputName ? readFileSync(join(gdir, inputName), "utf8") : null,
|
|
25
|
+
outputFile: outputName || null,
|
|
26
|
+
output: outputName ? readFileSync(join(gdir, outputName), "utf8") : null,
|
|
27
|
+
approval,
|
|
28
|
+
approvalError,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const isApproved = (g) => Boolean(g.approval && typeof g.approval.approved_by === "string" && g.approval.approved_by.trim() && g.approval.approved_at && !Number.isNaN(new Date(g.approval.approved_at).getTime()));
|
package/src/ledger.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Freedom's run ledger, read as plain files. Freedom records every skill run; a run that
|
|
2
|
+
// needed a correction, a rescue or a redirect, or that failed, is a miss the skill owes a fix
|
|
3
|
+
// for. Taste corrections are the operator's preference, not the skill's defect, and are skipped.
|
|
4
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const MISS_KINDS = new Set(["redirect", "correction", "rescue"]);
|
|
9
|
+
|
|
10
|
+
/** Ledger files that may hold runs of this skill. */
|
|
11
|
+
export function ledgerPaths(skillDir, skillName) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const local = join(skillDir, "invocations.jsonl");
|
|
14
|
+
if (existsSync(local)) out.push(local);
|
|
15
|
+
const root = join(homedir(), ".freedom", "ledger", "skills");
|
|
16
|
+
if (existsSync(root) && skillName) {
|
|
17
|
+
for (const plugin of readdirSync(root).sort()) {
|
|
18
|
+
const p = join(root, plugin, `${skillName}.jsonl`);
|
|
19
|
+
if (existsSync(p)) out.push(p);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Misses to import from one ledger file, skipping ledger ids already in `known`. */
|
|
26
|
+
export function ledgerMisses(path, known, skillName) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
29
|
+
if (!line.trim()) continue;
|
|
30
|
+
let rec;
|
|
31
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
32
|
+
if (!rec || !rec.id || known.has(rec.id)) continue;
|
|
33
|
+
if (skillName && rec.skill && rec.skill !== skillName) continue;
|
|
34
|
+
const kinds = (Array.isArray(rec.interventions) ? rec.interventions : []).filter((i) => i && MISS_KINDS.has(i.kind));
|
|
35
|
+
const failed = rec.outcome === "failed";
|
|
36
|
+
if (!kinds.length && !failed) continue;
|
|
37
|
+
const parts = kinds.map((i) => `${i.kind}${i.note || i.what ? `: ${i.note || i.what}` : ""}`);
|
|
38
|
+
if (failed) parts.unshift(`run failed${Array.isArray(rec.errors) && rec.errors.length ? ` (${rec.errors.slice(0, 2).join("; ")})` : ""}`);
|
|
39
|
+
const date = typeof rec.started === "string" && /^\d{4}-\d{2}-\d{2}/.test(rec.started) ? rec.started.slice(0, 10) : null;
|
|
40
|
+
out.push({ date, status: "open", what: parts.join("; ").replace(/\s+/g, " ").slice(0, 300), expected: "", source: `freedom-ledger ${rec.id}` });
|
|
41
|
+
known.add(rec.id);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/src/levels.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const LEVELS = ["skill", "tested", "superskill"];
|
|
2
|
+
|
|
3
|
+
/** Next level up from a reached level, or null at the top. */
|
|
4
|
+
export function nextLevel(level) {
|
|
5
|
+
if (level === "none") return "skill";
|
|
6
|
+
const i = LEVELS.indexOf(level);
|
|
7
|
+
return i >= 0 && i < LEVELS.length - 1 ? LEVELS[i + 1] : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** True when `reached` is at or above `target`. */
|
|
11
|
+
export function meets(reached, target) {
|
|
12
|
+
return reached !== "none" && LEVELS.indexOf(reached) >= LEVELS.indexOf(target);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A level is reached when it and every level below it have no `fail` finding. */
|
|
16
|
+
export function computeLevel(findings) {
|
|
17
|
+
let reached = "none";
|
|
18
|
+
for (const level of LEVELS) {
|
|
19
|
+
if (findings.some((f) => f.level === level && f.severity === "fail")) break;
|
|
20
|
+
reached = level;
|
|
21
|
+
}
|
|
22
|
+
return reached;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run rules against a context, tagging every finding with the rule's id and level. */
|
|
26
|
+
export function runRules(ctx, rules, opts = {}) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const r of rules) {
|
|
29
|
+
let found;
|
|
30
|
+
try {
|
|
31
|
+
found = r.check(ctx, opts) || [];
|
|
32
|
+
} catch (e) {
|
|
33
|
+
found = [{ severity: "fail", message: `rule crashed: ${e.message}`, fix: "Report this as a superskill bug." }];
|
|
34
|
+
}
|
|
35
|
+
for (const f of found) out.push({ ...f, rule: f.rule || r.id, level: r.level });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/src/misses.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// MISSES.md: one dated entry per time the skill got something wrong.
|
|
2
|
+
//
|
|
3
|
+
// ## m1 · 2026-09-20 · fixed
|
|
4
|
+
// - What happened: ...
|
|
5
|
+
// - Should have: ...
|
|
6
|
+
// - Fix: <commit sha or note>
|
|
7
|
+
// - Eval: m1
|
|
8
|
+
// - Source: freedom-ledger <id> (optional)
|
|
9
|
+
import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const MISSES_HEADER = `# Misses
|
|
13
|
+
|
|
14
|
+
Every time this skill got something wrong. An open miss older than 14 days blocks the
|
|
15
|
+
superskill level; a fixed miss must name the eval that would catch it again.
|
|
16
|
+
Written by \`superskill miss\` and \`superskill fix\`, and readable by hand.
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const HEAD_RE = /^##\s+(m\d+)\s*[·|\-–]\s*(\d{4}-\d{2}-\d{2})\s*[·|\-–]\s*(open|fixed)\s*$/i;
|
|
20
|
+
const FIELDS = { "what happened": "what", "should have": "expected", fix: "fix", eval: "eval", source: "source" };
|
|
21
|
+
|
|
22
|
+
export function parseMisses(text) {
|
|
23
|
+
const out = [];
|
|
24
|
+
let cur = null;
|
|
25
|
+
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
|
|
26
|
+
lines.forEach((line, i) => {
|
|
27
|
+
const h = line.match(HEAD_RE);
|
|
28
|
+
if (h) {
|
|
29
|
+
cur = { id: h[1].toLowerCase(), date: h[2], status: h[3].toLowerCase(), what: "", expected: "", fix: "", eval: "", source: "", line: i + 1 };
|
|
30
|
+
out.push(cur);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (/^##\s/.test(line)) { cur = null; return; }
|
|
34
|
+
const f = cur && line.match(/^\s*[-*]\s*([A-Za-z ]+?)\s*:\s*(.*)$/);
|
|
35
|
+
if (f) {
|
|
36
|
+
const key = FIELDS[f[1].toLowerCase()];
|
|
37
|
+
if (key) cur[key] = f[2].trim();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readMisses(dir) {
|
|
44
|
+
const p = join(dir, "MISSES.md");
|
|
45
|
+
if (!existsSync(p)) return null;
|
|
46
|
+
return parseMisses(readFileSync(p, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatMiss(m) {
|
|
50
|
+
const lines = [`## ${m.id} · ${m.date} · ${m.status}`, `- What happened: ${m.what}`];
|
|
51
|
+
if (m.expected) lines.push(`- Should have: ${m.expected}`);
|
|
52
|
+
lines.push(`- Fix: ${m.fix || ""}`, `- Eval: ${m.eval || ""}`);
|
|
53
|
+
if (m.source) lines.push(`- Source: ${m.source}`);
|
|
54
|
+
return lines.join("\n") + "\n";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nextMissId(misses) {
|
|
58
|
+
const n = misses.reduce((max, m) => Math.max(max, Number(m.id.slice(1)) || 0), 0);
|
|
59
|
+
return `m${n + 1}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Append entries to MISSES.md, creating it with its header when absent. */
|
|
63
|
+
export function appendMisses(dir, entries) {
|
|
64
|
+
const p = join(dir, "MISSES.md");
|
|
65
|
+
let text = existsSync(p) ? readFileSync(p, "utf8") : MISSES_HEADER;
|
|
66
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
67
|
+
for (const e of entries) text += "\n" + formatMiss(e);
|
|
68
|
+
writeFileSync(p, text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Rewrite one entry in place (status, fix, eval), leaving the rest of the file byte-identical. */
|
|
72
|
+
export function updateMiss(dir, id, patch) {
|
|
73
|
+
const p = join(dir, "MISSES.md");
|
|
74
|
+
const lines = readFileSync(p, "utf8").split("\n");
|
|
75
|
+
const start = lines.findIndex((l) => { const h = l.match(HEAD_RE); return h && h[1].toLowerCase() === id; });
|
|
76
|
+
if (start < 0) return false;
|
|
77
|
+
let end = lines.findIndex((l, i) => i > start && /^##\s/.test(l));
|
|
78
|
+
if (end < 0) end = lines.length;
|
|
79
|
+
const [cur] = parseMisses(lines.slice(start, end).join("\n"));
|
|
80
|
+
const next = { ...cur, ...patch };
|
|
81
|
+
let block = formatMiss(next).replace(/\n$/, "").split("\n");
|
|
82
|
+
// keep any trailing blank lines the original block had
|
|
83
|
+
const tail = [];
|
|
84
|
+
for (let i = end - 1; i > start && lines[i].trim() === ""; i--) tail.push("");
|
|
85
|
+
lines.splice(start, end - start, ...block, ...tail);
|
|
86
|
+
writeFileSync(p, lines.join("\n"));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { nextLevel, LEVELS } from "./levels.mjs";
|
|
2
|
+
import { pluginLines } from "./commands/collection.mjs";
|
|
3
|
+
|
|
4
|
+
/** Human-readable doctor report: level, what to fix now, and the to-do list for the next level. */
|
|
5
|
+
export function formatDoctor(result) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
for (const s of result.skills) {
|
|
8
|
+
lines.push(`${s.name} level: ${s.level}`);
|
|
9
|
+
lines.push(` ${s.path}`);
|
|
10
|
+
// fails never sit at or below the reached level, so they all belong to the to-do list
|
|
11
|
+
const shown = s.findings.filter((f) => f.severity !== "fail");
|
|
12
|
+
for (const f of shown) lines.push(` ${f.severity.padEnd(4)} ${f.rule}: ${f.message}`);
|
|
13
|
+
const up = nextLevel(s.level);
|
|
14
|
+
if (up && s.next.length) {
|
|
15
|
+
lines.push(` to reach ${up}:`);
|
|
16
|
+
for (const f of s.next) lines.push(` - ${f.rule}: ${f.message}\n fix: ${f.fix}`);
|
|
17
|
+
} else if (!up) lines.push(" top level reached");
|
|
18
|
+
lines.push("");
|
|
19
|
+
}
|
|
20
|
+
const counts = Object.fromEntries(["none", ...LEVELS].map((l) => [l, 0]));
|
|
21
|
+
for (const s of result.skills) counts[s.level]++;
|
|
22
|
+
const summary = ["superskill", "tested", "skill", "none"].filter((l) => counts[l]).map((l) => `${counts[l]} ${l}`).join(", ");
|
|
23
|
+
if (result.plugin) lines.push(...pluginLines(result.plugin, result.plugin.superplugin), "");
|
|
24
|
+
lines.push(`${result.skills.length} skill${result.skills.length === 1 ? "" : "s"}: ${summary}. target ${result.target}: ${result.ok ? "met" : "not met"}`);
|
|
25
|
+
return lines.join("\n") + "\n";
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Wrap rule definitions so every finding a check returns names its rule. */
|
|
2
|
+
export function defineRules(rules) {
|
|
3
|
+
return rules.map((r) => ({
|
|
4
|
+
...r,
|
|
5
|
+
check(ctx, opts = {}) {
|
|
6
|
+
return (r.check(ctx, opts) || []).map((x) => ({ rule: r.id, ...x }));
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { skillRules } from "./skill.mjs";
|
|
2
|
+
import { testedRules } from "./tested.mjs";
|
|
3
|
+
import { superskillRules } from "./superskill.mjs";
|
|
4
|
+
import { interopRules } from "./interop.mjs";
|
|
5
|
+
|
|
6
|
+
/** Every rule the doctor runs, in report order. */
|
|
7
|
+
export const allRules = [...skillRules, ...testedRules, ...superskillRules, ...interopRules];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Bonus lines for files other harnesses keep beside a skill. Never required, never a fail.
|
|
2
|
+
import { defineRules } from "./define.mjs";
|
|
3
|
+
|
|
4
|
+
export const interopRules = defineRules([
|
|
5
|
+
{
|
|
6
|
+
id: "workflow-map",
|
|
7
|
+
level: "skill",
|
|
8
|
+
check(ctx) {
|
|
9
|
+
if (ctx.error || !ctx.files.includes("HDSOP.md")) return [];
|
|
10
|
+
return [{ severity: "info", message: "workflow map (HDSOP.md) present", fix: "Nothing to do; this is a bonus." }];
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
]);
|