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 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 and the ledger's current model, so the next model change flags it as stale. Evidence is required.
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.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) 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.`);
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
- 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}]`;
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) 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.`);
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; 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,32 @@ 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));
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
+ }