heeded 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 +21 -0
- package/README.md +127 -2
- package/dist/check.js +493 -0
- package/dist/claude-code.js +250 -0
- package/dist/cli.js +215 -0
- package/dist/judge.js +359 -0
- package/dist/model.js +112 -0
- package/dist/redact.js +21 -0
- package/dist/report.js +173 -0
- package/dist/rules.js +376 -0
- package/dist/shell.js +182 -0
- package/package.json +23 -4
|
@@ -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,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The heeded command: arguments, the two commands, the question before spending, exit codes.
|
|
3
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { 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 { estimate, extract, MODEL, remember, rulesOf } from './rules.js';
|
|
13
|
+
const HELP = `heeded — did your coding agent follow its instructions?
|
|
14
|
+
|
|
15
|
+
usage
|
|
16
|
+
heeded report on the claude code sessions run in this folder
|
|
17
|
+
heeded rules the rules found, and how each one is checked
|
|
18
|
+
|
|
19
|
+
options
|
|
20
|
+
--all judge every case waiting, not one run's 150k tokens
|
|
21
|
+
--json the report as json
|
|
22
|
+
--help, --version
|
|
23
|
+
|
|
24
|
+
reads CLAUDE_CONFIG_DIR, else ~/.claude. caches rules and verdicts in XDG_CACHE_HOME, else ~/.cache.
|
|
25
|
+
`;
|
|
26
|
+
/** One pass over the folder's transcripts with the rules and verdicts cached so far: the report, the sections to read,
|
|
27
|
+
* the cases waiting for the judge, and what the most the rules may still take would cost. */
|
|
28
|
+
async function scan(configDir, folder, cacheDir) {
|
|
29
|
+
const count = counter(rulesOf(cacheDir), verdicts(cacheDir));
|
|
30
|
+
const { unrecognized } = await read(configDir, folder, count.on);
|
|
31
|
+
for (const v of count.readVersions())
|
|
32
|
+
remember(cacheDir, v.identity, v.section.hash, v.at);
|
|
33
|
+
const data = count.result();
|
|
34
|
+
const waiting = count.waiting();
|
|
35
|
+
// A tool rule lists 40 of its candidates: the others cost what those listed cost on average.
|
|
36
|
+
const listed = [...waiting.tool, ...waiting.judgment].flat();
|
|
37
|
+
const each = listed.length ? judgingEstimate(listed).tokens / listed.length : 0;
|
|
38
|
+
return { report: { folder, configDir, unrecognized, ...data, toJudgeTokens: Math.round(each * data.toJudge) },
|
|
39
|
+
pending: count.pending(), waiting, mostTokens: Math.round(each * data.atMost) };
|
|
40
|
+
}
|
|
41
|
+
const thousands = (n) => n.toLocaleString('en-US');
|
|
42
|
+
const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? '' : 's'}`;
|
|
43
|
+
const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
|
44
|
+
/** Runs a step that calls claude, with a status line meanwhile: what it does, the calls done when it makes several, the
|
|
45
|
+
* time so far, shown again every second. A call takes up to a minute or two: without it the run looks stuck. */
|
|
46
|
+
async function step(io, doing, work) {
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
let calls = '';
|
|
49
|
+
const show = () => io.status(`${doing}${calls} · ${clock(Date.now() - start)}`);
|
|
50
|
+
const timer = setInterval(show, 1000);
|
|
51
|
+
try {
|
|
52
|
+
return await work((done, of) => {
|
|
53
|
+
calls = of > 1 ? ` · ${done} of ${of} calls done` : '';
|
|
54
|
+
show();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearInterval(timer);
|
|
59
|
+
io.status('');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Runs heeded; resolves to the exit code: 0 done (violations included), 1 an error, 2 wrong usage. */
|
|
63
|
+
export async function main(argv, io) {
|
|
64
|
+
const usage = (what) => {
|
|
65
|
+
io.err(`heeded: ${what}. see heeded --help.\n`);
|
|
66
|
+
return 2;
|
|
67
|
+
};
|
|
68
|
+
let args;
|
|
69
|
+
try {
|
|
70
|
+
args = parseArgs({ args: argv, allowPositionals: true,
|
|
71
|
+
options: { all: { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean' },
|
|
72
|
+
version: { type: 'boolean' } } });
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
return usage(`unknown option ${/'([^']*)'/.exec(e.message)?.[1] ?? ''}`.trim());
|
|
76
|
+
}
|
|
77
|
+
const [command, ...rest] = args.positionals;
|
|
78
|
+
if (command !== undefined && command !== 'rules')
|
|
79
|
+
return usage(`unknown command ${command}`);
|
|
80
|
+
if (rest.length)
|
|
81
|
+
return usage(`unexpected ${rest.join(' ')}`);
|
|
82
|
+
if (args.values.help) {
|
|
83
|
+
io.out(HELP);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (args.values.version) {
|
|
87
|
+
io.out(`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return await run(command, args.values.json === true, args.values.all === true, io);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
io.err(`heeded: ${String(e.message).split('\n')[0]}\n`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The report, after reading new sections and judging cases if the user says so: within one run's budget, or, with
|
|
99
|
+
* `all`, round after round until no case waits. */
|
|
100
|
+
async function run(command, asJson, all, io) {
|
|
101
|
+
const home = io.env.HOME || homedir();
|
|
102
|
+
const configDir = io.env.CLAUDE_CONFIG_DIR || join(home, '.claude');
|
|
103
|
+
const cacheDir = io.env.XDG_CACHE_HOME || join(home, '.cache');
|
|
104
|
+
let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir);
|
|
105
|
+
let code = 0;
|
|
106
|
+
const failed = (errors) => {
|
|
107
|
+
for (const e of errors)
|
|
108
|
+
io.err(`heeded: ${e}\n`);
|
|
109
|
+
if (errors.length)
|
|
110
|
+
code = 1;
|
|
111
|
+
};
|
|
112
|
+
// ctrl-c or ctrl-d at the question is a no.
|
|
113
|
+
const yes = async () => /^y(es)?$/i.test((await io.ask('go ahead? [y/N] ').catch(() => '')).trim());
|
|
114
|
+
const distinct = [...new Map(pending.map((p) => [p.section.hash, p.section])).values()];
|
|
115
|
+
/** Reads the new sections, then counts again: the rules just read have cases of their own. False when none was
|
|
116
|
+
* read. */
|
|
117
|
+
const readSections = async () => {
|
|
118
|
+
const found = await step(io, `reading ${plural(distinct.length, 'rule section')}`, (progress) => extract(pending, io.env, cacheDir, progress));
|
|
119
|
+
if (found.read)
|
|
120
|
+
io.err(`read ${plural(found.read, 'section')} · ${thousands(found.tokens)} tokens\n`);
|
|
121
|
+
failed(found.errors);
|
|
122
|
+
if (found.read)
|
|
123
|
+
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
|
|
124
|
+
return found.read > 0;
|
|
125
|
+
};
|
|
126
|
+
/** Judges the cases a round picks, then counts again; with `all`, again while cases wait and the last round judged
|
|
127
|
+
* some. One line for every round. */
|
|
128
|
+
const judgeCases = async () => {
|
|
129
|
+
let judged = 0;
|
|
130
|
+
let tokens = 0;
|
|
131
|
+
const errors = [];
|
|
132
|
+
for (;;) {
|
|
133
|
+
const picked = select(waiting.tool, waiting.judgment, all ? Infinity : BUDGET);
|
|
134
|
+
if (!picked.length)
|
|
135
|
+
break;
|
|
136
|
+
const done = await step(io, `judging ${plural(picked.length, 'case')}`, (progress) => judge(picked, io.env, cacheDir, progress));
|
|
137
|
+
judged += done.judged;
|
|
138
|
+
tokens += done.tokens;
|
|
139
|
+
errors.push(...done.errors);
|
|
140
|
+
if (done.judged)
|
|
141
|
+
({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
|
|
142
|
+
if (!all || !done.judged)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if (judged)
|
|
146
|
+
io.err(`judged ${plural(judged, 'case')} · ${thousands(tokens)} tokens\n`);
|
|
147
|
+
failed(errors);
|
|
148
|
+
};
|
|
149
|
+
// Reading sections and judging cases spend the user's quota: only when asked on a terminal, after what the run may
|
|
150
|
+
// spend. Every call costs ~4k tokens: an estimate is never under a thousand.
|
|
151
|
+
if (io.terminal && (distinct.length || report.toJudge > 0)) {
|
|
152
|
+
if (!all) {
|
|
153
|
+
// One question. The cases of the rules about to be read aren't known yet: with sections to read, it shows the
|
|
154
|
+
// ceiling.
|
|
155
|
+
if (distinct.length) {
|
|
156
|
+
const models = [...new Set([MODEL, JUDGE_MODEL])].join(' and ');
|
|
157
|
+
io.err(`${plural(distinct.length, 'rule section')} to read, then cases to judge · claude ${models}`
|
|
158
|
+
+ ` · up to ${approx(estimate(distinct).tokens + BUDGET)} tokens\n`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const picked = select(waiting.tool, waiting.judgment, BUDGET);
|
|
162
|
+
const some = picked.length < report.toJudge;
|
|
163
|
+
const cases = some ? `${thousands(picked.length)} of ${plural(report.toJudge, 'case')}`
|
|
164
|
+
: plural(picked.length, 'case');
|
|
165
|
+
io.err(`${cases} to judge · claude ${JUDGE_MODEL} · ${approx(judgingEstimate(picked).tokens)} tokens`
|
|
166
|
+
+ `${some ? ` · all of them ${approx(report.toJudgeTokens)}: heeded --all` : ''}\n`);
|
|
167
|
+
}
|
|
168
|
+
if (await yes() && (!distinct.length || await readSections()))
|
|
169
|
+
await judgeCases();
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Every case: what judging them costs is known once the new sections are read, so the reading is asked first.
|
|
173
|
+
let reading = true;
|
|
174
|
+
if (distinct.length) {
|
|
175
|
+
io.err(`${plural(distinct.length, 'rule section')} to read first · claude ${MODEL}`
|
|
176
|
+
+ ` · ${approx(estimate(distinct).tokens)} tokens\n`);
|
|
177
|
+
reading = await yes() && await readSections();
|
|
178
|
+
}
|
|
179
|
+
if (reading && report.toJudge > 0) {
|
|
180
|
+
const more = report.atMost > report.toJudge;
|
|
181
|
+
io.err(`${plural(report.toJudge, 'case')} to judge${more ? ', more if their rules apply' : ''}`
|
|
182
|
+
+ ` · claude ${JUDGE_MODEL} · ${approx(report.toJudgeTokens)} tokens`
|
|
183
|
+
+ `${more ? `, at most ${approx(mostTokens)}` : ''}\n`);
|
|
184
|
+
if (await yes())
|
|
185
|
+
await judgeCases();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const color = io.columns !== undefined && !io.env.NO_COLOR;
|
|
190
|
+
if (asJson)
|
|
191
|
+
io.out(json(report));
|
|
192
|
+
else if (command === 'rules' && report.sessions && report.files.length)
|
|
193
|
+
io.out(rules(report, { color, home }));
|
|
194
|
+
else
|
|
195
|
+
io.out(text(report, { columns: io.columns ?? 80, color, now: new Date(), home }));
|
|
196
|
+
return code;
|
|
197
|
+
}
|
|
198
|
+
// Run as the `heeded` bin (npm links it, so its real path is compared), not when imported.
|
|
199
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === import.meta.filename) {
|
|
200
|
+
const ask = async (question) => {
|
|
201
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
202
|
+
try {
|
|
203
|
+
return await rl.question(question);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
rl.close();
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
210
|
+
env: process.env, cwd: process.cwd(), terminal: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
211
|
+
columns: process.stdout.isTTY ? process.stdout.columns : undefined, ask,
|
|
212
|
+
out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s),
|
|
213
|
+
status: (line) => process.stderr.write(`\r\x1b[K${line}`),
|
|
214
|
+
});
|
|
215
|
+
}
|