verderer 0.1.0 → 0.3.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
@@ -13,9 +13,12 @@ Validated on a real repo before a line of this tool existed: of 3 long-standing
13
13
  ## Usage
14
14
 
15
15
  ```bash
16
- npx verderer scan # inventory harness rules into .claude/verderer.json
17
- npx verderer status # active/retired rules, provenance, verdicts
18
- npx verderer probe # the ablation recipe (automation is the roadmap)
16
+ npx verderer scan # inventory harness rules into .claude/verderer.json
17
+ npx verderer status # active/retired rules, provenance, verdicts
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)
21
+ npx verderer probe # the ablation recipe (automation is the roadmap)
19
22
  ```
20
23
 
21
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.
@@ -27,6 +30,17 @@ Verderer checks how git sees each harness file and adapts:
27
30
  - **tracked** — provenance comes free: `git blame` dates every rule (and the date tells you which model era it was written against). Pruning is a normal commit or PR; rollback is git.
28
31
  - **ignored** — a deliberate privacy choice some repos make. There is no history, so the ledger carries provenance itself: first scan records rules as `origin: unknown`, everything after is dated. Consider tracking `.claude/verderer.json` even while the harness stays private — the decisions get history without exposing the content.
29
32
 
33
+ ## Model changes
34
+
35
+ Verderer never polls for releases. The question that matters is narrower: **is this repo running on a different model than the one your verdicts were judged against?** Every verdict records its model; `scan` reads the model id the repo actually ran on from the agent tool's own session logs (Claude Code transcripts today — concrete ids, so a stable `"opus"` alias doesn't hide an upgrade underneath) and flags verdicts judged on another model as stale:
36
+
37
+ ```
38
+ model changed: claude-fable-5 → claude-fable-5-1
39
+ 3 verdict(s) rendered against another model are stale — re-run their inquests.
40
+ ```
41
+
42
+ For tools without a detection adapter yet, `verderer checkpoint --model <id>` records it by hand.
43
+
30
44
  ## The ledger
31
45
 
32
46
  `.claude/verderer.json` — one entry per rule:
@@ -40,17 +54,19 @@ Verderer checks how git sees each harness file and adapts:
40
54
  "firstSeen": "2026-03-17",
41
55
  "origin": "git:da89e1b",
42
56
  "status": "active",
43
- "lastVerdict": { "date": "2026-10-31", "result": "dead", "evidence": "ablation probe: model derived it from neighboring code" }
57
+ "lastVerdict": { "date": "2026-10-31", "result": "dead", "model": "claude-fable-5", "evidence": "ablation probe: model derived it from neighboring code" }
44
58
  }
