verderer 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Velazquez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # verderer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **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 — so a model upgrade retires rules with evidence instead of making you throw the harness away.
4
+
5
+ A verderer was the officer of the royal forest: he kept its law, held inquests, and answered for what the law was still for. That's this tool's job for your harness.
6
+
7
+ ## The problem
8
+
9
+ Every harness rule is a patch over a model weakness, frozen at the moment you wrote it. Models improve; the repo matures until the code itself carries the convention; the rules stay — costing tokens every session, and sometimes actively fighting a model that outgrew them. The common answer is ritual: delete everything every six months and see what breaks. Verderer replaces the ritual with a ledger and evidence.
10
+
11
+ Validated on a real repo before a line of this tool existed: of 3 long-standing `CLAUDE.md` rules probed by ablation, 1 was dead (the model derived the convention from neighboring code and said so), 1 was derivable (kept as cheap insurance against an expensive miss), and 1 reduced to a single clause — everything else it stated, the model already did unaided. ~1 hour, 3 headless runs.
12
+
13
+ ## Usage
14
+
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)
19
+ ```
20
+
21
+ `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.
22
+
23
+ ## Two modes, decided by git
24
+
25
+ Verderer checks how git sees each harness file and adapts:
26
+
27
+ - **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
+ - **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
+ ## The ledger
31
+
32
+ `.claude/verderer.json` — one entry per rule:
33
+
34
+ ```json
35
+ {
36
+ "id": "9f2c01ab34de",
37
+ "text": "ESM only; imports use `.js` extensions even in TS source.",
38
+ "source": "CLAUDE.md",
39
+ "section": "Conventions",
40
+ "firstSeen": "2026-03-17",
41
+ "origin": "git:da89e1b",
42
+ "status": "active",
43
+ "lastVerdict": { "date": "2026-10-31", "result": "dead", "evidence": "ablation probe: model derived it from neighboring code" }
44
+ }
45
+ ```
46
+
47
+ 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
+
49
+ ## Roadmap
50
+
51
+ - `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.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ npm test # node --test
59
+ ```
60
+
61
+ No dependencies. Node ≥ 20.
62
+
63
+ ## Who built this
64
+
65
+ Built by [Victor Velázquez](https://victorvelazquez.dev) — COO at [MagmaLabs](https://www.magmalabs.io) and author of [*The Agentic Engineer*](https://leanpub.com/theagenticengineer). Sibling of [banneret](https://github.com/vicmaster/banneret) (sub-agent pipeline) and [reeve](https://github.com/vicmaster/reeve) (MCP guardrails): banneret disciplines how agents work, verderer audits what you tell them.
66
+
67
+ ## License
68
+
69
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ process.exitCode = await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "verderer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "verderer": "bin/verderer.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "keywords": [
22
+ "claude-code",
23
+ "agents",
24
+ "harness",
25
+ "claude-md",
26
+ "ablation",
27
+ "prompt-engineering"
28
+ ],
29
+ "author": "Victor Velázquez (https://victorvelazquez.dev)",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/vicmaster/verderer.git"
34
+ }
35
+ }
package/src/cli.js ADDED
@@ -0,0 +1,100 @@
1
+ import { resolve } from 'node:path';
2
+ import { scan } from './scan.js';
3
+ import { loadLedger, ledgerPath } from './ledger.js';
4
+
5
+ const HELP = `verderer — a rule ledger for AI agent harnesses
6
+
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)
11
+
12
+ The ledger adapts to how git sees each harness file:
13
+ tracked → provenance comes free from git blame; prune via normal commits/PRs
14
+ ignored → the ledger carries provenance itself; consider tracking the ledger file
15
+ `;
16
+
17
+ export async function run(argv) {
18
+ const [cmd, dirArg] = argv;
19
+ const dir = resolve(dirArg ?? '.');
20
+
21
+ switch (cmd) {
22
+ case 'scan':
23
+ return cmdScan(dir);
24
+ case 'status':
25
+ return cmdStatus(dir);
26
+ case 'probe':
27
+ return cmdProbe();
28
+ case undefined:
29
+ case '--help':
30
+ case '-h':
31
+ console.log(HELP);
32
+ return 0;
33
+ default:
34
+ console.error(`unknown command: ${cmd}\n`);
35
+ console.log(HELP);
36
+ return 1;
37
+ }
38
+ }
39
+
40
+ function cmdScan(dir) {
41
+ const { counts, modes, path } = scan(dir);
42
+ const sources = Object.entries(modes);
43
+
44
+ if (sources.length === 0) {
45
+ console.error('no harness files found (looked for CLAUDE.md, AGENTS.md)');
46
+ return 1;
47
+ }
48
+
49
+ for (const [source, mode] of sources) {
50
+ console.log(`${source} (${mode})`);
51
+ }
52
+ console.log(`${counts.active} active · ${counts.added} new · ${counts.retired} retired → ${path}`);
53
+
54
+ if (sources.some(([, mode]) => mode === 'ignored')) {
55
+ 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
+ }
57
+
58
+ return 0;
59
+ }
60
+
61
+ function cmdStatus(dir) {
62
+ const ledger = loadLedger(dir);
63
+
64
+ if (ledger.rules.length === 0) {
65
+ console.error(`no ledger at ${ledgerPath(dir)} — run \`verderer scan\` first`);
66
+ return 1;
67
+ }
68
+
69
+ console.log(`scanned ${ledger.scannedAt} · ${ledger.rules.length} rules\n`);
70
+ for (const rule of ledger.rules) {
71
+ const verdict = rule.lastVerdict ? `${rule.lastVerdict.result} (${rule.lastVerdict.date})` : 'unexamined';
72
+ const flag = rule.status === 'retired' ? ' [retired]' : '';
73
+ console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
74
+ console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
75
+ }
76
+
77
+ return 0;
78
+ }
79
+
80
+ function cmdProbe() {
81
+ console.log(`probe automation is on the roadmap. The manual recipe (validated on a real repo):
82
+
83
+ 1. git worktree add --detach <tmp>/probe <branch> # one worktree per suspect rule
84
+ 2. Copy the harness file in and delete ONE rule.
85
+ 3. In the worktree, run a task that forces exactly that decision:
86
+ claude -p "<task>" --permission-mode acceptEdits
87
+ 4. Inspect the result: did the model do the right thing without the rule?
88
+ - yes, by reading the codebase → the rule is dead (the code now carries the convention)
89
+ - yes, but a miss would be expensive → derivable; keeping it is cheap insurance
90
+ - no → the rule still earns its place
91
+ 5. Record the verdict in the ledger, prune with evidence.
92
+
93
+ Verdicts are judgment calls on blast radius — verderer reports, you decide. It never auto-deletes.`);
94
+ return 0;
95
+ }
96
+
97
+ function truncate(s, n) {
98
+ const flat = s.replace(/\s+/g, ' ');
99
+ return flat.length <= n ? flat : flat.slice(0, n - 1) + '…';
100
+ }
package/src/git.js ADDED
@@ -0,0 +1,50 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ function git(dir, args) {
4
+ return spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
5
+ }
6
+
7
+ // How git sees a harness file: that decides where provenance comes from.
8
+ // 'tracked' → git blame is the ledger's provenance, snapshots are free.
9
+ // 'ignored' → deliberate privacy; the ledger must carry provenance itself.
10
+ // 'untracked' → in a repo but not committed (yet).
11
+ // 'no-git' → not a repository.
12
+ export function fileMode(dir, relPath) {
13
+ const inRepo = git(dir, ['rev-parse', '--is-inside-work-tree']);
14
+ if (inRepo.status !== 0) return 'no-git';
15
+
16
+ if (git(dir, ['check-ignore', '-q', relPath]).status === 0) return 'ignored';
17
+ if (git(dir, ['ls-files', '--error-unmatch', relPath]).status === 0) return 'tracked';
18
+
19
+ return 'untracked';
20
+ }
21
+
22
+ // line number (1-based) → { sha, date } for a tracked file, null otherwise.
23
+ export function blameDates(dir, relPath) {
24
+ const res = git(dir, ['blame', '--porcelain', '--', relPath]);
25
+ if (res.status !== 0) return null;
26
+
27
+ const byLine = new Map();
28
+ const timeBySha = new Map();
29
+ let sha = null;
30
+ let finalLine = null;
31
+
32
+ for (const line of res.stdout.split('\n')) {
33
+ const head = line.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/);
34
+ if (head) {
35
+ sha = head[1];
36
+ finalLine = Number(head[2]);
37
+ byLine.set(finalLine, { sha: sha.slice(0, 7), date: timeBySha.get(sha) ?? null });
38
+ continue;
39
+ }
40
+
41
+ const time = line.match(/^author-time (\d+)$/);
42
+ if (time && sha !== null) {
43
+ const date = new Date(Number(time[1]) * 1000).toISOString().slice(0, 10);
44
+ timeBySha.set(sha, date);
45
+ byLine.set(finalLine, { sha: sha.slice(0, 7), date });
46
+ }
47
+ }
48
+
49
+ return byLine;
50
+ }
package/src/ledger.js ADDED
@@ -0,0 +1,72 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+
4
+ export const LEDGER_VERSION = 1;
5
+
6
+ export function ledgerPath(dir) {
7
+ return join(dir, '.claude', 'verderer.json');
8
+ }
9
+
10
+ export function loadLedger(dir) {
11
+ const path = ledgerPath(dir);
12
+ if (!existsSync(path)) return { version: LEDGER_VERSION, rules: [] };
13
+ return JSON.parse(readFileSync(path, 'utf8'));
14
+ }
15
+
16
+ export function saveLedger(dir, ledger) {
17
+ const path = ledgerPath(dir);
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ writeFileSync(path, JSON.stringify(ledger, null, 2) + '\n');
20
+ return path;
21
+ }
22
+
23
+ // 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.
27
+ export function mergeScan(ledger, scanned, { modes, today = isoToday() } = {}) {
28
+ const existing = new Map(ledger.rules.map((r) => [r.id, r]));
29
+ const seen = new Set();
30
+ const rules = [];
31
+ const counts = { active: 0, added: 0, retired: 0 };
32
+
33
+ for (const rule of scanned) {
34
+ seen.add(rule.id);
35
+ const prior = existing.get(rule.id);
36
+
37
+ if (prior) {
38
+ rules.push({ ...prior, text: rule.text, source: rule.source, section: rule.section, line: rule.line, status: 'active', retiredAt: undefined });
39
+ counts.active++;
40
+ continue;
41
+ }
42
+
43
+ rules.push({
44
+ ...rule,
45
+ status: 'active',
46
+ firstSeen: rule.blame?.date ?? today,
47
+ origin: rule.blame?.sha ? `git:${rule.blame.sha}` : 'unknown',
48
+ lastVerdict: null,
49
+ });
50
+ counts.added++;
51
+ }
52
+
53
+ for (const prior of ledger.rules) {
54
+ if (seen.has(prior.id)) continue;
55
+ const alreadyRetired = prior.status === 'retired';
56
+ rules.push(alreadyRetired ? prior : { ...prior, status: 'retired', retiredAt: today });
57
+ if (!alreadyRetired) counts.retired++;
58
+ }
59
+
60
+ return {
61
+ ledger: { version: LEDGER_VERSION, scannedAt: today, modes, rules: rules.map(stripBlame) },
62
+ counts,
63
+ };
64
+ }
65
+
66
+ function stripBlame({ blame, ...rule }) {
67
+ return rule;
68
+ }
69
+
70
+ export function isoToday() {
71
+ return new Date().toISOString().slice(0, 10);
72
+ }
package/src/parse.js ADDED
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // A "rule" is a top-level bullet in a harness file. Continuation lines
4
+ // (indented 2+ spaces) belong to the bullet above; nested bullets too —
5
+ // they qualify the rule, they are not rules of their own.
6
+ export function parseRules(markdown, source) {
7
+ const rules = [];
8
+ let section = '';
9
+ let current = null;
10
+
11
+ const flush = () => {
12
+ if (!current) return;
13
+ const text = current.lines.join('\n').trim();
14
+ rules.push({
15
+ id: ruleId(text),
16
+ text,
17
+ source,
18
+ section,
19
+ line: current.line,
20
+ });
21
+ current = null;
22
+ };
23
+
24
+ const lines = markdown.split('\n');
25
+ for (let i = 0; i < lines.length; i++) {
26
+ const line = lines[i];
27
+ const heading = line.match(/^#{1,6}\s+(.*)/);
28
+
29
+ if (heading) {
30
+ flush();
31
+ section = heading[1].trim();
32
+ continue;
33
+ }
34
+
35
+ if (/^- /.test(line)) {
36
+ flush();
37
+ current = { lines: [line.slice(2)], line: i + 1 };
38
+ continue;
39
+ }
40
+
41
+ if (current && /^\s{2,}\S/.test(line)) {
42
+ current.lines.push(line.trim());
43
+ continue;
44
+ }
45
+
46
+ flush();
47
+ }
48
+ flush();
49
+
50
+ return rules;
51
+ }
52
+
53
+ // Stable across reformatting: whitespace collapses before hashing.
54
+ export function ruleId(text) {
55
+ const normalized = text.replace(/\s+/g, ' ').trim();
56
+ return createHash('sha1').update(normalized).digest('hex').slice(0, 12);
57
+ }
package/src/scan.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseRules } from './parse.js';
4
+ import { fileMode, blameDates } from './git.js';
5
+ import { loadLedger, saveLedger, mergeScan } from './ledger.js';
6
+
7
+ export const SOURCES = ['CLAUDE.md', 'AGENTS.md'];
8
+
9
+ export function scan(dir) {
10
+ const modes = {};
11
+ const scanned = [];
12
+
13
+ for (const source of SOURCES) {
14
+ const path = join(dir, source);
15
+ if (!existsSync(path)) continue;
16
+
17
+ const mode = fileMode(dir, source);
18
+ modes[source] = mode;
19
+
20
+ const rules = parseRules(readFileSync(path, 'utf8'), source);
21
+ const blame = mode === 'tracked' ? blameDates(dir, source) : null;
22
+
23
+ for (const rule of rules) {
24
+ scanned.push(blame ? { ...rule, blame: blame.get(rule.line) ?? null } : rule);
25
+ }
26
+ }
27
+
28
+ const { ledger, counts } = mergeScan(loadLedger(dir), scanned, { modes });
29
+ const path = saveLedger(dir, ledger);
30
+
31
+ return { ledger, counts, modes, path };
32
+ }