verderer 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +80 -2
- package/bin/verderer.js +4 -0
- package/package.json +33 -4
- package/src/cli.js +148 -0
- package/src/git.js +50 -0
- package/src/ledger.js +79 -0
- package/src/model.js +42 -0
- package/src/parse.js +57 -0
- package/src/scan.js +44 -0
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,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# verderer
|
|
2
2
|
|
|
3
|
-
|
|
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 checkpoint --model <id> # record the model by hand (tools without an adapter)
|
|
19
|
+
npx verderer probe # the ablation recipe (automation is the roadmap)
|
|
20
|
+
```
|
|
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.
|
|
23
|
+
|
|
24
|
+
## Two modes, decided by git
|
|
25
|
+
|
|
26
|
+
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.
|
|
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.
|
|
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
|
+
|
|
42
|
+
## The ledger
|
|
43
|
+
|
|
44
|
+
`.claude/verderer.json` — one entry per rule:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "9f2c01ab34de",
|
|
49
|
+
"text": "ESM only; imports use `.js` extensions even in TS source.",
|
|
50
|
+
"source": "CLAUDE.md",
|
|
51
|
+
"section": "Conventions",
|
|
52
|
+
"firstSeen": "2026-03-17",
|
|
53
|
+
"origin": "git:da89e1b",
|
|
54
|
+
"status": "active",
|
|
55
|
+
"lastVerdict": { "date": "2026-10-31", "result": "dead", "model": "claude-fable-5", "evidence": "ablation probe: model derived it from neighboring code" }
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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
|
+
|
|
61
|
+
## Roadmap
|
|
62
|
+
|
|
63
|
+
- `probe` automation: per-rule ablation worktrees + headless runs + verdict recording (today it prints the validated manual recipe).
|
|
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.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm test # node --test
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
No dependencies. Node ≥ 20.
|
|
74
|
+
|
|
75
|
+
## Who built this
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT — see [LICENSE](LICENSE).
|
package/bin/verderer.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verderer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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,148 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { scan } from './scan.js';
|
|
3
|
+
import { loadLedger, saveLedger, ledgerPath, staleVerdicts, isoToday } 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 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.
|
|
16
|
+
|
|
17
|
+
The ledger adapts to how git sees each harness file:
|
|
18
|
+
tracked → provenance comes free from git blame; prune via normal commits/PRs
|
|
19
|
+
ignored → the ledger carries provenance itself; consider tracking the ledger file
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export async function run(argv) {
|
|
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;
|
|
31
|
+
const dir = resolve(dirArg ?? '.');
|
|
32
|
+
|
|
33
|
+
switch (cmd) {
|
|
34
|
+
case 'scan':
|
|
35
|
+
return cmdScan(dir);
|
|
36
|
+
case 'status':
|
|
37
|
+
return cmdStatus(dir);
|
|
38
|
+
case 'checkpoint':
|
|
39
|
+
return cmdCheckpoint(dir, flags.model);
|
|
40
|
+
case 'probe':
|
|
41
|
+
return cmdProbe();
|
|
42
|
+
case undefined:
|
|
43
|
+
case '--help':
|
|
44
|
+
case '-h':
|
|
45
|
+
console.log(HELP);
|
|
46
|
+
return 0;
|
|
47
|
+
default:
|
|
48
|
+
console.error(`unknown command: ${cmd}\n`);
|
|
49
|
+
console.log(HELP);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cmdScan(dir) {
|
|
55
|
+
const { ledger, counts, modes, path, modelChange, stale } = scan(dir);
|
|
56
|
+
const sources = Object.entries(modes);
|
|
57
|
+
|
|
58
|
+
if (sources.length === 0) {
|
|
59
|
+
console.error('no harness files found (looked for CLAUDE.md, AGENTS.md)');
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [source, mode] of sources) {
|
|
64
|
+
console.log(`${source} (${mode})`);
|
|
65
|
+
}
|
|
66
|
+
console.log(`${counts.active} active · ${counts.added} new · ${counts.retired} retired → ${path}`);
|
|
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
|
+
|
|
78
|
+
if (sources.some(([, mode]) => mode === 'ignored')) {
|
|
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.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cmdStatus(dir) {
|
|
86
|
+
const ledger = loadLedger(dir);
|
|
87
|
+
|
|
88
|
+
if (ledger.rules.length === 0) {
|
|
89
|
+
console.error(`no ledger at ${ledgerPath(dir)} — run \`verderer scan\` first`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
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));
|
|
97
|
+
for (const rule of ledger.rules) {
|
|
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 + ']';
|
|
100
|
+
const flag = rule.status === 'retired' ? ' [retired]' : '';
|
|
101
|
+
console.log(`${rule.id} ${rule.firstSeen} ${verdict}${flag}`);
|
|
102
|
+
console.log(` ${rule.section ? rule.section + ' · ' : ''}${truncate(rule.text, 100)}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
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
|
+
|
|
128
|
+
function cmdProbe() {
|
|
129
|
+
console.log(`probe automation is on the roadmap. The manual recipe (validated on a real repo):
|
|
130
|
+
|
|
131
|
+
1. git worktree add --detach <tmp>/probe <branch> # one worktree per suspect rule
|
|
132
|
+
2. Copy the harness file in and delete ONE rule.
|
|
133
|
+
3. In the worktree, run a task that forces exactly that decision:
|
|
134
|
+
claude -p "<task>" --permission-mode acceptEdits
|
|
135
|
+
4. Inspect the result: did the model do the right thing without the rule?
|
|
136
|
+
- yes, by reading the codebase → the rule is dead (the code now carries the convention)
|
|
137
|
+
- yes, but a miss would be expensive → derivable; keeping it is cheap insurance
|
|
138
|
+
- no → the rule still earns its place
|
|
139
|
+
5. Record the verdict in the ledger, prune with evidence.
|
|
140
|
+
|
|
141
|
+
Verdicts are judgment calls on blast radius — verderer reports, you decide. It never auto-deletes.`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function truncate(s, n) {
|
|
146
|
+
const flat = s.replace(/\s+/g, ' ');
|
|
147
|
+
return flat.length <= n ? flat : flat.slice(0, n - 1) + '…';
|
|
148
|
+
}
|
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,79 @@
|
|
|
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, model, 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, model: model ?? ledger.model, rules: rules.map(stripBlame) },
|
|
62
|
+
counts,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
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
|
+
function stripBlame({ blame, ...rule }) {
|
|
74
|
+
return rule;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isoToday() {
|
|
78
|
+
return new Date().toISOString().slice(0, 10);
|
|
79
|
+
}
|
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/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,44 @@
|
|
|
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 { detectModel } from './model.js';
|
|
6
|
+
import { loadLedger, saveLedger, mergeScan, staleVerdicts, isoToday } from './ledger.js';
|
|
7
|
+
|
|
8
|
+
export const SOURCES = ['CLAUDE.md', 'AGENTS.md'];
|
|
9
|
+
|
|
10
|
+
export function scan(dir) {
|
|
11
|
+
const modes = {};
|
|
12
|
+
const scanned = [];
|
|
13
|
+
|
|
14
|
+
for (const source of SOURCES) {
|
|
15
|
+
const path = join(dir, source);
|
|
16
|
+
if (!existsSync(path)) continue;
|
|
17
|
+
|
|
18
|
+
const mode = fileMode(dir, source);
|
|
19
|
+
modes[source] = mode;
|
|
20
|
+
|
|
21
|
+
const rules = parseRules(readFileSync(path, 'utf8'), source);
|
|
22
|
+
const blame = mode === 'tracked' ? blameDates(dir, source) : null;
|
|
23
|
+
|
|
24
|
+
for (const rule of rules) {
|
|
25
|
+
scanned.push(blame ? { ...rule, blame: blame.get(rule.line) ?? null } : rule);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
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 });
|
|
34
|
+
const path = saveLedger(dir, ledger);
|
|
35
|
+
|
|
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
|
+
};
|
|
44
|
+
}
|