@supersuit/superskill 0.4.0 → 0.6.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/src/goldens.mjs CHANGED
@@ -1,38 +1,97 @@
1
- // goldens/<id>/: input.md, the approved output (output.md, or any other non-input file),
2
- // and APPROVAL.json written by a person: { approvals: [{approved_by, approved_at, skill_sha,
3
- // rationale, basis, evidence}] }, or the single-approval shape from before 0.3.0.
1
+ // goldens/<id>/: input.md, expectations.json (0.6.0: the checklist a right answer meets,
2
+ // graded by --run), the reference output (output.md, or any other non-input file),
3
+ // APPROVAL.json written by a person: { approvals: [{approved_by, approved_at, skill_sha,
4
+ // rationale, basis, evidence}] } (or the single-approval shape from before 0.3.0),
5
+ // PROVENANCE.json saying where the example came from (0.5.0), and, for an anonymized twin of a
6
+ // private golden, ANONYMIZED.json, the anonymizer's receipt.
7
+ //
8
+ // A golden may also live OUTSIDE the skill, in a private folder the operator keeps
9
+ // (<private>/<skill-name>/<id>/), because a real run's input and output are usually about real
10
+ // people and should not travel with a skill that is shared. The doctor reads both.
4
11
  import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
5
12
  import { join } from "node:path";
6
13
 