45
59
  ```
46
60
 
47
61
  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.
48
62
 
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.
64
+
49
65
  ## Roadmap
50
66
 
51
67
  - `probe` automation: per-rule ablation worktrees + headless runs + verdict recording (today it prints the validated manual recipe).
52
- - Model-upgrade trigger: re-examine `unexamined`/`needed` rules when the configured model changes.
53
- - More sources: `.claude/agents/*.md`, skills.
68
+ - Model-detection adapters beyond Claude Code (Codex sessions next); the ledger core is already tool-agnostic.
69
+ - More sources: `.claude/agents/*.md`, `GEMINI.md`, `.cursor/rules/`, skills.
54
70
 
55
71
  ## Development
56
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verderer",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,13 +1,20 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { scan } from './scan.js';
3
- import { loadLedger, ledgerPath } from './ledger.js';
3
+ import { loadLedger, saveLedger, ledgerPath, staleVerdicts, recordVerdict, isoToday } from './ledger.js';
4
4
 
5
5
  const HELP = `verderer — a rule ledger for AI agent harnesses
6
6
 
7
7
  Usage:
8
- verderer scan [dir] inventory harness rules (CLAUDE.md, AGENTS.md) into .claude/verderer.json
9
- verderer status [dir] show the ledger: active/retired rules, provenance, verdicts
10
- verderer probe print the manual ablation recipe (automation is the roadmap)
8
+ verderer scan [dir] inventory harness rules (CLAUDE.md, AGENTS.md) into .claude/verderer.json
9
+ verderer status [dir] show the ledger: active/retired rules, provenance, verdicts
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)
13
+ verderer probe print the manual ablation recipe (automation is the roadmap)
14
+
15
+ Scan also records which model the repo is actually running on (read from the
16
+ agent tool's own session logs — Claude Code today) and flags verdicts rendered
17
+ against a different model as stale.
11
18
 
12
19
  The ledger adapts to how git sees each harness file:
13
20
  tracked → provenance comes free from git blame; prune via normal commits/PRs
@@ -15,14 +22,28 @@ The ledger adapts to how git sees each harness file:
15
22
  `;
16
23
 
17
24
  export async function run(argv) {
18
- const [cmd, dirArg] = argv;
19
- const dir = resolve(dirArg ?? '.');
25
+ const flags = {};
26
+ const positional = [];
27
+ for (let i = 0; i < argv.length; i++) {
28
+ if (argv[i] === '--model') flags.model = argv[++i];
29
+ else if (argv[i] === '--evidence') flags.evidence = argv[++i];
30
+ else positional.push(argv[i]);
31
+ }
32
+
33
+ const [cmd, ...args] = positional;
34
+ const dir = resolve(args[0] ?? '.');
20
35
 
21
36
  switch (cmd) {
22
37
  case 'scan':
23
38
  return cmdScan(dir);
24
39
  case 'status':
25
40
  return cmdStatus(dir);
41
+ case 'checkpoint':
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
+ }
26
47
  case 'probe':
27
48
  return cmdProbe();
28
49
  case undefined:
@@ -38,7 +59,7 @@ export async function run(argv) {
38
59
  }
39
60
 
40
61
  function cmdScan(dir) {
41
- const { counts, modes, path } = scan(dir);
62
+ const { ledger, counts, modes, path, modelChange, stale } = scan(dir);
42
63
  const sources = Object.entries(modes);
43
64
 
44
65
  if (sources.length === 0) {
@@ -51,6 +72,16 @@ function cmdScan(dir) {
51
72
  }
52
73
  console.log(`${counts.active} active · ${counts.added} new · ${counts.retired} retired → ${path}`);
53
74
 
75
+ if (ledger.model) {
76
+ console.log(`model: ${ledger.model.id} (${ledger.model.source})`);
77
+ }
78
+ if (modelChange) {
79
+ console.log(`model changed: ${modelChange.from} → ${modelChange.to}`);
80
+ }
81
+ if (stale.length > 0) {
82
+ console.log(`${stale.length} verdict(s) rendered against another model are stale — re-run their inquests.`);
83
+ }
84
+
54
85
  if (sources.some(([, mode]) => mode === 'ignored')) {
55
86
  console.log('note: an ignored harness file has no git history — the ledger IS the provenance. Consider tracking .claude/verderer.json even if the harness stays private.');
56
87
  }
@@ -66,9 +97,13 @@ function cmdStatus(dir) {
66
97
  return 1;
67
98
  }
68
99
 
69
- console.log(`scanned ${ledger.scannedAt} · ${ledger.rules.length} rules\n`);
100
+ const model = ledger.model ? ` · model ${ledger.model.id}` : '';
101
+ console.log(`scanned ${ledger.scannedAt}${model} · ${ledger.rules.length} rules\n`);
102
+
103
+ const stale = new Set(staleVerdicts(ledger).map((r) => r.id));
70
104
  for (const rule of ledger.rules) {
71
- const verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
105
+ let verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
106
+ if (stale.has(rule.id)) verdict += ' [stale: judged on ' + rule.lastVerdict.model + ']';
72
107
  const flag = rule.status === 'retired' ? ' [retired]' : '';
73
108
  console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
74
109
  console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
@@ -77,6 +112,47 @@ function cmdStatus(dir) {
77
112
  return 0;
78
113
  }
79
114
 
115
+ function cmdCheckpoint(dir, modelId) {
116
+ if (!modelId) {
117
+ console.error('usage: verderer checkpoint [dir] --model <id>');
118
+ return 1;
119
+ }
120
+
121
+ const ledger = loadLedger(dir);
122
+ const prior = ledger.model?.id;
123
+ ledger.model = { id: modelId, source: 'manual', detectedAt: isoToday() };
124
+ saveLedger(dir, ledger);
125
+
126
+ console.log(prior && prior !== modelId ? `model changed: ${prior} → ${modelId}` : `model: ${modelId}`);
127
+ const stale = staleVerdicts(ledger);
128
+ if (stale.length > 0) {
129
+ console.log(`${stale.length} verdict(s) rendered against another model are stale — re-run their inquests.`);
130
+ }
131
+
132
+ return 0;
133
+ }
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
+
80
156
  function cmdProbe() {
81
157
  console.log(`probe automation is on the roadmap. The manual recipe (validated on a real repo):
82
158
 
@@ -88,7 +164,8 @@ function cmdProbe() {
88
164
  - yes, by reading the codebase → the rule is dead (the code now carries the convention)
89
165
  - yes, but a miss would be expensive → derivable; keeping it is cheap insurance
90
166
  - no → the rule still earns its place
91
- 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.
92
169
 
93
170
  Verdicts are judgment calls on blast radius — verderer reports, you decide. It never auto-deletes.`);
94
171
  return 0;
package/src/ledger.js CHANGED
@@ -24,7 +24,7 @@ export function saveLedger(dir, ledger) {
24
24
  // Known rules keep their provenance and verdicts; new ones get firstSeen
25
25
  // (from git blame when the file is tracked, else today); rules that left
26
26
  // the file are retired, never deleted — the ledger is append-only history.
27
- export function mergeScan(ledger, scanned, { modes, today = isoToday() } = {}) {
27
+ export function mergeScan(ledger, scanned, { modes, model, today = isoToday() } = {}) {
28
28
  const existing = new Map(ledger.rules.map((r) => [r.id, r]));
29
29
  const seen = new Set();
30
30
  const rules = [];
@@ -58,11 +58,46 @@ export function mergeScan(ledger, scanned, { modes, today = isoToday() } = {}) {
58
58
  }
59
59
 
60
60
  return {
61
- ledger: { version: LEDGER_VERSION, scannedAt: today, modes, rules: rules.map(stripBlame) },
61
+ ledger: { version: LEDGER_VERSION, scannedAt: today, modes, model: model ?? ledger.model, rules: rules.map(stripBlame) },
62
62
  counts,
63
63
  };
64
64
  }
65
65
 
66
+ // A verdict was rendered against one model; a different model makes it stale.
67
+ 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);
71
+ }
72
+
73
+ export const VERDICTS = ['dead', 'derivable', 'needed'];
74
+
75
+ // Stamp a verdict on one rule, found by id prefix (like a git sha).
76
+ // The verdict records the ledger's current model so a later model change
77
+ // can flag it as stale; without a model there is nothing to judge against.
78
+ export function recordVerdict(ledger, prefix, result, { evidence, today = isoToday() } = {}) {
79
+ if (!VERDICTS.includes(result)) {
80
+ throw new Error(`verdict must be one of: ${VERDICTS.join(', ')}`);
81
+ }
82
+
83
+ if (!evidence) throw new Error('a verdict needs --evidence');
84
+
85
+ const model = ledger.model?.id;
86
+ if (!model) throw new Error('no model recorded — run `verderer scan` or `verderer checkpoint --model <id>` first');
87
+
88
+ const matches = prefix ? ledger.rules.filter((r) => r.id.startsWith(prefix)) : [];
89
+ if (matches.length === 0) throw new Error(`no rule matches id ${prefix ?? '(none)'}`);
90
+ if (matches.length > 1) throw new Error(`id ${prefix} is ambiguous: ${matches.map((r) => r.id).join(', ')}`);
91
+
92
+ const [rule] = matches;
93
+ const verdict = { date: today, result, model, evidence };
94
+
95
+ return {
96
+ ledger: { ...ledger, rules: ledger.rules.map((r) => (r === rule ? { ...r, lastVerdict: verdict } : r)) },
97
+ rule: { ...rule, lastVerdict: verdict },
98
+ };
99
+ }
100
+
66
101
  function stripBlame({ blame, ...rule }) {
67
102
  return rule;
68
103
  }
package/src/model.js ADDED
@@ -0,0 +1,42 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // What model is this repo actually running on? Not "what did the vendor
6
+ // release" and not the settings alias ("opus" stays stable while the model
7
+ // under it changes) — the concrete id recorded in the tool's own session
8
+ // logs. Claude Code is the first adapter; others follow the same contract:
9
+ // (dir) → { id, source } | null.
10
+ export function detectModel(dir, { home = homedir() } = {}) {
11
+ return fromClaudeTranscripts(dir, home);
12
+ }
13
+
14
+ const RECENT_TRANSCRIPTS = 5;
15
+
16
+ function fromClaudeTranscripts(dir, home) {
17
+ const slug = dir.replace(/[^a-zA-Z0-9]/g, '-');
18
+ const projectDir = join(home, '.claude', 'projects', slug);
19
+ if (!existsSync(projectDir)) return null;
20
+
21
+ const transcripts = readdirSync(projectDir)
22
+ .filter((f) => f.endsWith('.jsonl'))
23
+ .map((f) => join(projectDir, f))
24
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
25
+ .slice(0, RECENT_TRANSCRIPTS);
26
+
27
+ for (const file of transcripts) {
28
+ const id = lastModelIn(readFileSync(file, 'utf8'));
29
+ if (id) return { id, source: 'claude-transcripts' };
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ function lastModelIn(text) {
36
+ let last = null;
37
+ for (const match of text.matchAll(/"model":"([^"]+)"/g)) {
38
+ if (match[1].startsWith('<')) continue; // "<synthetic>" and friends
39
+ last = match[1];
40
+ }
41
+ return last;
42
+ }
package/src/scan.js CHANGED
@@ -2,7 +2,8 @@ import { readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parseRules } from './parse.js';
4
4
  import { fileMode, blameDates } from './git.js';
5
- import { loadLedger, saveLedger, mergeScan } from './ledger.js';
5
+ import { detectModel } from './model.js';
6
+ import { loadLedger, saveLedger, mergeScan, staleVerdicts, isoToday } from './ledger.js';
6
7
 
7
8
  export const SOURCES = ['CLAUDE.md', 'AGENTS.md'];
8
9
 
@@ -25,8 +26,19 @@ export function scan(dir) {
25
26
  }
26
27
  }
27
28
 
28
- const { ledger, counts } = mergeScan(loadLedger(dir), scanned, { modes });
29
+ const prior = loadLedger(dir);
30
+ const detected = detectModel(dir);
31
+ const model = detected ? { ...detected, detectedAt: isoToday() } : prior.model;
32
+
33
+ const { ledger, counts } = mergeScan(prior, scanned, { modes, model });
29
34
  const path = saveLedger(dir, ledger);
30
35
 
31
- return { ledger, counts, modes, path };
36
+ return {
37
+ ledger,
38
+ counts,
39
+ modes,
40
+ path,
41
+ modelChange: prior.model?.id && model?.id && prior.model.id !== model.id ? { from: prior.model.id, to: model.id } : null,
42
+ stale: staleVerdicts(ledger),
43
+ };
32
44
  }