verderer 0.3.0 → 0.4.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/README.md +4 -2
- package/package.json +1 -1
- package/src/cli.js +6 -6
- package/src/ledger.js +42 -11
- package/src/reword.js +71 -0
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ npx verderer probe # the ablation recipe (automation is th
|
|
|
23
23
|
|
|
24
24
|
`scan` is idempotent and append-only: known rules keep their provenance and verdicts, new rules are dated, rules that left the file are marked retired — never deleted. The ledger is the history of your harness's law.
|
|
25
25
|
|
|
26
|
+
Rewording a rule doesn't reset it. A rule edited in place (same spot, mostly the same words, or a trim of them) keeps its provenance and verdict, and its old wording goes into `revisions`. The verdict judged the old wording, so it shows as stale until you re-run the inquest.
|
|
27
|
+
|
|
26
28
|
## Two modes, decided by git
|
|
27
29
|
|
|
28
30
|
Verderer checks how git sees each harness file and adapts:
|
|
@@ -54,13 +56,13 @@ For tools without a detection adapter yet, `verderer checkpoint --model <id>` re
|
|
|
54
56
|
"firstSeen": "2026-03-17",
|
|
55
57
|
"origin": "git:da89e1b",
|
|
56
58
|
"status": "active",
|
|
57
|
-
"lastVerdict": { "date": "2026-10-31", "result": "dead", "model": "claude-fable-5", "evidence": "ablation probe: model derived it from neighboring code" }
|
|
59
|
+
"lastVerdict": { "date": "2026-10-31", "result": "dead", "model": "claude-fable-5", "evidence": "ablation probe: model derived it from neighboring code", "ruleId": "9f2c01ab34de" }
|
|
58
60
|
}
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
Verdicts are three-valued on purpose: **dead** (the model does it unaided), **derivable** (unaided but an expensive miss — keeping it is cheap insurance), **needed**. Blast radius is a judgment call, so verderer reports and you decide; it never auto-deletes a rule.
|
|
62
64
|
|
|
63
|
-
`verderer verdict` records one: it stamps today's date
|
|
65
|
+
`verderer verdict` records one: it stamps today's date, the ledger's current model and the rule's wording, so a model change or a rewording flags it as stale. Evidence is required.
|
|
64
66
|
|
|
65
67
|
## Roadmap
|
|
66
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A rule ledger for AI agent harnesses: per-rule provenance for CLAUDE.md/AGENTS.md, plus ablation inquests that tell you which rules still earn their place after a model upgrade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { scan } from './scan.js';
|
|
3
|
-
import { loadLedger, saveLedger, ledgerPath, staleVerdicts, recordVerdict, isoToday } from './ledger.js';
|
|
3
|
+
import { loadLedger, saveLedger, ledgerPath, staleVerdicts, staleReason, recordVerdict, isoToday } from './ledger.js';
|
|
4
4
|
|
|
5
5
|
const HELP = `verderer — a rule ledger for AI agent harnesses
|
|
6
6
|
|
|
@@ -70,7 +70,7 @@ function cmdScan(dir) {
|
|
|
70
70
|
for (const [source, mode] of sources) {
|
|
71
71
|
console.log(`${source} (${mode})`);
|
|
72
72
|
}
|
|
73
|
-
console.log(`${counts.active} active · ${counts.added} new · ${counts.retired} retired → ${path}`);
|
|
73
|
+
console.log(`${counts.active} active · ${counts.added} new · ${counts.reworded} reworded · ${counts.retired} retired → ${path}`);
|
|
74
74
|
|
|
75
75
|
if (ledger.model) {
|
|
76
76
|
console.log(`model: ${ledger.model.id} (${ledger.model.source})`);
|
|
@@ -79,7 +79,7 @@ function cmdScan(dir) {
|
|
|
79
79
|
console.log(`model changed: ${modelChange.from} → ${modelChange.to}`);
|
|
80
80
|
}
|
|
81
81
|
if (stale.length > 0) {
|
|
82
|
-
console.log(`${stale.length} verdict(s)
|
|
82
|
+
console.log(`${stale.length} stale verdict(s): judged on another model or an earlier wording — re-run their inquests.`);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (sources.some(([, mode]) => mode === 'ignored')) {
|
|
@@ -100,10 +100,10 @@ function cmdStatus(dir) {
|
|
|
100
100
|
const model = ledger.model ? ` · model ${ledger.model.id}` : '';
|
|
101
101
|
console.log(`scanned ${ledger.scannedAt}${model} · ${ledger.rules.length} rules\n`);
|
|
102
102
|
|
|
103
|
-
const stale = new Set(staleVerdicts(ledger).map((r) => r.id));
|
|
104
103
|
for (const rule of ledger.rules) {
|
|
105
104
|
let verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
|
|
106
|
-
|
|
105
|
+
const stale = staleReason(rule, ledger.model?.id);
|
|
106
|
+
if (stale) verdict += ` [stale: ${stale}]`;
|
|
107
107
|
const flag = rule.status === 'retired' ? ' [retired]' : '';
|
|
108
108
|
console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
|
|
109
109
|
console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
|
|
@@ -126,7 +126,7 @@ function cmdCheckpoint(dir, modelId) {
|
|
|
126
126
|
console.log(prior && prior !== modelId ? `model changed: ${prior} → ${modelId}` : `model: ${modelId}`);
|
|
127
127
|
const stale = staleVerdicts(ledger);
|
|
128
128
|
if (stale.length > 0) {
|
|
129
|
-
console.log(`${stale.length} verdict(s)
|
|
129
|
+
console.log(`${stale.length} stale verdict(s): judged on another model or an earlier wording — re-run their inquests.`);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
return 0;
|
package/src/ledger.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
+
import { pairRewordings } from './reword.js';
|
|
3
4
|
|
|
4
5
|
export const LEDGER_VERSION = 1;
|
|
5
6
|
|
|
@@ -21,17 +22,18 @@ export function saveLedger(dir, ledger) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// Merge a fresh scan into the existing ledger.
|
|
24
|
-
// Known rules keep their provenance and verdicts;
|
|
25
|
-
//
|
|
26
|
-
// the file
|
|
25
|
+
// Known rules keep their provenance and verdicts; reworded rules too, with
|
|
26
|
+
// the old wording kept in `revisions`; new ones get firstSeen (from git
|
|
27
|
+
// blame when the file is tracked, else today); rules that left the file are
|
|
28
|
+
// retired, never deleted — the ledger is append-only history.
|
|
27
29
|
export function mergeScan(ledger, scanned, { modes, model, today = isoToday() } = {}) {
|
|
28
30
|
const existing = new Map(ledger.rules.map((r) => [r.id, r]));
|
|
29
|
-
const seen = new Set();
|
|
31
|
+
const seen = new Set(scanned.map((r) => r.id));
|
|
32
|
+
const rewordings = pairRewordings(ledger.rules, scanned);
|
|
30
33
|
const rules = [];
|
|
31
|
-
const counts = { active: 0, added: 0, retired: 0 };
|
|
34
|
+
const counts = { active: 0, added: 0, reworded: 0, retired: 0 };
|
|
32
35
|
|
|
33
36
|
for (const rule of scanned) {
|
|
34
|
-
seen.add(rule.id);
|
|
35
37
|
const prior = existing.get(rule.id);
|
|
36
38
|
|
|
37
39
|
if (prior) {
|
|
@@ -40,6 +42,14 @@ export function mergeScan(ledger, scanned, { modes, model, today = isoToday() }
|
|
|
40
42
|
continue;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
const before = rewordings.get(rule.id);
|
|
46
|
+
if (before) {
|
|
47
|
+
rules.push(reword(before, rule, today));
|
|
48
|
+
seen.add(before.id);
|
|
49
|
+
counts.reworded++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
rules.push({
|
|
44
54
|
...rule,
|
|
45
55
|
status: 'active',
|
|
@@ -63,11 +73,32 @@ export function mergeScan(ledger, scanned, { modes, model, today = isoToday() }
|
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
//
|
|
76
|
+
// The same rule in new words: new id and text, old provenance and verdict.
|
|
77
|
+
// The verdict remembers which wording it judged, so it reads as stale now.
|
|
78
|
+
function reword(before, rule, today) {
|
|
79
|
+
return {
|
|
80
|
+
...before,
|
|
81
|
+
id: rule.id,
|
|
82
|
+
text: rule.text,
|
|
83
|
+
source: rule.source,
|
|
84
|
+
section: rule.section,
|
|
85
|
+
line: rule.line,
|
|
86
|
+
revisions: [...(before.revisions ?? []), { id: before.id, text: before.text, until: today }],
|
|
87
|
+
lastVerdict: before.lastVerdict && { ruleId: before.id, ...before.lastVerdict },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A verdict judged one wording on one model; a change to either makes it stale.
|
|
92
|
+
export function staleReason(rule, modelId) {
|
|
93
|
+
const verdict = rule.lastVerdict;
|
|
94
|
+
if (rule.status !== 'active' || !verdict) return null;
|
|
95
|
+
if (verdict.ruleId && verdict.ruleId !== rule.id) return 'judged an earlier wording';
|
|
96
|
+
if (modelId && verdict.model && verdict.model !== modelId) return `judged on ${verdict.model}`;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
67
100
|
export function staleVerdicts(ledger) {
|
|
68
|
-
|
|
69
|
-
if (!current) return [];
|
|
70
|
-
return ledger.rules.filter((r) => r.status === 'active' && r.lastVerdict?.model && r.lastVerdict.model !== current);
|
|
101
|
+
return ledger.rules.filter((r) => staleReason(r, ledger.model?.id));
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
export const VERDICTS = ['dead', 'derivable', 'needed'];
|
|
@@ -90,7 +121,7 @@ export function recordVerdict(ledger, prefix, result, { evidence, today = isoTod
|
|
|
90
121
|
if (matches.length > 1) throw new Error(`id ${prefix} is ambiguous: ${matches.map((r) => r.id).join(', ')}`);
|
|
91
122
|
|
|
92
123
|
const [rule] = matches;
|
|
93
|
-
const verdict = { date: today, result, model, evidence };
|
|
124
|
+
const verdict = { date: today, result, model, evidence, ruleId: rule.id };
|
|
94
125
|
|
|
95
126
|
return {
|
|
96
127
|
ledger: { ...ledger, rules: ledger.rules.map((r) => (r === rule ? { ...r, lastVerdict: verdict } : r)) },
|
package/src/reword.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// A rule's id is a hash of its text, so rewording a rule looks like one rule
|
|
2
|
+
// leaving and another arriving. Pair them back up so the rule keeps its
|
|
3
|
+
// provenance and verdict.
|
|
4
|
+
//
|
|
5
|
+
// Two checks, both calibrated on real harness files:
|
|
6
|
+
// - same slot: a rewording is an edit in place, so both sit right after the
|
|
7
|
+
// same unchanged rule. This keeps rolling lists (add at top, drop at the
|
|
8
|
+
// bottom) and templated sibling bullets from pairing up.
|
|
9
|
+
// - similar words: Dice over word sets >= 0.4, or a trim that keeps every
|
|
10
|
+
// word of the shorter wording ("ESM only; imports use .js" → "ESM only").
|
|
11
|
+
const MIN_SIMILARITY = 0.4;
|
|
12
|
+
const MIN_SHARED_WORDS = 2;
|
|
13
|
+
|
|
14
|
+
// prior: every rule in the ledger; scanned: the rules in the files now.
|
|
15
|
+
// Returns Map(new id → the prior rule it rewords).
|
|
16
|
+
export function pairRewordings(prior, scanned) {
|
|
17
|
+
const known = new Set(prior.map((r) => r.id));
|
|
18
|
+
const current = new Set(scanned.map((r) => r.id));
|
|
19
|
+
const active = prior.filter((r) => r.status === 'active');
|
|
20
|
+
const unchanged = new Set(active.filter((r) => current.has(r.id)).map((r) => r.id));
|
|
21
|
+
|
|
22
|
+
const departed = slotted(active, unchanged).filter(({ rule }) => !current.has(rule.id));
|
|
23
|
+
const arrived = slotted(scanned, unchanged).filter(({ rule }) => !known.has(rule.id));
|
|
24
|
+
|
|
25
|
+
const candidates = [];
|
|
26
|
+
for (const before of departed) {
|
|
27
|
+
for (const after of arrived) {
|
|
28
|
+
if (before.slot !== after.slot) continue;
|
|
29
|
+
const score = similarity(before.rule.text, after.rule.text);
|
|
30
|
+
if (score !== null) candidates.push({ before: before.rule, after: after.rule, score });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pairs = new Map();
|
|
35
|
+
const taken = new Set();
|
|
36
|
+
for (const { before, after } of candidates.sort((a, b) => b.score - a.score)) {
|
|
37
|
+
if (pairs.has(after.id) || taken.has(before.id)) continue;
|
|
38
|
+
pairs.set(after.id, before);
|
|
39
|
+
taken.add(before.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return pairs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Dice coefficient over word sets, or null when the texts don't look like
|
|
46
|
+
// the same rule.
|
|
47
|
+
export function similarity(a, b) {
|
|
48
|
+
const wordsA = words(a);
|
|
49
|
+
const wordsB = words(b);
|
|
50
|
+
const shared = [...wordsA].filter((w) => wordsB.has(w)).length;
|
|
51
|
+
const dice = (2 * shared) / (wordsA.size + wordsB.size || 1);
|
|
52
|
+
const trimmed = shared >= MIN_SHARED_WORDS && shared === Math.min(wordsA.size, wordsB.size);
|
|
53
|
+
|
|
54
|
+
return dice >= MIN_SIMILARITY || trimmed ? dice : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Where a rule sits: its file and the nearest unchanged rule above it.
|
|
58
|
+
// Rules are in file order (the ledger keeps scan order).
|
|
59
|
+
function slotted(rules, unchanged) {
|
|
60
|
+
const anchors = new Map();
|
|
61
|
+
|
|
62
|
+
return rules.map((rule) => {
|
|
63
|
+
const slot = `${rule.source}:${anchors.get(rule.source) ?? ''}`;
|
|
64
|
+
if (unchanged.has(rule.id)) anchors.set(rule.source, rule.id);
|
|
65
|
+
return { rule, slot };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function words(text) {
|
|
70
|
+
return new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
71
|
+
}
|