7
- export function readGoldens(dir) {
8
- const root = join(dir, "goldens");
9
- if (!existsSync(root)) return [];
14
+ /** Files in a golden folder that describe it rather than being its input or output. */
15
+ export const META_FILES = new Set(["APPROVAL.json", "PROVENANCE.json", "ANONYMIZED.json", "expectations.json"]);
16
+
17
+ /** Where goldens are read from: the skill's own goldens/, then the private folder for its name. */
18
+ export function goldenRoots(dir, { privateGoldens = null, name = null } = {}) {
19
+ const roots = [{ root: join(dir, "goldens"), private: false }];
20
+ if (privateGoldens && name) roots.push({ root: join(privateGoldens, name), private: true });
21
+ return roots;
22
+ }
23
+
24
+ const readJsonFile = (p) => {
25
+ if (!existsSync(p)) return { value: null, error: null };
26
+ try { return { value: JSON.parse(readFileSync(p, "utf8")), error: null }; }
27
+ catch (e) { return { value: null, error: e.message }; }
28
+ };
29
+
30
+ export function readGoldens(dir, opts = {}) {
10
31
  const out = [];
11
- for (const id of readdirSync(root).sort()) {
12
- const gdir = join(root, id);
13
- try { if (!statSync(gdir).isDirectory()) continue; } catch { continue; }
14
- const files = readdirSync(gdir);
15
- const inputName = files.find((f) => /^input\./i.test(f));
16
- const outputName = files.find((f) => /^output\./i.test(f)) || files.find((f) => f !== inputName && f !== "APPROVAL.json" && !f.startsWith("."));
17
- let approval = null, approvalError = null;
18
- if (files.includes("APPROVAL.json")) {
19
- try { approval = JSON.parse(readFileSync(join(gdir, "APPROVAL.json"), "utf8")); }
20
- catch (e) { approvalError = e.message; }
32
+ for (const { root, private: priv } of goldenRoots(dir, opts)) {
33
+ if (!existsSync(root)) continue;
34
+ for (const id of readdirSync(root).sort()) {
35
+ const gdir = join(root, id);
36
+ try { if (!statSync(gdir).isDirectory()) continue; } catch { continue; }
37
+ const files = readdirSync(gdir).filter((f) => { try { return statSync(join(gdir, f)).isFile(); } catch { return false; } });
38
+ const inputName = files.find((f) => /^input\./i.test(f));
39
+ const outputName = files.find((f) => /^output\./i.test(f)) || files.find((f) => f !== inputName && !META_FILES.has(f) && !f.startsWith("."));
40
+ const appr = readJsonFile(join(gdir, "APPROVAL.json"));
41
+ const prov = readJsonFile(join(gdir, "PROVENANCE.json"));
42
+ const anon = readJsonFile(join(gdir, "ANONYMIZED.json"));
43
+ const provenance = prov.value;
44
+ const exp = readJsonFile(join(gdir, "expectations.json"));
45
+ const expList = Array.isArray(exp.value) ? exp.value : Array.isArray(exp.value?.expectations) ? exp.value.expectations : [];
46
+ out.push({
47
+ id,
48
+ dir: gdir,
49
+ private: priv,
50
+ input: inputName ? readFileSync(join(gdir, inputName), "utf8") : null,
51
+ outputFile: outputName || null,
52
+ output: outputName ? readFileSync(join(gdir, outputName), "utf8") : null,
53
+ approval: appr.value,
54
+ approvals: approvalsOf(appr.value),
55
+ approvalError: appr.error,
56
+ provenance,
57
+ provenanceError: prov.error,
58
+ anonymized: anon.value,
59
+ origin: originOf(provenance, anon.value),
60
+ // 0.6.0: what a right answer does, graded by --run. output.md is the reference that
61
+ // proves the task is solvable; it is no longer what the output has to look like.
62
+ expectations: expList.filter((x) => typeof x === "string" && x.trim()),
63
+ expectationsError: exp.error,
64
+ });
21
65
  }
22
- out.push({
23
- id,
24
- dir: gdir,
25
- input: inputName ? readFileSync(join(gdir, inputName), "utf8") : null,
26
- outputFile: outputName || null,
27
- output: outputName ? readFileSync(join(gdir, outputName), "utf8") : null,
28
- approval,
29
- approvals: approvalsOf(approval),
30
- approvalError,
31
- });
32
66
  }
33
67
  return out;
34
68
  }
35
69
 
70
+ /** Where a golden's example came from. `real-run` is the only source that can reach superskill. */
71
+ export const SOURCES = ["real-run", "synthetic", "synthetic-reconstruction"];
72
+
73
+ /**
74
+ * Is this golden a real run a person accepted, and if not, why not?
75
+ *
76
+ * A real run names the run it came from (a session, a commit, a ledger id, or, for an
77
+ * anonymized twin, `derived_from`: a hash of the private original, never its content) and the
78
+ * person who accepted the output when it happened, and when. An anonymized twin must also carry
79
+ * the anonymizer's receipt, because "the original was accepted" and "this twin still says the
80
+ * same thing" are different claims.
81
+ */
82
+ export function originOf(prov, receipt = null) {
83
+ if (!prov || typeof prov !== "object") return { real: false, why: "no PROVENANCE.json" };
84
+ if (prov.source !== "real-run") return { real: false, why: `source is ${JSON.stringify(prov.source ?? null)}, not "real-run"` };
85
+ const run = prov.run && typeof prov.run === "object" ? prov.run : {};
86
+ const ref = ["session", "commit", "ledger_id"].map((k) => run[k]).concat(prov.derived_from).find((x) => typeof x === "string" && x.trim());
87
+ if (!ref) return { real: false, why: "names no run (run.session, run.commit, run.ledger_id or derived_from)" };
88
+ const acc = prov.accepted && typeof prov.accepted === "object" ? prov.accepted : {};
89
+ if (!(typeof acc.by === "string" && acc.by.trim())) return { real: false, why: "names no person who accepted the run (accepted.by)" };
90
+ if (!validDate(acc.at)) return { real: false, why: "has no valid accepted.at" };
91
+ if (prov.anonymized === true && !(receipt && typeof receipt === "object" && receipt.fingerprint)) return { real: false, why: "is an anonymized twin with no ANONYMIZED.json receipt" };
92
+ return { real: true, why: "" };
93
+ }
94
+
36
95
  /** What an approval rests on. `judgment`: the people who approved it read it and said it is right.
37
96
  * `outcome`: it produced a result in the world someone can check (a client landed, a call booked).
38
97
  * Being liked and being proven are different weights, and a golden says which it carries. */
@@ -82,3 +141,12 @@ export function withApproval(existing, entry) {
82
141
  }
83
142
 
84
143
  export const isApproved = (g) => approvalsOf(g.approval).length > 0;
144
+
145
+ /** Approved AND from a real run a person accepted: the only golden that counts for superskill. */
146
+ export const isRealApproved = (g) => isApproved(g) && (g.origin || originOf(g.provenance, g.anonymized)).real;
147
+
148
+ /** The options readGoldens needs for a loaded skill: its name and the private folder, if any. */
149
+ export const goldenOpts = (ctx, opts = {}) => ({
150
+ name: (typeof ctx.data?.name === "string" && ctx.data.name) || ctx.folderName,
151
+ privateGoldens: opts.privateGoldens ?? ctx.privateGoldens ?? process.env.SUPERSKILL_PRIVATE_GOLDENS ?? null,
152
+ });
package/src/ledger.mjs CHANGED
@@ -12,7 +12,10 @@ export function ledgerPaths(skillDir, skillName) {
12
12
  const out = [];
13
13
  const local = join(skillDir, "invocations.jsonl");
14
14
  if (existsSync(local)) out.push(local);
15
- const root = join(homedir(), ".freedom", "ledger", "skills");
15
+ // FREEDOM_SKILL_LEDGER_HOME is where Freedom itself writes when it is re-pointed (tests, a
16
+ // second profile); read the same place it writes.
17
+ const home = process.env.FREEDOM_SKILL_LEDGER_HOME || join(homedir(), ".freedom", "ledger");
18
+ const root = join(home, "skills");
16
19
  if (existsSync(root) && skillName) {
17
20
  for (const plugin of readdirSync(root).sort()) {
18
21
  const p = join(root, plugin, `${skillName}.jsonl`);
@@ -30,15 +33,48 @@ export function ledgerMisses(path, known, skillName) {
30
33
  let rec;
31
34
  try { rec = JSON.parse(line); } catch { continue; }
32
35
  if (!rec || !rec.id || known.has(rec.id)) continue;
33
- if (skillName && rec.skill && rec.skill !== skillName) continue;
36
+ // A sandbox run (superskill doctor --run) is a test of the skill, not a use of it.
37
+ if (rec.synthetic) continue;
38
+ if (skillName && rec.skill && bare(rec.skill) !== bare(skillName)) continue;
34
39
  const kinds = (Array.isArray(rec.interventions) ? rec.interventions : []).filter((i) => i && MISS_KINDS.has(i.kind));
35
40
  const failed = rec.outcome === "failed";
36
41
  if (!kinds.length && !failed) continue;
37
42
  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("; ")})` : ""}`);
43
+ if (failed) parts.unshift(`run failed${Array.isArray(rec.errors) && rec.errors.length ? ` (${rec.errors.slice(0, 2).map((e) => (typeof e === "string" ? e : e?.kind || "error")).join("; ")})` : ""}`);
39
44
  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}` });
45
+ // A correction made in the operator's NEXT message is the best "should have" there is, and the
46
+ // ledger keeps only where it is, never its words: point at it, so the fixer reads it there.
47
+ const ref = rec.next_turn_ref && rec.session_id ? ` (the correction is the operator's message in session ${String(rec.session_id).slice(0, 8)} at ${rec.next_turn_ref.at || "?"}${Number.isFinite(rec.next_turn_ref.offset) ? `, transcript byte ${rec.next_turn_ref.offset}` : ""})` : "";
48
+ const expected = rec.corrected_after ? `what the operator asked for instead${ref}` : "";
49
+ out.push({ date, status: "open", what: parts.join("; ").replace(/\s+/g, " ").slice(0, 300), expected, source: `freedom-ledger ${rec.id}` });
41
50
  known.add(rec.id);
42
51
  }
43
52
  return out;
44
53
  }
54
+
55
+ const bare = (name) => String(name || "").split(":").pop();
56
+
57
+ /**
58
+ * How many runs the person accepted, from Freedom's `next_turn` verdict (the class of the first
59
+ * message after a run handed back). Runs with no verdict are left out of both sides, so a missing
60
+ * record can never read as an acceptance, and sandbox runs never count.
61
+ */
62
+ export function acceptedRate(paths, { now = new Date(), days = 30 } = {}) {
63
+ const since = now.getTime() - days * 86400000;
64
+ let judged = 0, accepted = 0, synthetic = 0;
65
+ for (const p of paths) {
66
+ let text = "";
67
+ try { text = readFileSync(p, "utf8"); } catch { continue; }
68
+ for (const line of text.split("\n")) {
69
+ if (!line.trim()) continue;
70
+ let rec; try { rec = JSON.parse(line); } catch { continue; }
71
+ const t = Date.parse(rec?.started || "");
72
+ if (!Number.isFinite(t) || t < since || t > now.getTime()) continue;
73
+ if (rec.synthetic) { synthetic++; continue; }
74
+ if (!rec.next_turn || rec.next_turn === "none") continue;
75
+ judged++;
76
+ if (["close", "go", "new_topic"].includes(rec.next_turn) && !rec.corrected_after && !["failed", "abandoned"].includes(rec.outcome)) accepted++;
77
+ }
78
+ }
79
+ return { judged, accepted, synthetic };
80
+ }
package/src/misses.mjs CHANGED
@@ -1,41 +1,49 @@
1
- // MISSES.md: one dated entry per time the skill got something wrong.
1
+ // MISSES.md: one dated entry per time the skill got something wrong. It is also where a
2
+ // skill's history lives (0.6.0): the story of the incident that earned a rule goes here, and
3
+ // SKILL.md keeps only the rule and a one-line why.
2
4
  //
3
5
  // ## m1 · 2026-09-20 · fixed
4
- // - What happened: ...
6
+ // - What happened: ... (an indented line under a field continues it)
5
7
  // - Should have: ...
6
- // - Fix: <commit sha or note>
8
+ // - Fix: <commit sha or note: the rule now in SKILL.md>
7
9
  // - Eval: m1
10
+ // - Quote: "what the person said" (optional)
8
11
  // - Source: freedom-ledger <id> (optional)
9
12
  import { readFileSync, existsSync, writeFileSync } from "node:fs";
10
13
  import { join } from "node:path";
11
14
 
12
15
  export const MISSES_HEADER = `# Misses
13
16
 
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.
17
+ Every time this skill got something wrong, and the story behind each rule it learned. An open
18
+ miss blocks the superskill level; a fixed miss must name the eval that would catch it again.
16
19
  Written by \`superskill miss\` and \`superskill fix\`, and readable by hand.
17
20
  `;
18
21
 
19
22
  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" };
23
+ const FIELDS = { "what happened": "what", "should have": "expected", fix: "fix", eval: "eval", quote: "quote", source: "source" };
21
24
 
22
25
  export function parseMisses(text) {
23
26
  const out = [];
24
27
  let cur = null;
28
+ let last = null;
25
29
  const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
26
30
  lines.forEach((line, i) => {
27
31
  const h = line.match(HEAD_RE);
28
32
  if (h) {
29
- cur = { id: h[1].toLowerCase(), date: h[2], status: h[3].toLowerCase(), what: "", expected: "", fix: "", eval: "", source: "", line: i + 1 };
33
+ cur = { id: h[1].toLowerCase(), date: h[2], status: h[3].toLowerCase(), what: "", expected: "", fix: "", eval: "", quote: "", source: "", line: i + 1 };
34
+ last = null;
30
35
  out.push(cur);
31
36
  return;
32
37
  }
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
- }
38
+ if (/^##\s/.test(line)) { cur = null; last = null; return; }
39
+ if (!cur) return;
40
+ const f = line.match(/^\s*[-*]\s*([A-Za-z ]+?)\s*:\s*(.*)$/);
41
+ const key = f && FIELDS[f[1].toLowerCase()];
42
+ if (key) { cur[key] = f[2].trim(); last = key; return; }
43
+ // A story rarely fits on one line: an indented line continues the field above it.
44
+ if (last && /^\s{2,}\S/.test(line)) { cur[last] = `${cur[last]} ${line.trim()}`.trim(); return; }
45
+ if (!line.trim()) return;
46
+ last = null;
39
47
  });
40
48
  return out;
41
49
  }
@@ -50,6 +58,7 @@ export function formatMiss(m) {
50
58
  const lines = [`## ${m.id} · ${m.date} · ${m.status}`, `- What happened: ${m.what}`];
51
59
  if (m.expected) lines.push(`- Should have: ${m.expected}`);
52
60
  lines.push(`- Fix: ${m.fix || ""}`, `- Eval: ${m.eval || ""}`);
61
+ if (m.quote) lines.push(`- Quote: ${m.quote}`);
53
62
  if (m.source) lines.push(`- Source: ${m.source}`);
54
63
  return lines.join("\n") + "\n";
55
64
  }
@@ -0,0 +1,122 @@
1
+ // The real-run record (0.6.0): how often the skill did the job for a real person with no
2
+ // correction, counted separately for every model and harness it ran on.
3
+ //
4
+ // Harness-neutral on purpose. Any harness, ledger or script can write the file; the doctor only
5
+ // reads it. Format `superskill-real-runs/1`:
6
+ //
7
+ // {
8
+ // "format": "superskill-real-runs/1",
9
+ // "skill": "weekly-status",
10
+ // "generated_at": "2026-10-06T12:00:00Z",
11
+ // "source": "freedom-skill-ledger",
12
+ // "pairs": [
13
+ // { "model": "claude-opus-5-5", "harness": "claude-code", "runs": 12, "one_shot": 11,
14
+ // "first_at": "2026-09-01T09:00:00Z", "last_at": "2026-10-05T18:00:00Z" }
15
+ // ]
16
+ // }
17
+ //
18
+ // A run is one real use: never a sandbox run (`doctor --run`), never a test. It is one-shot when
19
+ // the person needed no correction, rescue or redirect, it did not fail or get abandoned, and it
20
+ // was not corrected after it handed back. A taste note is the person's preference, not a defect,
21
+ // and does not break one-shot.
22
+ //
23
+ // Where the doctor reads it, first found wins:
24
+ // 1. <dir>/<skill-name>.json, where <dir> is --real-runs or SUPERSKILL_REAL_RUNS (an
25
+ // operator's own export, kept out of the skill);
26
+ // 2. <skill>/evals/real-runs.json (counts only, so it can travel with the skill);
27
+ // 3. Freedom's skill ledger, read as plain files, when neither exists.
28
+ import { readFileSync, existsSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { ledgerPaths } from "./ledger.mjs";
31
+
32
+ export const FORMAT = "superskill-real-runs/1";
33
+ export const UNKNOWN = "unknown";
34
+ /** The defaults the real-runs rule holds a skill to. `--min-real-runs` / `--min-one-shot` change them. */
35
+ export const MIN_REAL_RUNS = 5;
36
+ export const MIN_ONE_SHOT = 0.8;
37
+
38
+ const MISS_KINDS = new Set(["redirect", "correction", "rescue"]);
39
+
40
+ /** Is this a known model+harness pair? A record that could not say proves nothing about either. */
41
+ export const knownPair = (p) => Boolean(p.model && p.harness && p.model !== UNKNOWN && p.harness !== UNKNOWN);
42
+
43
+ function normalize(doc, where) {
44
+ if (!doc || typeof doc !== "object") return { error: `${where} is not a JSON object` };
45
+ if (doc.format && doc.format !== FORMAT) return { error: `${where} has format ${JSON.stringify(doc.format)}, not ${FORMAT}` };
46
+ if (!Array.isArray(doc.pairs)) return { error: `${where} has no pairs array` };
47
+ const pairs = doc.pairs
48
+ .filter((p) => p && typeof p === "object")
49
+ .map((p) => ({
50
+ model: String(p.model || UNKNOWN),
51
+ harness: String(p.harness || UNKNOWN),
52
+ runs: Math.max(0, Number(p.runs) || 0),
53
+ one_shot: Math.max(0, Number(p.one_shot) || 0),
54
+ first_at: p.first_at || null,
55
+ last_at: p.last_at || null,
56
+ }))
57
+ .map((p) => ({ ...p, one_shot: Math.min(p.one_shot, p.runs) }));
58
+ return { pairs, generated_at: doc.generated_at || null, source: doc.source || null, where };
59
+ }
60
+
61
+ function readFile(p, where) {
62
+ try { return normalize(JSON.parse(readFileSync(p, "utf8")), where); }
63
+ catch (e) { return { error: `${where} is not valid JSON: ${e.message}` }; }
64
+ }
65
+
66
+ /** Is a ledger record a one-shot run? Exported so a writer can count the same way the reader does. */
67
+ export function isOneShot(rec) {
68
+ const kinds = (Array.isArray(rec?.interventions) ? rec.interventions : []).filter((i) => i && MISS_KINDS.has(i.kind));
69
+ return !kinds.length && !["failed", "abandoned"].includes(rec?.outcome) && !rec?.corrected_after;
70
+ }
71
+
72
+ /** Summarize ledger records (Freedom's shape, or any with model/harness/started) per model+harness. */
73
+ export function pairsFromRecords(records, skillName = null) {
74
+ const by = new Map();
75
+ for (const rec of records) {
76
+ if (!rec || rec.synthetic) continue;
77
+ if (skillName && rec.skill && bare(rec.skill) !== bare(skillName)) continue;
78
+ const model = String(rec.model || UNKNOWN), harness = String(rec.harness || UNKNOWN);
79
+ const k = `${model}\u0000${harness}`;
80
+ const p = by.get(k) || { model, harness, runs: 0, one_shot: 0, first_at: null, last_at: null };
81
+ p.runs++;
82
+ if (isOneShot(rec)) p.one_shot++;
83
+ const t = typeof rec.started === "string" ? rec.started : null;
84
+ if (t && (!p.first_at || t < p.first_at)) p.first_at = t;
85
+ if (t && (!p.last_at || t > p.last_at)) p.last_at = t;
86
+ by.set(k, p);
87
+ }
88
+ return [...by.values()].sort((a, b) => b.runs - a.runs || a.model.localeCompare(b.model));
89
+ }
90
+
91
+ const bare = (name) => String(name || "").split(":").pop();
92
+
93
+ function fromLedger(dir, name) {
94
+ const paths = ledgerPaths(dir, name);
95
+ if (!paths.length) return null;
96
+ const recs = [];
97
+ for (const p of paths) {
98
+ let text = "";
99
+ try { text = readFileSync(p, "utf8"); } catch { continue; }
100
+ for (const line of text.split("\n")) {
101
+ if (!line.trim()) continue;
102
+ try { recs.push(JSON.parse(line)); } catch {}
103
+ }
104
+ }
105
+ return { pairs: pairsFromRecords(recs, name), generated_at: null, source: "freedom-skill-ledger (read live)", where: "Freedom's skill ledger" };
106
+ }
107
+
108
+ /** The real-run record for one skill, or null when nothing records any. */
109
+ export function readRealRuns(dir, { name, realRunsDir = null } = {}) {
110
+ if (realRunsDir && name) {
111
+ const p = join(realRunsDir, `${name}.json`);
112
+ if (existsSync(p)) return readFile(p, `${realRunsDir}/${name}.json`);
113
+ }
114
+ const local = join(dir, "evals", "real-runs.json");
115
+ if (existsSync(local)) return readFile(local, "evals/real-runs.json");
116
+ return fromLedger(dir, name);
117
+ }
118
+
119
+ /** Does any single pair clear the bar? Never pooled: each pair stands or falls on its own runs. */
120
+ export function meetsBar(pairs, { minRuns = MIN_REAL_RUNS, minOneShot = MIN_ONE_SHOT } = {}) {
121
+ return pairs.filter(knownPair).filter((p) => p.runs >= minRuns && p.one_shot / p.runs >= minOneShot);
122
+ }
@@ -26,6 +26,63 @@ function proseLines(body) {
26
26
  return { line, fenced };
27
27
  });
28
28
  }
29
+ // A dated incident story, as written in real skills: "Earned 2026-09-08", "(Gary, 2026-09-16:
30
+ // ...)", "Wilson, live, 2026-09-05: *"...", "on 2026-09-13 the in-process version sat...",
31
+ // "until 2026-09-21 the flag...", "measured on 2026-09-20", "(2026-09-30, #324)",
32
+ // "- 2026-09-15 (#147): ...". Matched per paragraph, because hard-wrapped prose puts the name on
33
+ // one line and its date on the next. Tuned against Freedom's 103 shipped skills (2026-10-06): a
34
+ // date in an example, a template, a code span, a fence, or a provenance line ending at the date
35
+ // ("vendored ... on 2026-09-19.") is not a story.
36
+ const D = "20\\d\\d-\\d\\d-\\d\\d";
37
+ const HISTORY = [
38
+ new RegExp(`\\bearned\\b[^.]{0,24}?\\b(${D})`, "gi"),
39
+ new RegExp(`\\([^()\\n]{0,80}?\\b(${D})\\s*[:),.;—]`, "g"),
40
+ new RegExp(`(?:\\b[A-Z][\\w'-]+|\\b(?:operator|owner|maintainer|client|teammate)),\\s*(?:[a-z]+,\\s*)?(${D})\\s*[,:]`, "g"),
41
+ new RegExp(`\\b(?:on|until|since|before|after)\\s+(${D})\\b`, "gi"),
42
+ new RegExp(`\\bfrom\\s+(${D})\\s+\\(`, "gi"),
43
+ new RegExp(`\\b(?:measured|reported|found|caught|hit|corrected|retired|aligned|renamed|refused|broke|failed|observed|verified|watched|fixed|softened|flipped|restored|removed|changed|added|introduced)\\s+(?:[a-z]+\\s+)?(?:on\\s+|in\\s+)?(${D})`, "gi"),
44
+ new RegExp(`\\b(?:rule|ruling|default|correction|incident|reversal)\\s+(?:of|from)\\s+(${D})`, "gi"),
45
+ new RegExp(`\\bthe\\s+(${D})\\b`, "gi"),
46
+ new RegExp(`(${D})'s\\b`, "g"),
47
+ new RegExp(`^\\s*[-*]\\s+[*_]*(${D})[*_]*\\s*[(:]`, "gm"),
48
+ ];
49
+ const EXAMPLE = /\b(e\.g\.|example|for instance|such as)\b/i;
50
+
51
+ /**
52
+ * Body lines that narrate a dated incident, outside code fences and inline code: one entry per
53
+ * line that carries the date of a story.
54
+ */
55
+ export function historyLines(body) {
56
+ const lines = proseLines(body);
57
+ const hit = new Map();
58
+ let para = [];
59
+ const flush = () => {
60
+ if (!para.length) return;
61
+ // Join the paragraph, remembering where each line starts, so a match maps back to its line.
62
+ let text = "";
63
+ const starts = [];
64
+ for (const { i, line } of para) { starts.push({ i, at: text.length }); text += line.replace(/`[^`]*`/g, "``") + "\n"; }
65
+ for (const re of HISTORY) {
66
+ re.lastIndex = 0;
67
+ for (const m of text.matchAll(re)) {
68
+ const at = m.index + m[0].lastIndexOf(m[1]);
69
+ const owner = [...starts].reverse().find((s) => s.at <= at);
70
+ const raw = lines[owner.i].line;
71
+ if (EXAMPLE.test(raw)) continue;
72
+ if (!hit.has(owner.i)) hit.set(owner.i, raw.trim());
73
+ }
74
+ }
75
+ para = [];
76
+ };
77
+ lines.forEach(({ line, fenced }, i) => {
78
+ // A heading, an HTML comment (a generator's provenance stamp) and a fence are not prose.
79
+ if (fenced || !line.trim() || /^\s*(#|<!--)/.test(line)) { flush(); return; }
80
+ para.push({ i, line });
81
+ });
82
+ flush();
83
+ return [...hit.entries()].sort((a, b) => a[0] - b[0]).map(([i, text]) => ({ lineNo: i + 1, text }));
84
+ }
85
+
29
86
  const normRule = (line) => line.toLowerCase().replace(/[*_`>#-]/g, "").replace(/\s+/g, " ").trim();
30
87
  const broken = (ctx) => Boolean(ctx.error || ctx.parseError);
31
88
  const str = (v) => (typeof v === "string" ? v : "");
@@ -194,6 +251,20 @@ export const skillRules = defineRules([
194
251
  return [f("warn", `${late.length} hard rule(s) sit past the first ~${FOLD_TOKENS} tokens and would not survive compaction (${shown})`, "Restate them in a short Rules section near the top, or move them up. Length is fine; the rules just need to be above the fold. Step-specific detail can move into step files (steps/<step>.md) that are read fresh when the step comes up.")];
195
252
  },
196
253
  },
254
+ {
255
+ // 0.6.0: SKILL.md is instructions, read on every run. The story of the incident that earned
256
+ // a rule is history: it belongs in MISSES.md, under the miss it records, and SKILL.md keeps
257
+ // the rule and a one-line why. Anthropic's authoring guidance: avoid time-sensitive content.
258
+ id: "history-in-skill",
259
+ level: "skill",
260
+ check(ctx) {
261
+ if (broken(ctx)) return [];
262
+ const hits = historyLines(ctx.body);
263
+ if (!hits.length) return [];
264
+ const shown = hits.slice(0, 3).map((h) => `line ${h.lineNo + ctx.bodyStartLine - 1}: ${h.text.slice(0, 70)}`).join("; ");
265
+ return [f("warn", `${hits.length} dated incident stor${hits.length === 1 ? "y" : "ies"} in SKILL.md (${shown})`, "Move each story into MISSES.md as a miss entry (id, date, what happened, fix, eval, optional quote) and leave the rule plus a one-line why in SKILL.md.")];
266
+ },
267
+ },
197
268
  {
198
269
  id: "navigable",
199
270
  level: "skill",