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 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
- # 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 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).
@@ -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.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
+ }