verderer 0.1.0 → 0.2.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 +18 -6
- package/package.json +1 -1
- package/src/cli.js +56 -8
- package/src/ledger.js +9 -2
- package/src/model.js +42 -0
- package/src/scan.js +15 -3
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ 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
|
|
17
|
-
npx verderer status
|
|
18
|
-
npx verderer
|
|
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 probe # the ablation recipe (automation is the roadmap)
|
|
19
20
|
```
|
|
20
21
|
|
|
21
22
|
`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 +28,17 @@ Verderer checks how git sees each harness file and adapts:
|
|
|
27
28
|
- **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
29
|
- **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
30
|
|
|
31
|
+
## Model changes
|
|
32
|
+
|
|
33
|
+
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:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
model changed: claude-fable-5 → claude-fable-5-1
|
|
37
|
+
3 verdict(s) rendered against another model are stale — re-run their inquests.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For tools without a detection adapter yet, `verderer checkpoint --model <id>` records it by hand.
|
|
41
|
+
|
|
30
42
|
## The ledger
|
|
31
43
|
|
|
32
44
|
`.claude/verderer.json` — one entry per rule:
|
|
@@ -40,7 +52,7 @@ Verderer checks how git sees each harness file and adapts:
|
|
|
40
52
|
"firstSeen": "2026-03-17",
|
|
41
53
|
"origin": "git:da89e1b",
|
|
42
54
|
"status": "active",
|
|
43
|
-
"lastVerdict": { "date": "2026-10-31", "result": "dead", "evidence": "ablation probe: model derived it from neighboring code" }
|
|
55
|
+
"lastVerdict": { "date": "2026-10-31", "result": "dead", "model": "claude-fable-5", "evidence": "ablation probe: model derived it from neighboring code" }
|
|
44
56
|
}
|
|
45
57
|
```
|
|
46
58
|
|
|
@@ -49,8 +61,8 @@ Verdicts are three-valued on purpose: **dead** (the model does it unaided), **de
|
|
|
49
61
|
## Roadmap
|
|
50
62
|
|
|
51
63
|
- `probe` automation: per-rule ablation worktrees + headless runs + verdict recording (today it prints the validated manual recipe).
|
|
52
|
-
- Model-
|
|
53
|
-
- More sources: `.claude/agents/*.md`, skills.
|
|
64
|
+
- Model-detection adapters beyond Claude Code (Codex sessions next); the ledger core is already tool-agnostic.
|
|
65
|
+
- More sources: `.claude/agents/*.md`, `GEMINI.md`, `.cursor/rules/`, skills.
|
|
54
66
|
|
|
55
67
|
## Development
|
|
56
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,18 @@
|
|
|
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, 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]
|
|
9
|
-
verderer status [dir]
|
|
10
|
-
verderer
|
|
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 probe print the manual ablation recipe (automation is the roadmap)
|
|
12
|
+
|
|
13
|
+
Scan also records which model the repo is actually running on (read from the
|
|
14
|
+
agent tool's own session logs — Claude Code today) and flags verdicts rendered
|
|
15
|
+
against a different model as stale.
|
|
11
16
|
|
|
12
17
|
The ledger adapts to how git sees each harness file:
|
|
13
18
|
tracked → provenance comes free from git blame; prune via normal commits/PRs
|
|
@@ -15,7 +20,14 @@ The ledger adapts to how git sees each harness file:
|
|
|
15
20
|
`;
|
|
16
21
|
|
|
17
22
|
export async function run(argv) {
|
|
18
|
-
const
|
|
23
|
+
const flags = {};
|
|
24
|
+
const positional = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
if (argv[i] === '--model') flags.model = argv[++i];
|
|
27
|
+
else positional.push(argv[i]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [cmd, dirArg] = positional;
|
|
19
31
|
const dir = resolve(dirArg ?? '.');
|
|
20
32
|
|
|
21
33
|
switch (cmd) {
|
|
@@ -23,6 +35,8 @@ export async function run(argv) {
|
|
|
23
35
|
return cmdScan(dir);
|
|
24
36
|
case 'status':
|
|
25
37
|
return cmdStatus(dir);
|
|
38
|
+
case 'checkpoint':
|
|
39
|
+
return cmdCheckpoint(dir, flags.model);
|
|
26
40
|
case 'probe':
|
|
27
41
|
return cmdProbe();
|
|
28
42
|
case undefined:
|
|
@@ -38,7 +52,7 @@ export async function run(argv) {
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
function cmdScan(dir) {
|
|
41
|
-
const { counts, modes, path } = scan(dir);
|
|
55
|
+
const { ledger, counts, modes, path, modelChange, stale } = scan(dir);
|
|
42
56
|
const sources = Object.entries(modes);
|
|
43
57
|
|
|
44
58
|
if (sources.length === 0) {
|
|
@@ -51,6 +65,16 @@ function cmdScan(dir) {
|
|
|
51
65
|
}
|
|
52
66
|
console.log(`${counts.active} active · ${counts.added} new · ${counts.retired} retired → ${path}`);
|
|
53
67
|
|
|
68
|
+
if (ledger.model) {
|
|
69
|
+
console.log(`model: ${ledger.model.id} (${ledger.model.source})`);
|
|
70
|
+
}
|
|
71
|
+
if (modelChange) {
|
|
72
|
+
console.log(`model changed: ${modelChange.from} → ${modelChange.to}`);
|
|
73
|
+
}
|
|
74
|
+
if (stale.length > 0) {
|
|
75
|
+
console.log(`${stale.length} verdict(s) rendered against another model are stale — re-run their inquests.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
if (sources.some(([, mode]) => mode === 'ignored')) {
|
|
55
79
|
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
80
|
}
|
|
@@ -66,9 +90,13 @@ function cmdStatus(dir) {
|
|
|
66
90
|
return 1;
|
|
67
91
|
}
|
|
68
92
|
|
|
69
|
-
|
|
93
|
+
const model = ledger.model ? ` · model ${ledger.model.id}` : '';
|
|
94
|
+
console.log(`scanned ${ledger.scannedAt}${model} · ${ledger.rules.length} rules\n`);
|
|
95
|
+
|
|
96
|
+
const stale = new Set(staleVerdicts(ledger).map((r) => r.id));
|
|
70
97
|
for (const rule of ledger.rules) {
|
|
71
|
-
|
|
98
|
+
let verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
|
|
99
|
+
if (stale.has(rule.id)) verdict += ' [stale: judged on ' + rule.lastVerdict.model + ']';
|
|
72
100
|
const flag = rule.status === 'retired' ? ' [retired]' : '';
|
|
73
101
|
console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
|
|
74
102
|
console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
|
|
@@ -77,6 +105,26 @@ function cmdStatus(dir) {
|
|
|
77
105
|
return 0;
|
|
78
106
|
}
|
|
79
107
|
|
|
108
|
+
function cmdCheckpoint(dir, modelId) {
|
|
109
|
+
if (!modelId) {
|
|
110
|
+
console.error('usage: verderer checkpoint [dir] --model <id>');
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ledger = loadLedger(dir);
|
|
115
|
+
const prior = ledger.model?.id;
|
|
116
|
+
ledger.model = { id: modelId, source: 'manual', detectedAt: isoToday() };
|
|
117
|
+
saveLedger(dir, ledger);
|
|
118
|
+
|
|
119
|
+
console.log(prior && prior !== modelId ? `model changed: ${prior} → ${modelId}` : `model: ${modelId}`);
|
|
120
|
+
const stale = staleVerdicts(ledger);
|
|
121
|
+
if (stale.length > 0) {
|
|
122
|
+
console.log(`${stale.length} verdict(s) rendered against another model are stale — re-run their inquests.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
function cmdProbe() {
|
|
81
129
|
console.log(`probe automation is on the roadmap. The manual recipe (validated on a real repo):
|
|
82
130
|
|
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,18 @@ 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
|
+
|
|
66
73
|
function stripBlame({ blame, ...rule }) {
|
|
67
74
|
return rule;
|
|
68
75
|
}
|
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 {
|
|
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
|
|
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 {
|
|
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
|
}
|