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 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.2.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, dirArg] = positional;
31
- const dir = resolve(dirArg ?? '.');
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) rendered against another model are stale — re-run their inquests.`);
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
- if (stale.has(rule.id)) verdict += ' [stale: judged on ' + rule.lastVerdict.model + ']';
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) rendered against another model are stale — re-run their inquests.`);
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 the verdict in the ledger, prune with evidence.
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; new ones get firstSeen
25
- // (from git blame when the file is tracked, else today); rules that left
26
- // the file are retired, never deleted — the ledger is append-only history.
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
- // A verdict was rendered against one model; a different model makes it stale.
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
- const current = ledger.model?.id;
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));
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
+ }