verderer 0.2.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 +7 -1
- package/package.json +1 -1
- package/src/cli.js +38 -9
- package/src/ledger.js +69 -10
- package/src/reword.js +71 -0
package/README.md
CHANGED
|
@@ -16,11 +16,15 @@ Validated on a real repo before a line of this tool existed: of 3 long-standing
|
|
|
16
16
|
npx verderer scan # inventory harness rules into .claude/verderer.json
|
|
17
17
|
npx verderer status # active/retired rules, provenance, verdicts
|
|
18
18
|
npx verderer checkpoint --model <id> # record the model by hand (tools without an adapter)
|
|
19
|
+
npx verderer verdict 9f2c dead --evidence "derived it from neighboring code"
|
|
20
|
+
# record an inquest (id prefix is enough)
|
|
19
21
|
npx verderer probe # the ablation recipe (automation is the roadmap)
|
|
20
22
|
```
|
|
21
23
|
|
|
22
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.
|
|
23
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
|
+
|
|
24
28
|
## Two modes, decided by git
|
|
25
29
|
|
|
26
30
|
Verderer checks how git sees each harness file and adapts:
|
|
@@ -52,12 +56,14 @@ For tools without a detection adapter yet, `verderer checkpoint --model <id>` re
|
|
|
52
56
|
"firstSeen": "2026-03-17",
|
|
53
57
|
"origin": "git:da89e1b",
|
|
54
58
|
"status": "active",
|
|
55
|
-
"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" }
|
|
56
60
|
}
|
|
57
61
|
```
|
|
58
62
|
|
|
59
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.
|
|
60
64
|
|
|
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.
|
|
66
|
+
|
|
61
67
|
## Roadmap
|
|
62
68
|
|
|
63
69
|
- `probe` automation: per-rule ablation worktrees + headless runs + verdict recording (today it prints the validated manual recipe).
|
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, 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
|
|
|
@@ -8,6 +8,8 @@ Usage:
|
|
|
8
8
|
verderer scan [dir] inventory harness rules (CLAUDE.md, AGENTS.md) into .claude/verderer.json
|
|
9
9
|
verderer status [dir] show the ledger: active/retired rules, provenance, verdicts
|
|
10
10
|
verderer checkpoint [dir] --model <id> record the model by hand (for tools without a detection adapter)
|
|
11
|
+
verderer verdict <id> <result> [dir] --evidence <text>
|
|
12
|
+
record an inquest: dead | derivable | needed (id prefix is enough)
|
|
11
13
|
verderer probe print the manual ablation recipe (automation is the roadmap)
|
|
12
14
|
|
|
13
15
|
Scan also records which model the repo is actually running on (read from the
|
|
@@ -24,11 +26,12 @@ export async function run(argv) {
|
|
|
24
26
|
const positional = [];
|
|
25
27
|
for (let i = 0; i < argv.length; i++) {
|
|
26
28
|
if (argv[i] === '--model') flags.model = argv[++i];
|
|
29
|
+
else if (argv[i] === '--evidence') flags.evidence = argv[++i];
|
|
27
30
|
else positional.push(argv[i]);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
const [cmd,
|
|
31
|
-
const dir = resolve(
|
|
33
|
+
const [cmd, ...args] = positional;
|
|
34
|
+
const dir = resolve(args[0] ?? '.');
|
|
32
35
|
|
|
33
36
|
switch (cmd) {
|
|
34
37
|
case 'scan':
|
|
@@ -37,6 +40,10 @@ export async function run(argv) {
|
|
|
37
40
|
return cmdStatus(dir);
|
|
38
41
|
case 'checkpoint':
|
|
39
42
|
return cmdCheckpoint(dir, flags.model);
|
|
43
|
+
case 'verdict': {
|
|
44
|
+
const [id, result, dirArg] = args;
|
|
45
|
+
return cmdVerdict(resolve(dirArg ?? '.'), id, result, flags.evidence);
|
|
46
|
+
}
|
|
40
47
|
case 'probe':
|
|
41
48
|
return cmdProbe();
|
|
42
49
|
case undefined:
|
|
@@ -63,7 +70,7 @@ function cmdScan(dir) {
|
|
|
63
70
|
for (const [source, mode] of sources) {
|
|
64
71
|
console.log(`${source} (${mode})`);
|
|
65
72
|
}
|
|
66
|
-
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}`);
|
|
67
74
|
|
|
68
75
|
if (ledger.model) {
|
|
69
76
|
console.log(`model: ${ledger.model.id} (${ledger.model.source})`);
|
|
@@ -72,7 +79,7 @@ function cmdScan(dir) {
|
|
|
72
79
|
console.log(`model changed: ${modelChange.from} → ${modelChange.to}`);
|
|
73
80
|
}
|
|
74
81
|
if (stale.length > 0) {
|
|
75
|
-
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.`);
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
if (sources.some(([, mode]) => mode === 'ignored')) {
|
|
@@ -93,10 +100,10 @@ function cmdStatus(dir) {
|
|
|
93
100
|
const model = ledger.model ? ` · model ${ledger.model.id}` : '';
|
|
94
101
|
console.log(`scanned ${ledger.scannedAt}${model} · ${ledger.rules.length} rules\n`);
|
|
95
102
|
|
|
96
|
-
const stale = new Set(staleVerdicts(ledger).map((r) => r.id));
|
|
97
103
|
for (const rule of ledger.rules) {
|
|
98
104
|
let verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
|
|
99
|
-
|
|
105
|
+
const stale = staleReason(rule, ledger.model?.id);
|
|
106
|
+
if (stale) verdict += ` [stale: ${stale}]`;
|
|
100
107
|
const flag = rule.status === 'retired' ? ' [retired]' : '';
|
|
101
108
|
console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
|
|
102
109
|
console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
|
|
@@ -119,12 +126,33 @@ function cmdCheckpoint(dir, modelId) {
|
|
|
119
126
|
console.log(prior && prior !== modelId ? `model changed: ${prior} → ${modelId}` : `model: ${modelId}`);
|
|
120
127
|
const stale = staleVerdicts(ledger);
|
|
121
128
|
if (stale.length > 0) {
|
|
122
|
-
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.`);
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
return 0;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
function cmdVerdict(dir, id, result, evidence) {
|
|
136
|
+
const prior = loadLedger(dir);
|
|
137
|
+
|
|
138
|
+
if (prior.rules.length === 0) {
|
|
139
|
+
console.error(`no ledger at ${ledgerPath(dir)} — run \`verderer scan\` first`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const { ledger, rule } = recordVerdict(prior, id, result, { evidence });
|
|
145
|
+
saveLedger(dir, ledger);
|
|
146
|
+
console.log(`${rule.id} ${result} on ${rule.lastVerdict.model}`);
|
|
147
|
+
console.log(` ${truncate(rule.text, 100)}`);
|
|
148
|
+
return 0;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(err.message);
|
|
151
|
+
console.error('usage: verderer verdict <id> <dead|derivable|needed> [dir] --evidence <text>');
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
function cmdProbe() {
|
|
129
157
|
console.log(`probe automation is on the roadmap. The manual recipe (validated on a real repo):
|
|
130
158
|
|
|
@@ -136,7 +164,8 @@ function cmdProbe() {
|
|
|
136
164
|
- yes, by reading the codebase → the rule is dead (the code now carries the convention)
|
|
137
165
|
- yes, but a miss would be expensive → derivable; keeping it is cheap insurance
|
|
138
166
|
- no → the rule still earns its place
|
|
139
|
-
5. Record
|
|
167
|
+
5. Record it: verderer verdict <id> <dead|derivable|needed> --evidence "..."
|
|
168
|
+
then prune with evidence.
|
|
140
169
|
|
|
141
170
|
Verdicts are judgment calls on blast radius — verderer reports, you decide. It never auto-deletes.`);
|
|
142
171
|
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,60 @@ 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
|
-
|
|
70
|
-
|
|
101
|
+
return ledger.rules.filter((r) => staleReason(r, ledger.model?.id));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const VERDICTS = ['dead', 'derivable', 'needed'];
|
|
105
|
+
|
|
106
|
+
// Stamp a verdict on one rule, found by id prefix (like a git sha).
|
|
107
|
+
// The verdict records the ledger's current model so a later model change
|
|
108
|
+
// can flag it as stale; without a model there is nothing to judge against.
|
|
109
|
+
export function recordVerdict(ledger, prefix, result, { evidence, today = isoToday() } = {}) {
|
|
110
|
+
if (!VERDICTS.includes(result)) {
|
|
111
|
+
throw new Error(`verdict must be one of: ${VERDICTS.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!evidence) throw new Error('a verdict needs --evidence');
|
|
115
|
+
|
|
116
|
+
const model = ledger.model?.id;
|
|
117
|
+
if (!model) throw new Error('no model recorded — run `verderer scan` or `verderer checkpoint --model <id>` first');
|
|
118
|
+
|
|
119
|
+
const matches = prefix ? ledger.rules.filter((r) => r.id.startsWith(prefix)) : [];
|
|
120
|
+
if (matches.length === 0) throw new Error(`no rule matches id ${prefix ?? '(none)'}`);
|
|
121
|
+
if (matches.length > 1) throw new Error(`id ${prefix} is ambiguous: ${matches.map((r) => r.id).join(', ')}`);
|
|
122
|
+
|
|
123
|
+
const [rule] = matches;
|
|
124
|
+
const verdict = { date: today, result, model, evidence, ruleId: rule.id };
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
ledger: { ...ledger, rules: ledger.rules.map((r) => (r === rule ? { ...r, lastVerdict: verdict } : r)) },
|
|
128
|
+
rule: { ...rule, lastVerdict: verdict },
|
|
129
|
+
};
|
|
71
130
|
}
|
|
72
131
|
|
|
73
132
|
function stripBlame({ blame, ...rule }) {
|
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
|
+
}
|