heeded 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,250 @@
1
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /** A folder's entries by name; none when it doesn't exist. */
4
+ async function entries(dir) {
5
+ try {
6
+ return (await readdir(dir, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1));
7
+ }
8
+ catch {
9
+ return [];
10
+ }
11
+ }
12
+ /** A file's lines as its bytes arrive, never the whole file in memory. */
13
+ async function* lines(file) {
14
+ const decoder = new TextDecoder();
15
+ let rest = '';
16
+ for await (const chunk of file.createReadStream()) {
17
+ const text = decoder.decode(chunk, { stream: true });
18
+ let start = 0;
19
+ for (let end = text.indexOf('\n'); end >= 0; end = text.indexOf('\n', start)) {
20
+ yield rest + text.slice(start, end);
21
+ rest = '';
22
+ start = end + 1;
23
+ }
24
+ rest += text.slice(start);
25
+ }
26
+ rest += decoder.decode();
27
+ if (rest)
28
+ yield rest;
29
+ }
30
+ const isFile = (f) => typeof f === 'object' && f !== null
31
+ && typeof f.path === 'string' && typeof f.type === 'string'
32
+ && typeof f.content === 'string';
33
+ /** The tools whose result is what the user answered: a choice among options, a plan approved or not. */
34
+ const ASKING = new Set(['AskUserQuestion', 'ExitPlanMode']);
35
+ function flush(state, emit) {
36
+ if (state.reply)
37
+ emit(state.reply);
38
+ state.reply = null;
39
+ }
40
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
41
+ const tokens = (v) => (typeof v === 'number' ? v : 0);
42
+ /** A message's text: a string, or its text blocks joined (images and the rest left out). */
43
+ const textOf = (c) => (typeof c === 'string' ? c
44
+ : Array.isArray(c) ? c.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n') : '');
45
+ /** A launcher that appends the user's own instructions to the system prompt marks them with this tag, opened on a line
46
+ * of its own (the tag named in a sentence is not a block); the rest of the system prompt is Claude Code's, or the
47
+ * launcher's. */
48
+ const INSTRUCTIONS = /(?:^|\n)<user-instructions>([\s\S]*?)<\/user-instructions>/g;
49
+ /** The events of one line; false when it should have parsed and didn't. Only the lines heeded needs are parsed, found
50
+ * by a substring first: a tool result, the bulk of a transcript, only when it answers one of the agent's questions; the
51
+ * system prompt only when it holds the user's instructions. */
52
+ function onLine(line, state, out) {
53
+ const user = line.includes('"type":"user"');
54
+ const result = user ? /"tool_use_id":"([^"]*)"/.exec(line) : null;
55
+ if (!line.includes('"type":"assistant"') && !line.includes('"type":"instructions"')
56
+ && !line.includes('"type":"nested_memory"') && !line.includes('"compact_boundary"') && !line.includes('"queued_command"')
57
+ && !(line.includes('"prompt_snapshot"') && line.includes('<user-instructions>'))
58
+ && !(user && (!result || state.asked.has(result[1]))))
59
+ return true;
60
+ let o;
61
+ try {
62
+ o = JSON.parse(line);
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ // A message's reply goes out before an event that changes the rules or the compactions; its own calls don't.
68
+ const emit = (e) => {
69
+ flush(state, out);
70
+ out(e);
71
+ };
72
+ const a = o.attachment;
73
+ const meta = o.compactMetadata;
74
+ const m = o.message;
75
+ if (o.type === 'assistant' && isObject(m) && Array.isArray(m.content) && typeof o.timestamp === 'string'
76
+ && !Number.isNaN(Date.parse(o.timestamp))) {
77
+ // The context the request was sent with: every line of a message carries the same usage.
78
+ const u = m.usage;
79
+ if (isObject(u))
80
+ state.depth = tokens(u.input_tokens) + tokens(u.cache_creation_input_tokens) + tokens(u.cache_read_input_tokens);
81
+ for (const b of m.content) {
82
+ // Replies are what the user reads: a subagent's text goes to the agent that called it.
83
+ if (b?.type === 'text' && typeof b.text === 'string' && typeof m.id === 'string' && state.replies) {
84
+ if (state.reply && state.of === m.id) {
85
+ state.reply.text += `\n${b.text}`;
86
+ }
87
+ else {
88
+ flush(state, out);
89
+ if (state.of !== m.id)
90
+ [state.of, state.parts] = [m.id, 0];
91
+ // A message's text after one of its calls is a reply of its own.
92
+ const id = state.parts ? `${m.id}/${state.parts}` : m.id;
93
+ state.reply = { kind: 'reply', id, text: b.text, at: o.timestamp, depth: state.depth, compactions: state.compactions };
94
+ }
95
+ }
96
+ else if (b?.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string' && isObject(b.input)) {
97
+ if (ASKING.has(b.name))
98
+ state.asked.add(b.id);
99
+ // What the agent said goes out before the call it said it before.
100
+ if (state.reply) {
101
+ flush(state, out);
102
+ if (state.of === m.id)
103
+ state.parts++;
104
+ }
105
+ const own = typeof b.input.file_path === 'string' && b.input.file_path.startsWith(state.own);
106
+ out({ kind: 'call', id: b.id, tool: b.name, input: b.input, at: o.timestamp, depth: state.depth, compactions: state.compactions,
107
+ ...(own ? { own } : {}) });
108
+ }
109
+ }
110
+ }
111
+ else if (o.type === 'user' && isObject(m) && !o.isMeta && !o.isCompactSummary && typeof o.timestamp === 'string'
112
+ && !Number.isNaN(Date.parse(o.timestamp))) {
113
+ const results = Array.isArray(m.content) ? m.content.filter((b) => b?.type === 'tool_result') : [];
114
+ for (const b of results) {
115
+ const text = state.asked.has(b.tool_use_id) ? textOf(b.content) : '';
116
+ if (text.trim())
117
+ emit({ kind: 'prompt', text, at: o.timestamp, by: 'user', starts: false });
118
+ }
119
+ const text = results.length ? '' : textOf(m.content);
120
+ const start = text.trimStart();
121
+ const command = /^<command-(?:name|message)>/.test(start) && /<command-name>([^<]*)<\/command-name>/.exec(start);
122
+ if (command) {
123
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(start)?.[1] ?? '';
124
+ emit({ kind: 'prompt', text: `${command[1]} ${args}`.trim(), at: o.timestamp, by: 'user', starts: false });
125
+ }
126
+ else if (start && start[0] !== '<' && !start.startsWith('[Request interrupted')) {
127
+ // Other lines starting with a tag are Claude Code's own (a command's output, a notification), as is an
128
+ // interruption's note.
129
+ emit({ kind: 'prompt', text, at: o.timestamp, by: state.replies ? 'user' : 'agent', starts: true });
130
+ }
131
+ }
132
+ else if (o.type === 'attachment' && a?.type === 'queued_command' && a.commandMode === 'prompt' && a.origin?.kind === 'human'
133
+ && typeof o.timestamp === 'string' && !Number.isNaN(Date.parse(o.timestamp))) {
134
+ // What the user typed while the agent worked reaches it mid-turn, recorded only here.
135
+ const text = textOf(a.prompt);
136
+ if (text.trim())
137
+ emit({ kind: 'prompt', text, at: o.timestamp, by: 'user', starts: false });
138
+ }
139
+ else if (o.type === 'attachment' && a?.type === 'prompt_snapshot' && Array.isArray(a.systemPrompt)
140
+ && typeof o.timestamp === 'string' && !Number.isNaN(Date.parse(o.timestamp))) {
141
+ const parts = a.systemPrompt.filter((p) => typeof p === 'string');
142
+ const text = parts.flatMap((p) => [...p.matchAll(INSTRUCTIONS)].map((m) => m[1].trim())).filter(Boolean).join('\n');
143
+ if (text)
144
+ emit({ kind: 'prompt', text, at: o.timestamp, by: 'user', starts: false, session: true });
145
+ }
146
+ else if (o.type === 'attachment' && a?.type === 'instructions' && Array.isArray(a.files)) {
147
+ // After a compaction Claude Code records every file in effect again: they replace the ones before.
148
+ emit({ kind: 'instructions', files: a.files.filter(isFile), replace: state.replace });
149
+ state.replace = false;
150
+ }
151
+ else if (o.type === 'attachment' && a?.type === 'nested_memory' && isFile(a.content)) {
152
+ const { path, type, content } = a.content;
153
+ emit({ kind: 'instructions', files: [{ path, type, content }], replace: false });
154
+ }
155
+ else if (o.type === 'system' && o.subtype === 'compact_boundary' && typeof meta?.trigger === 'string'
156
+ && typeof meta.preTokens === 'number') {
157
+ emit({ kind: 'compaction', trigger: meta.trigger, preTokens: meta.preTokens });
158
+ state.compactions++;
159
+ state.replace = true;
160
+ }
161
+ return true;
162
+ }
163
+ /**
164
+ * Streams one transcript into events, after its `transcript` event. A main transcript (`inside` given) is read only
165
+ * when it ran in the folder: decided by the first line that records a `cwd`, the only line parsed for it; a file that
166
+ * never records one stays in scope, since its project folder's name already matched. Events of the lines before that
167
+ * one wait for the decision.
168
+ *
169
+ * A file read whole is unrecognized when, from 20 lines on, more than half don't parse (a line that isn't `{…}`, or one
170
+ * heeded parses and can't) or none is an assistant or user line: Claude Code may have changed its format, and an empty
171
+ * result would hide it.
172
+ */
173
+ async function readFile(path, session, agent, inside, own, emit, unrecognized) {
174
+ const file = await open(path).catch(() => null);
175
+ if (!file)
176
+ return false; // deleted since it was listed, or not readable: skipped
177
+ let decided = inside === null;
178
+ const held = [];
179
+ if (decided)
180
+ emit({ kind: 'transcript', session, agent });
181
+ const out = (e) => (decided ? emit(e) : held.push(e));
182
+ const state = { compactions: 0, replace: false, depth: 0, replies: agent === null, reply: null, of: '', parts: 0,
183
+ asked: new Set(), own };
184
+ let total = 0;
185
+ let bad = 0;
186
+ let messages = false;
187
+ for await (const line of lines(file)) {
188
+ if (!line)
189
+ continue;
190
+ total++;
191
+ if (!decided && line.includes('"cwd":"')) {
192
+ let cwd;
193
+ try {
194
+ cwd = JSON.parse(line).cwd;
195
+ }
196
+ catch { }
197
+ if (typeof cwd === 'string') {
198
+ if (!inside(cwd))
199
+ return false;
200
+ decided = true;
201
+ emit({ kind: 'transcript', session, agent });
202
+ held.forEach(emit);
203
+ }
204
+ }
205
+ if (line.includes('"type":"assistant"') || line.includes('"type":"user"'))
206
+ messages = true;
207
+ if (line[0] !== '{' || !line.endsWith('}') || !onLine(line, state, out))
208
+ bad++;
209
+ }
210
+ flush(state, out);
211
+ if (total >= 20 && (bad * 2 > total || !messages))
212
+ unrecognized.push(path);
213
+ if (!decided && total > 0) {
214
+ emit({ kind: 'transcript', session, agent });
215
+ held.forEach(emit);
216
+ }
217
+ return true;
218
+ }
219
+ /** Claude Code names a project's folder after its path, every character but a letter or a digit turned into `-`. */
220
+ const encode = (path) => path.replace(/[^a-zA-Z0-9]/g, '-');
221
+ export async function read(configDir, folder, emit) {
222
+ const unrecognized = [];
223
+ // Claude Code records the path it was started from, which may be the folder's real path or a symlink to it.
224
+ const folders = [...new Set([folder, await realpath(folder).catch(() => folder)])];
225
+ const names = folders.map(encode);
226
+ const inside = (cwd) => folders.some((f) => cwd === f || cwd.startsWith(`${f}/`));
227
+ const projects = join(configDir, 'projects');
228
+ for (const dir of await entries(projects)) {
229
+ if (!dir.isDirectory() || !names.some((n) => dir.name === n || dir.name.startsWith(`${n}-`)))
230
+ continue;
231
+ const project = join(projects, dir.name);
232
+ for (const file of await entries(project)) {
233
+ if (!file.isFile() || !file.name.endsWith('.jsonl'))
234
+ continue;
235
+ const session = file.name.slice(0, -'.jsonl'.length);
236
+ if (!(await readFile(join(project, file.name), session, null, inside, `${projects}/`, emit, unrecognized)))
237
+ continue;
238
+ const subagents = join(project, session, 'subagents');
239
+ const real = async (dir) => (await lstat(dir).catch(() => null))?.isDirectory();
240
+ if (!(await real(join(project, session))) || !(await real(subagents)))
241
+ continue;
242
+ for (const sub of await entries(subagents)) {
243
+ if (!sub.isFile() || !sub.name.startsWith('agent-') || !sub.name.endsWith('.jsonl'))
244
+ continue;
245
+ await readFile(join(subagents, sub.name), session, sub.name.slice('agent-'.length, -'.jsonl'.length), null, `${projects}/`, emit, unrecognized);
246
+ }
247
+ }
248
+ }
249
+ return { unrecognized };
250
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ // The heeded command: arguments, the two commands, the question before spending, exit codes.
3
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { parseArgs } from 'node:util';
8
+ import { counter } from './check.js';
9
+ import { read } from './claude-code.js';
10
+ import { BUDGET, estimate as judgingEstimate, JUDGE_MODEL, judge, select, verdicts } from './judge.js';
11
+ import { approx, json, rules, text } from './report.js';
12
+ import { redact } from './redact.js';
13
+ import { estimate, extract, lineOf, MODEL, remember, rulesOf } from './rules.js';
14
+ const HELP = `heeded — did your coding agent follow its instructions?
15
+
16
+ usage
17
+ heeded report on the claude code sessions run in this folder
18
+ heeded rules the rules found, and how each one is checked
19
+
20
+ options
21
+ --all judge every case waiting, not one run's 150k tokens
22
+ --json the report as json
23
+ --help, --version
24
+
25
+ reads CLAUDE_CONFIG_DIR, else ~/.claude. caches rules and verdicts in XDG_CACHE_HOME, else ~/.cache.
26
+ `;
27
+ /** One pass over the folder's transcripts with the rules and verdicts cached so far: the report, the sections to read,
28
+ * the cases waiting for the judge, and what the most the rules may still take would cost. */
29
+ async function scan(configDir, folder, cacheDir, home) {
30
+ const count = counter(rulesOf(cacheDir), verdicts(cacheDir));
31
+ const { unrecognized } = await read(configDir, folder, count.on);
32
+ for (const v of count.readVersions())
33
+ remember(cacheDir, v.identity, v.section.hash, v.at);
34
+ const data = count.result();
35
+ // Where each rule is written now, what the user opens to rewrite or remove it.
36
+ // Claude Code reads the user's own file from ~/.claude whatever the config dir.
37
+ const content = onDisk(folder, [configDir, join(home, '.claude')]);
38
+ for (const rule of data.rules) {
39
+ for (const file of rule.files) {
40
+ const line = lineOf(content(file), rule.quote, rule.heading);
41
+ if (line)
42
+ rule.lines[file] = line;
43
+ }
44
+ }
45
+ const waiting = count.waiting();
46
+ // A tool rule lists 40 of its candidates: the others cost what those listed cost on average.
47
+ const listed = [...waiting.tool, ...waiting.judgment].flat();
48
+ const each = listed.length ? judgingEstimate(listed).tokens / listed.length : 0;
49
+ return { report: { folder, configDir, unrecognized, ...data, toJudgeTokens: Math.round(each * data.toJudge) },
50
+ pending: count.pending(), waiting, mostTokens: Math.round(each * data.atMost) };
51
+ }
52
+ /** Instruction files as they are on disk now, secrets redacted as in what their rules were read from (a redaction
53
+ * keeps every line); empty for a file gone. Read only in the folder, the folders above it and the agent's own: where
54
+ * the user already gave access, so no privacy prompt for another folder. And only a regular file under 1 MB: a path
55
+ * from a transcript may name a pipe, which would never end. */
56
+ function onDisk(folder, own) {
57
+ const read = new Map();
58
+ const allowed = (file) => [folder, ...own].some((dir) => file.startsWith(`${dir}/`))
59
+ || `${folder}/`.startsWith(dirname(file).replace(/\/?$/, '/'));
60
+ return (file) => {
61
+ if (!read.has(file)) {
62
+ let text = '';
63
+ try {
64
+ const stat = allowed(file) ? statSync(file) : null;
65
+ if (stat?.isFile() && stat.size < 1_000_000)
66
+ text = redact(readFileSync(file, 'utf8'));
67
+ }
68
+ catch {
69
+ // gone, or not readable: no line
70
+ }
71
+ read.set(file, text);
72
+ }
73
+ return read.get(file);
74
+ };
75
+ }
76
+ const thousands = (n) => n.toLocaleString('en-US');
77
+ const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? '' : 's'}`;
78
+ const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
79
+ /** Runs a step that calls claude, with a status line meanwhile: what it does, the calls done when it makes several, the
80
+ * time so far, shown again every second. A call takes up to a minute or two: without it the run looks stuck. */
81
+ async function step(io, doing, work) {
82
+ const start = Date.now();
83
+ let calls = '';
84
+ const show = () => io.status(`${doing}${calls} · ${clock(Date.now() - start)}`);
85
+ const timer = setInterval(show, 1000);
86
+ try {
87
+ return await work((done, of) => {
88
+ calls = of > 1 ? ` · ${done} of ${of} calls done` : '';
89
+ show();
90
+ });
91
+ }
92
+ finally {
93
+ clearInterval(timer);
94
+ io.status('');
95
+ }
96
+ }
97
+ /** Runs heeded; resolves to the exit code: 0 done (violations included), 1 an error, 2 wrong usage. */
98
+ export async function main(argv, io) {
99
+ const usage = (what) => {
100
+ io.err(`heeded: ${what}. see heeded --help.\n`);
101
+ return 2;
102
+ };
103
+ let args;
104
+ try {
105
+ args = parseArgs({ args: argv, allowPositionals: true,
106
+ options: { all: { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean' },
107
+ version: { type: 'boolean' } } });
108
+ }
109
+ catch (e) {
110
+ return usage(`unknown option ${/'([^']*)'/.exec(e.message)?.[1] ?? ''}`.trim());
111
+ }
112
+ const [command, ...rest] = args.positionals;
113
+ if (command !== undefined && command !== 'rules')
114
+ return usage(`unknown command ${command}`);
115
+ if (rest.length)
116
+ return usage(`unexpected ${rest.join(' ')}`);
117
+ if (command === 'rules' && args.values.all)
118
+ return usage('--all judges cases, and heeded rules never judges');
119
+ if (args.values.help) {
120
+ io.out(HELP);
121
+ return 0;
122
+ }
123
+ if (args.values.version) {
124
+ io.out(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`);
125
+ return 0;
126
+ }
127
+ try {
128
+ return await run(command, args.values.json === true, args.values.all === true, io);
129
+ }
130
+ catch (e) {
131
+ io.err(`heeded: ${String(e.message).split('\n')[0]}\n`);
132
+ return 1;
133
+ }
134
+ }
135
+ /** The report, after reading new sections and judging cases if the user says so: within one run's budget, or, with
136
+ * `all`, round after round until no case waits. */
137
+ async function run(command, asJson, all, io) {
138
+ const home = io.env.HOME || homedir();
139
+ const configDir = io.env.CLAUDE_CONFIG_DIR || join(home, '.claude');
140
+ const cacheDir = io.env.XDG_CACHE_HOME || join(home, '.cache');
141
+ let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home);
142
+ let code = 0;
143
+ const failed = (errors) => {
144
+ for (const e of errors)
145
+ io.err(`heeded: ${e}\n`);
146
+ if (errors.length)
147
+ code = 1;
148
+ };
149
+ // ctrl-c or ctrl-d at the question is a no.
150
+ const yes = async () => /^y(es)?$/i.test((await io.ask('go ahead? [y/N] ').catch(() => '')).trim());
151
+ const distinct = [...new Map(pending.map((p) => [p.section.hash, p.section])).values()];
152
+ /** Reads the new sections, then counts again: the rules just read have cases of their own. False when none was
153
+ * read. */
154
+ const readSections = async () => {
155
+ const found = await step(io, `reading ${plural(distinct.length, 'rule section')}`, (progress) => extract(pending, io.env, cacheDir, progress));
156
+ if (found.read)
157
+ io.err(`read ${plural(found.read, 'section')} · ${thousands(found.tokens)} tokens\n`);
158
+ failed(found.errors);
159
+ if (found.read)
160
+ ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
161
+ return found.read > 0;
162
+ };
163
+ /** Asks about the new sections alone, and reads them on yes. */
164
+ const askToRead = async (then) => {
165
+ io.err(`${plural(distinct.length, 'rule section')} to read${then} · claude ${MODEL}`
166
+ + ` · ${approx(estimate(distinct).tokens)} tokens\n`);
167
+ return await yes() && await readSections();
168
+ };
169
+ /** Judges the cases a round picks, then counts again; with `all`, again while cases wait and the last round judged
170
+ * some. One line for every round. */
171
+ const judgeCases = async () => {
172
+ let judged = 0;
173
+ let tokens = 0;
174
+ const errors = [];
175
+ for (;;) {
176
+ const picked = select(waiting.tool, waiting.judgment, all ? Infinity : BUDGET);
177
+ if (!picked.length)
178
+ break;
179
+ const done = await step(io, `judging ${plural(picked.length, 'case')}`, (progress) => judge(picked, io.env, cacheDir, progress));
180
+ judged += done.judged;
181
+ tokens += done.tokens;
182
+ errors.push(...done.errors);
183
+ if (done.judged)
184
+ ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
185
+ if (!all || !done.judged)
186
+ break;
187
+ }
188
+ if (judged)
189
+ io.err(`judged ${plural(judged, 'case')} · ${thousands(tokens)} tokens\n`);
190
+ failed(errors);
191
+ };
192
+ // Reading sections and judging cases spend the user's quota: only when asked on a terminal, after what the run may
193
+ // spend. Every call costs ~4k tokens: an estimate is never under a thousand.
194
+ if (io.terminal && command === 'rules') {
195
+ // The rules and how each is checked: no verdict changes them, only new sections do.
196
+ if (distinct.length)
197
+ await askToRead('');
198
+ }
199
+ else if (io.terminal && (distinct.length || report.toJudge > 0)) {
200
+ if (!all) {
201
+ // One question. The cases of the rules about to be read aren't known yet: with sections to read, it shows the
202
+ // ceiling.
203
+ if (distinct.length) {
204
+ const models = [...new Set([MODEL, JUDGE_MODEL])].join(' and ');
205
+ io.err(`${plural(distinct.length, 'rule section')} to read, then cases to judge · claude ${models}`
206
+ + ` · up to ${approx(estimate(distinct).tokens + BUDGET)} tokens\n`);
207
+ }
208
+ else {
209
+ const picked = select(waiting.tool, waiting.judgment, BUDGET);
210
+ const some = picked.length < report.toJudge;
211
+ const cases = some ? `${thousands(picked.length)} of ${plural(report.toJudge, 'case')}`
212
+ : plural(picked.length, 'case');
213
+ io.err(`${cases} to judge · claude ${JUDGE_MODEL} · ${approx(judgingEstimate(picked).tokens)} tokens`
214
+ + `${some ? ` · all of them ${approx(report.toJudgeTokens)}: heeded --all` : ''}\n`);
215
+ }
216
+ if (await yes() && (!distinct.length || await readSections()))
217
+ await judgeCases();
218
+ }
219
+ else {
220
+ // Every case: what judging them costs is known once the new sections are read, so the reading is asked first.
221
+ const reading = !distinct.length || await askToRead(' first');
222
+ if (reading && report.toJudge > 0) {
223
+ const more = report.atMost > report.toJudge;
224
+ io.err(`${plural(report.toJudge, 'case')} to judge${more ? ', more if their rules apply' : ''}`
225
+ + ` · claude ${JUDGE_MODEL} · ${approx(report.toJudgeTokens)} tokens`
226
+ + `${more ? `, at most ${approx(mostTokens)}` : ''}\n`);
227
+ if (await yes())
228
+ await judgeCases();
229
+ }
230
+ }
231
+ }
232
+ const color = io.columns !== undefined && !io.env.NO_COLOR;
233
+ if (asJson)
234
+ io.out(json(report));
235
+ else if (command === 'rules' && report.sessions && report.files.length)
236
+ io.out(rules(report, { color, home }));
237
+ else
238
+ io.out(text(report, { columns: io.columns ?? 80, color, now: new Date(), home }));
239
+ return code;
240
+ }
241
+ // Run as the `heeded` bin (npm links it, so its real path is compared), not when imported.
242
+ if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
243
+ const ask = async (question) => {
244
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
245
+ try {
246
+ return await rl.question(question);
247
+ }
248
+ finally {
249
+ rl.close();
250
+ }
251
+ };
252
+ process.exitCode = await main(process.argv.slice(2), {
253
+ env: process.env, cwd: process.cwd(), terminal: Boolean(process.stdin.isTTY && process.stderr.isTTY),
254
+ columns: process.stdout.isTTY ? process.stdout.columns : undefined, ask,
255
+ out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s),
256
+ status: (line) => process.stderr.write(`\r\x1b[K${line}`),
257
+ });
258
+ }