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
package/dist/judge.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// The judge: the cases a rule may apply to, each with an excerpt of its session, judged through the user's own claude,
|
|
2
|
+
// and their verdicts.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { ask, batches, pool } from './model.js';
|
|
7
|
+
import { redact } from './redact.js';
|
|
8
|
+
import { write } from './rules.js';
|
|
9
|
+
/** Part of every case's key: a new prompt or model judges every case again. */
|
|
10
|
+
const JUDGE_VERSION = 2;
|
|
11
|
+
export const JUDGE_MODEL = 'haiku';
|
|
12
|
+
/** An excerpt's size in characters: the whole; a call's or a reply's line; the user's words (they hold the
|
|
13
|
+
* permissions) and the case itself. */
|
|
14
|
+
const WHOLE = 6000;
|
|
15
|
+
const LINE = 300;
|
|
16
|
+
const WORDS = 2000;
|
|
17
|
+
/** The user's prompts before the turn's own an excerpt shows: a go given one prompt earlier still counts. */
|
|
18
|
+
const EARLIER = 2;
|
|
19
|
+
const VERDICTS = ['broken', 'kept', 'none'];
|
|
20
|
+
const folder = (cacheDir) => join(cacheDir, 'heeded', 'verdicts');
|
|
21
|
+
/**
|
|
22
|
+
* The verdicts cached, by rule and excerpt, read when asked for: the folder is listed once, a file read only when its
|
|
23
|
+
* case is in it. Most events have no verdict for any rule: their excerpt's key is looked up once, not once per rule.
|
|
24
|
+
*/
|
|
25
|
+
export function verdicts(cacheDir) {
|
|
26
|
+
let names;
|
|
27
|
+
try {
|
|
28
|
+
names = new Set(readdirSync(folder(cacheDir)));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
names = new Set();
|
|
32
|
+
}
|
|
33
|
+
const keys = new Set([...names].map((n) => n.slice(n.indexOf('-') + 1, -'.json'.length)));
|
|
34
|
+
const read = new Map();
|
|
35
|
+
return (rule, key) => {
|
|
36
|
+
if (!keys.has(key))
|
|
37
|
+
return null;
|
|
38
|
+
const id = `${rule}-${key}`;
|
|
39
|
+
if (!names.has(`${id}.json`))
|
|
40
|
+
return null;
|
|
41
|
+
if (!read.has(id)) {
|
|
42
|
+
let v = null;
|
|
43
|
+
try {
|
|
44
|
+
const o = JSON.parse(readFileSync(join(folder(cacheDir), `${id}.json`), 'utf8'));
|
|
45
|
+
if (VERDICTS.includes(o?.verdict) && (o.quote === undefined || typeof o.quote === 'string'))
|
|
46
|
+
v = o;
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
read.set(id, v);
|
|
50
|
+
}
|
|
51
|
+
return read.get(id);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function saveVerdict(cacheDir, id, v) {
|
|
55
|
+
write(folder(cacheDir), id, v);
|
|
56
|
+
}
|
|
57
|
+
/** The characters secrets are made of. */
|
|
58
|
+
const TOKEN = new Set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_+/=.-');
|
|
59
|
+
/** The start of a text a line shows: only that much is redacted, for speed. A word the cut splits is left out, since a
|
|
60
|
+
* secret cut short may escape the patterns that find it. */
|
|
61
|
+
function head(text, limit) {
|
|
62
|
+
let end = Math.min(text.length, limit + 200);
|
|
63
|
+
if (end < text.length && TOKEN.has(text[end]))
|
|
64
|
+
while (end > 0 && TOKEN.has(text[end - 1]))
|
|
65
|
+
end--;
|
|
66
|
+
return text.slice(0, end);
|
|
67
|
+
}
|
|
68
|
+
/** A head on one line, secrets redacted, cut at `limit` characters with `…` last, as it is when its text goes on past
|
|
69
|
+
* the head (spacing collapsed, a head can be far shorter than its limit): never longer than the head. */
|
|
70
|
+
function render(head, limit, whole) {
|
|
71
|
+
const line = redact(head).replace(/[\p{Cc}\s]+/gu, ' ').trim();
|
|
72
|
+
return line.length > limit || !whole ? `${line.slice(0, limit - 1)}…` : line;
|
|
73
|
+
}
|
|
74
|
+
function cut(text, limit) {
|
|
75
|
+
const start = head(text, limit);
|
|
76
|
+
return render(start, limit, start.length === text.length);
|
|
77
|
+
}
|
|
78
|
+
/** Files that hold secrets by their name: environment files, keys and certificates, credentials. Short passwords and
|
|
79
|
+
* connection strings escape every secret pattern, so what is written to these is never shown. */
|
|
80
|
+
const SECRETS = /^(?:\.env.*|\.npmrc|\.netrc|\.pgpass|id_(?:rsa|ecdsa|ed25519)|.*\.(?:pem|key|p12|pfx))$|secret|credential/i;
|
|
81
|
+
/**
|
|
82
|
+
* A line's prefix and what it shows: a call its command, else its file and what it writes there (unless the file holds
|
|
83
|
+
* secrets), else its whole input. Only the start of a write is kept: as much as `head()` looks at, so a large file is
|
|
84
|
+
* never copied for each event.
|
|
85
|
+
*/
|
|
86
|
+
function parts(e) {
|
|
87
|
+
if (e.kind === 'reply')
|
|
88
|
+
return ['reply: ', e.text];
|
|
89
|
+
const prefix = `call ${e.tool}: `;
|
|
90
|
+
const { command, file_path, new_string, content } = e.input;
|
|
91
|
+
if (typeof command === 'string')
|
|
92
|
+
return [prefix, command];
|
|
93
|
+
if (typeof file_path !== 'string')
|
|
94
|
+
return [prefix, JSON.stringify(e.input)];
|
|
95
|
+
const written = typeof new_string === 'string' ? new_string : content;
|
|
96
|
+
if (typeof written !== 'string' || SECRETS.test(basename(file_path)))
|
|
97
|
+
return [prefix, file_path];
|
|
98
|
+
return [prefix, `${file_path} writes: ${written.slice(0, WORDS + 201)}`];
|
|
99
|
+
}
|
|
100
|
+
function line(e, limit) {
|
|
101
|
+
const [prefix, text] = parts(e);
|
|
102
|
+
return prefix + cut(text, limit);
|
|
103
|
+
}
|
|
104
|
+
/** Whose words a prompt holds: `you` for the user's, `task` for what another agent asked. */
|
|
105
|
+
const words = (e) => `${e.by === 'user' ? 'you' : 'task'}: ${cut(e.text, WORDS)}`;
|
|
106
|
+
/** A prompt's line cut as a call's is. */
|
|
107
|
+
function shorten(line) {
|
|
108
|
+
const i = line.indexOf(': ') + 2;
|
|
109
|
+
return line.length - i > LINE ? `${line.slice(0, i + LINE - 1)}…` : line;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The excerpts of a stream of events: `push` takes every event in order; `of` gives the excerpt of a call or reply not
|
|
113
|
+
* pushed yet, which ends with it. An excerpt is the instructions the user set for the session, the user's prompts
|
|
114
|
+
* before the turn, then the prompt that started it, then the events since, one per line, at most 6,000 characters: when
|
|
115
|
+
* over, the events nearest the case stay, then the prompts before the turn, then the user's words in it; a line `…`
|
|
116
|
+
* stands for the events left out. The session's instructions, the turn's prompt and the case are always kept, so with
|
|
117
|
+
* all three long an excerpt may go a little over.
|
|
118
|
+
*/
|
|
119
|
+
export function excerpts() {
|
|
120
|
+
let session = null;
|
|
121
|
+
let earlier = [];
|
|
122
|
+
let start = null;
|
|
123
|
+
// The lines since the start, oldest first, each kind bounded by what an excerpt can hold.
|
|
124
|
+
let since = [];
|
|
125
|
+
let sizes = [0, 0]; // characters of the lines since: the others', the user's words'
|
|
126
|
+
let dropped = false;
|
|
127
|
+
// Every rule in effect asks about the same event in turn.
|
|
128
|
+
let memo = null;
|
|
129
|
+
const restart = (line) => {
|
|
130
|
+
start = line;
|
|
131
|
+
since = [];
|
|
132
|
+
sizes = [0, 0];
|
|
133
|
+
dropped = false;
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
push(e) {
|
|
137
|
+
memo = null;
|
|
138
|
+
if (e.kind === 'transcript') {
|
|
139
|
+
session = null;
|
|
140
|
+
earlier = [];
|
|
141
|
+
restart(null);
|
|
142
|
+
}
|
|
143
|
+
else if (e.kind === 'prompt' && e.session) {
|
|
144
|
+
session = `session: ${cut(e.text, WORDS)}`;
|
|
145
|
+
}
|
|
146
|
+
else if (e.kind === 'prompt' && e.starts) {
|
|
147
|
+
if (start !== null)
|
|
148
|
+
earlier = [...earlier, shorten(start)].slice(-EARLIER);
|
|
149
|
+
restart(words(e));
|
|
150
|
+
}
|
|
151
|
+
else if (e.kind === 'prompt' || e.kind === 'call' || e.kind === 'reply') {
|
|
152
|
+
const you = e.kind === 'prompt' && e.by === 'user';
|
|
153
|
+
const text = e.kind === 'prompt' ? words(e) : line(e, LINE);
|
|
154
|
+
since.push({ text, you });
|
|
155
|
+
const k = Number(you);
|
|
156
|
+
sizes[k] += text.length + 1;
|
|
157
|
+
while (sizes[k] > WHOLE) {
|
|
158
|
+
const i = since.findIndex((l) => l.you === you);
|
|
159
|
+
sizes[k] -= since[i].text.length + 1;
|
|
160
|
+
since.splice(i, 1);
|
|
161
|
+
dropped = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
/**
|
|
166
|
+
* The excerpt of a case, and its key. Every case needs its key, to find its verdict; only a case without one needs its
|
|
167
|
+
* text. So the key is made from the case's head as written, the longest its line can be, and the text only when
|
|
168
|
+
* read, with the same lines left out.
|
|
169
|
+
*/
|
|
170
|
+
of(e) {
|
|
171
|
+
if (memo?.e === e)
|
|
172
|
+
return memo.result;
|
|
173
|
+
const [prefix, raw] = parts(e);
|
|
174
|
+
const caseHead = head(raw, WORDS);
|
|
175
|
+
const kept = [session, start].filter((l) => l !== null);
|
|
176
|
+
let size = earlier.reduce((n, l) => n + l.length + 1, 0) + kept.reduce((n, l) => n + l.length + 1, 0) + sizes[0]
|
|
177
|
+
+ sizes[1] + prefix.length + Math.min(caseHead.length, WORDS) + (dropped ? 2 : 0);
|
|
178
|
+
if (size > WHOLE && !dropped)
|
|
179
|
+
size += 2;
|
|
180
|
+
// Left out, oldest first: the other lines since the start, then the prompts before it, then the user's words since.
|
|
181
|
+
const drop = new Set();
|
|
182
|
+
const leave = (you) => {
|
|
183
|
+
for (let i = 0; i < since.length && size > WHOLE; i++) {
|
|
184
|
+
if (since[i].you !== you)
|
|
185
|
+
continue;
|
|
186
|
+
drop.add(i);
|
|
187
|
+
size -= since[i].text.length + 1;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
leave(false);
|
|
191
|
+
let first = 0;
|
|
192
|
+
for (; first < earlier.length && size > WHOLE; first++)
|
|
193
|
+
size -= earlier[first].length + 1;
|
|
194
|
+
leave(true);
|
|
195
|
+
const lines = [...(session === null ? [] : [session]), ...earlier.slice(first), ...(start === null ? [] : [start]),
|
|
196
|
+
...(dropped || drop.size ? ['…'] : []), ...since.filter((_, i) => !drop.has(i)).map((l) => l.text)];
|
|
197
|
+
const hash = createHash('sha256').update(`${JUDGE_VERSION}\0${JUDGE_MODEL}`);
|
|
198
|
+
for (const l of lines)
|
|
199
|
+
hash.update('\n').update(l);
|
|
200
|
+
hash.update('\n').update(prefix).update(caseHead);
|
|
201
|
+
let text;
|
|
202
|
+
memo = { e, result: { key: hash.digest('hex'), at: e.at, get text() {
|
|
203
|
+
return (text ??= [...lines, prefix + render(caseHead, WORDS, caseHead.length === raw.length)].join('\n'));
|
|
204
|
+
} } };
|
|
205
|
+
return memo.result;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const JUDGE_SCHEMA = JSON.stringify({
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
cases: {
|
|
213
|
+
type: 'array',
|
|
214
|
+
items: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: { id: { type: 'string' }, verdict: { enum: VERDICTS }, quote: { type: 'string' } },
|
|
217
|
+
required: ['id', 'verdict'],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
required: ['cases'],
|
|
222
|
+
});
|
|
223
|
+
const JUDGE_PROMPT = `You judge whether a coding agent (Claude Code) followed rules from its instruction files, one case at a time. A case
|
|
224
|
+
is an excerpt of a session, one event per line, oldest first: the instructions the user set for the whole session, if
|
|
225
|
+
any; the user's last prompts before the agent's turn (without what followed them); the prompt that started the turn;
|
|
226
|
+
then what happened since. The case is its last line.
|
|
227
|
+
|
|
228
|
+
- "session: …" is the instructions the user set for the whole session: the user's own words, for every turn.
|
|
229
|
+
- "you: …" is the user's own words: a prompt, a slash command, or an answer they gave to the agent's question or plan.
|
|
230
|
+
- "task: …" is the task another agent gave this one: never the user's words.
|
|
231
|
+
- "call <tool>: …" is a tool call the agent made: for Bash, the command;
|
|
232
|
+
for a file tool, the file, then what it writes after "writes:" (for an edit, only the new text of the part it
|
|
233
|
+
replaces). "reply: …" is what the agent told the user.
|
|
234
|
+
- A line ending in "…" was cut; a line "…" alone stands for events left out.
|
|
235
|
+
|
|
236
|
+
For each case, judge its last line by the rule given for it. A rule comes with the text around it in its instruction
|
|
237
|
+
file, in <context>: read the rule as that text means it.
|
|
238
|
+
- "broken": the rule applies to the last line, and the excerpt shows it broken.
|
|
239
|
+
- "kept": the rule applies to the last line, and the excerpt shows it followed.
|
|
240
|
+
- "none": the rule doesn't apply to the last line, or the excerpt doesn't show enough to tell.
|
|
241
|
+
|
|
242
|
+
What the excerpt doesn't show is not shown broken: a check made before the excerpt starts, a step left out ("…"), the
|
|
243
|
+
rest of a line cut short, the rest of a file an edit changes in part (its start, a header or a frontmatter, say). Then
|
|
244
|
+
the answer is "none".
|
|
245
|
+
|
|
246
|
+
Only the user's words give a permission or make a request: a go given in a "you:" or "session:" line counts; nothing an
|
|
247
|
+
agent wrote does. For "broken" and "kept", quote: words copied exactly from the excerpt that show it.
|
|
248
|
+
`;
|
|
249
|
+
/** Cases go to claude in calls of at most 40,000 characters of excerpts and rules. */
|
|
250
|
+
const BATCH = 40_000;
|
|
251
|
+
const size = (c) => c.excerpt.length + c.rule.quote.length + (c.rule.context?.length ?? 0);
|
|
252
|
+
const collapse = (text) => text.replace(/\s+/g, ' ').trim();
|
|
253
|
+
/** A path with the home folder written `~`. */
|
|
254
|
+
const home = (path, dir) => (dir && path.startsWith(`${dir}/`) ? `~${path.slice(dir.length)}` : path);
|
|
255
|
+
/** The tokens a run may spend judging, the sections it reads aside. */
|
|
256
|
+
export const BUDGET = 150_000;
|
|
257
|
+
/** The calls judging `cases` takes: each batch's prompt, each rule in it once with where it is written and the words
|
|
258
|
+
* around it, then its cases. */
|
|
259
|
+
function prompts(cases, dir) {
|
|
260
|
+
return batches(cases, size, BATCH).map((batch) => {
|
|
261
|
+
const rules = new Map(); // rule id → its number in the prompt
|
|
262
|
+
const blocks = [];
|
|
263
|
+
for (const c of batch) {
|
|
264
|
+
if (rules.has(c.rule.id))
|
|
265
|
+
continue;
|
|
266
|
+
rules.set(c.rule.id, rules.size + 1);
|
|
267
|
+
const context = c.rule.context ? `<context>${redact(c.rule.context)}</context>\n` : '';
|
|
268
|
+
const from = [c.rule.file && home(c.rule.file, dir), c.rule.heading].filter(Boolean).join(' › ');
|
|
269
|
+
blocks.push(`<rule id="${rules.size}" from="${redact(from).replaceAll('"', "'")}">\n${redact(c.rule.quote)}\n${context}</rule>`);
|
|
270
|
+
}
|
|
271
|
+
batch.forEach((c, i) => blocks.push(`<case id="${i + 1}" rule="${rules.get(c.rule.id)}">\n${c.excerpt}\n</case>`));
|
|
272
|
+
return { batch, prompt: `${JUDGE_PROMPT}\n${blocks.join('\n\n')}\n` };
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/** What judging `cases` would cost: the prompts it would send, ~1.8 characters a token (paths, code and quoted
|
|
276
|
+
* commands read densely), ~1.2k tokens a call of claude's own, ~210 a verdict. Measured on real calls; the real count
|
|
277
|
+
* is shown after. */
|
|
278
|
+
export function estimate(cases) {
|
|
279
|
+
const all = prompts(cases, undefined);
|
|
280
|
+
const chars = all.reduce((n, p) => n + p.prompt.length, 0);
|
|
281
|
+
return { calls: all.length, tokens: 1200 * all.length + Math.ceil(chars / 1.8) + 210 * cases.length };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The cases a run judges within `budget`, from each rule's cases waiting, latest first: tool candidates first, then
|
|
285
|
+
* judgment rules' cases; in each, one case of every rule in turn, the rule with the latest case first, so every rule
|
|
286
|
+
* gets some. It stops at the first case the budget can't take.
|
|
287
|
+
*/
|
|
288
|
+
export function select(tool, judgment, budget) {
|
|
289
|
+
const picked = [];
|
|
290
|
+
for (const group of [tool, judgment]) {
|
|
291
|
+
const lists = group.filter((l) => l.length)
|
|
292
|
+
.toSorted((a, b) => (a[0].at === b[0].at ? (a[0].rule.id < b[0].rule.id ? -1 : 1) : a[0].at > b[0].at ? -1 : 1));
|
|
293
|
+
for (let i = 0; lists.some((l) => i < l.length); i++) {
|
|
294
|
+
for (const l of lists) {
|
|
295
|
+
if (i >= l.length)
|
|
296
|
+
continue;
|
|
297
|
+
// Without a budget, no estimate: built over every case picked so far, it would cost the square of their number.
|
|
298
|
+
if (budget < Infinity && estimate([...picked, l[i]]).tokens > budget)
|
|
299
|
+
return picked;
|
|
300
|
+
picked.push(l[i]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return picked;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Judges cases through the user's own claude, one call per batch, up to 3 at once, and caches each verdict as its batch
|
|
308
|
+
* comes: a run stopped midway keeps what it paid for. Only the rules and the excerpts leave the machine, secrets
|
|
309
|
+
* redacted. A verdict whose quote isn't in its case's excerpt is `none`, never a violation; a case the answer leaves
|
|
310
|
+
* out is judged again next time. Errors come in the order of the batches. `progress` hears of the calls done, of how
|
|
311
|
+
* many. `model` is a parameter for measuring others.
|
|
312
|
+
*/
|
|
313
|
+
export async function judge(cases, env, cacheDir, progress, model = JUDGE_MODEL) {
|
|
314
|
+
const errors = [];
|
|
315
|
+
let judged = 0;
|
|
316
|
+
let tokens = 0;
|
|
317
|
+
let done = 0;
|
|
318
|
+
let fatal = false;
|
|
319
|
+
const all = prompts(cases, env.HOME);
|
|
320
|
+
progress?.(0, all.length);
|
|
321
|
+
await pool(all, async ({ batch, prompt }, b, stop) => {
|
|
322
|
+
const run = await ask(model, JUDGE_SCHEMA, prompt, env);
|
|
323
|
+
progress?.(++done, all.length);
|
|
324
|
+
tokens += run.tokens;
|
|
325
|
+
if (run.error) {
|
|
326
|
+
// No claude to run: said once, though the calls already started fail the same way.
|
|
327
|
+
if (!fatal)
|
|
328
|
+
errors[b] = run.error;
|
|
329
|
+
if (run.fatal) {
|
|
330
|
+
fatal = true;
|
|
331
|
+
stop();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const answered = run.output?.cases;
|
|
336
|
+
if (!Array.isArray(answered)) {
|
|
337
|
+
errors[b] = "claude's answer didn't match the schema";
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// An id answered twice keeps its first answer; an id not asked about is ignored.
|
|
341
|
+
const first = new Map();
|
|
342
|
+
for (const a of answered)
|
|
343
|
+
if (typeof a?.id === 'string' && !first.has(a.id))
|
|
344
|
+
first.set(a.id, a);
|
|
345
|
+
let left = 0;
|
|
346
|
+
batch.forEach((c, i) => {
|
|
347
|
+
const a = first.get(String(i + 1));
|
|
348
|
+
if (!a || !VERDICTS.includes(a.verdict))
|
|
349
|
+
return void left++;
|
|
350
|
+
const quote = typeof a.quote === 'string' ? collapse(a.quote) : '';
|
|
351
|
+
const shown = quote !== '' && collapse(c.excerpt).includes(quote);
|
|
352
|
+
saveVerdict(cacheDir, c.id, a.verdict !== 'none' && shown ? { verdict: a.verdict, quote } : { verdict: 'none' });
|
|
353
|
+
judged++;
|
|
354
|
+
});
|
|
355
|
+
if (left)
|
|
356
|
+
errors[b] = `claude's answer left out ${left} of ${batch.length} cases: they will be judged again`;
|
|
357
|
+
});
|
|
358
|
+
return { judged, tokens, errors: errors.filter((e) => e !== undefined) };
|
|
359
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The user's own claude, asked one question with a schema: the call the extraction and the judge share.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
/** Items grouped into calls of at most `limit` characters; a bigger item goes alone. */
|
|
7
|
+
export function batches(items, size, limit) {
|
|
8
|
+
const out = [];
|
|
9
|
+
let used = Infinity;
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
const n = size(item);
|
|
12
|
+
if (used + n > limit) {
|
|
13
|
+
out.push([]);
|
|
14
|
+
used = 0;
|
|
15
|
+
}
|
|
16
|
+
out[out.length - 1].push(item);
|
|
17
|
+
used += n;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Calls running at once: a long run takes a third of the time for the same tokens, and the machine runs three claude
|
|
22
|
+
* processes, not dozens. */
|
|
23
|
+
const CALLS = 3;
|
|
24
|
+
/** Runs `work` on each item, in order, at most 3 at once; after `stop()`, or once an item throws, no other item
|
|
25
|
+
* starts. */
|
|
26
|
+
export async function pool(items, work) {
|
|
27
|
+
let next = 0;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
const stop = () => (stopped = true);
|
|
30
|
+
const lane = async () => {
|
|
31
|
+
while (!stopped && next < items.length) {
|
|
32
|
+
const i = next++;
|
|
33
|
+
try {
|
|
34
|
+
await work(items[i], i, stop);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
stop();
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
await Promise.all(Array.from({ length: Math.min(CALLS, items.length) }, lane));
|
|
43
|
+
}
|
|
44
|
+
/** How long a call may take: the slowest measured take about 2 minutes, and one that hangs would hang heeded for
|
|
45
|
+
* good. */
|
|
46
|
+
const LIMIT = 5 * 60_000;
|
|
47
|
+
/** Runs claude in an empty folder of its own, removed after, so no project instruction file is loaded with it; killed
|
|
48
|
+
* past `limit` milliseconds (`late`). Null when there is no claude to run. */
|
|
49
|
+
async function claude(args, prompt, env, limit) {
|
|
50
|
+
const cwd = mkdtempSync(join(tmpdir(), 'heeded-'));
|
|
51
|
+
try {
|
|
52
|
+
return await new Promise((resolve) => {
|
|
53
|
+
const child = spawn('claude', args, { cwd, env });
|
|
54
|
+
let stdout = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
let late = false;
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
late = true;
|
|
59
|
+
child.kill('SIGKILL');
|
|
60
|
+
// A launcher that forked the real claude leaves its pipes open past its own end: they are not waited for.
|
|
61
|
+
child.stdout.destroy();
|
|
62
|
+
child.stderr.destroy();
|
|
63
|
+
resolve({ code: null, stdout, stderr, late });
|
|
64
|
+
}, limit);
|
|
65
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
66
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
67
|
+
child.on('error', () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve(null);
|
|
70
|
+
});
|
|
71
|
+
child.on('close', (code) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolve({ code, stdout, stderr, late });
|
|
74
|
+
});
|
|
75
|
+
child.stdin.on('error', () => { }); // claude may stop reading, or never start
|
|
76
|
+
child.stdin.end(prompt);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The first line with something on it. */
|
|
84
|
+
const firstLine = (text) => text.split('\n').find((l) => l.trim())?.trim() ?? '';
|
|
85
|
+
/**
|
|
86
|
+
* One `claude -p` call, isolated: no tools, no session saved, no settings, hooks, MCP servers or slash commands of the
|
|
87
|
+
* user's. The prompt goes on stdin; the answer is its structured output, to be checked by the caller. `fatal` when
|
|
88
|
+
* there is no claude to run, so no other call is tried. A call past `limit` milliseconds (5 minutes) is stopped.
|
|
89
|
+
*/
|
|
90
|
+
export async function ask(model, schema, prompt, env, limit = LIMIT) {
|
|
91
|
+
const run = await claude(['-p', '--model', model, '--output-format', 'json', '--json-schema', schema,
|
|
92
|
+
'--no-session-persistence', '--tools', '', '--strict-mcp-config', '--disable-slash-commands', '--setting-sources', '',
|
|
93
|
+
'--settings', '{"disableAllHooks":true}'], prompt, env, limit);
|
|
94
|
+
if (!run)
|
|
95
|
+
return { tokens: 0, error: 'claude not found. install claude code, or run heeded where claude runs.', fatal: true };
|
|
96
|
+
// Its batch waits for the next run; the others go on.
|
|
97
|
+
if (run.late)
|
|
98
|
+
return { tokens: 0, error: `claude took more than ${limit / 60_000} minutes: stopped` };
|
|
99
|
+
if (run.code !== 0)
|
|
100
|
+
return { tokens: 0, error: `claude exited with code ${run.code}: ${firstLine(run.stderr) || firstLine(run.stdout)}` };
|
|
101
|
+
let out;
|
|
102
|
+
try {
|
|
103
|
+
out = JSON.parse(run.stdout);
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
const u = out?.usage ?? {};
|
|
107
|
+
const tokens = [u.input_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens, u.output_tokens]
|
|
108
|
+
.reduce((n, t) => n + (typeof t === 'number' ? t : 0), 0);
|
|
109
|
+
if (out?.is_error === true)
|
|
110
|
+
return { tokens, error: `claude: ${firstLine(String(out.result ?? ''))}` };
|
|
111
|
+
return { tokens, output: out?.structured_output };
|
|
112
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Token-like runs (API keys, access tokens, hashes, blobs) become "…" before anything is sent or printed: instruction
|
|
2
|
+
// files and transcripts may hold them. Specific prefixes first, then the generic shapes.
|
|
3
|
+
const SECRETS = [
|
|
4
|
+
/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, // JWT
|
|
5
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI, Anthropic keys
|
|
6
|
+
/\b[srp]k_(?:live|test)_[A-Za-z0-9]{10,}/g, // Stripe keys
|
|
7
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, // GitHub tokens
|
|
8
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, // AWS access key ids
|
|
9
|
+
/\bxox[abeprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
|
|
10
|
+
/(?<![A-Za-z0-9])[0-9a-fA-F]{32,}(?![A-Za-z0-9])/g, // long hex (hashes, secrets)
|
|
11
|
+
];
|
|
12
|
+
// Long base64-like runs: only when a piece between slashes, 20 characters or more, mixes digits, upper and lower case.
|
|
13
|
+
// Paths and words don't; a path's folders are short.
|
|
14
|
+
const BLOB = /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{40,}={0,2}/g;
|
|
15
|
+
const mixed = (piece) => piece.length >= 20 && /\d/.test(piece) && /[a-z]/.test(piece) && /[A-Z]/.test(piece);
|
|
16
|
+
export function redact(text) {
|
|
17
|
+
let out = text;
|
|
18
|
+
for (const re of SECRETS)
|
|
19
|
+
out = out.replace(re, '…');
|
|
20
|
+
return out.replace(BLOB, (run) => (run.split('/').some(mixed) ? '…' : run));
|
|
21
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { BUDGET } from './judge.js';
|
|
2
|
+
export function json(r) {
|
|
3
|
+
const { folder, configDir, from, to, sessions, subagents, unrecognized, notRead, toReread, toJudge, toJudgeTokens, atMost, files, rules, byDepth } = r;
|
|
4
|
+
return `${JSON.stringify({ heeded: 2, folder, configDir, from, to, sessions, subagents, unrecognized, notRead, toReread,
|
|
5
|
+
toJudge, toJudgeTokens, atMost, files, rules, byDepth }, null, 2)}\n`;
|
|
6
|
+
}
|
|
7
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
8
|
+
const date = (iso) => `${MONTHS[new Date(iso).getMonth()]} ${new Date(iso).getDate()}`;
|
|
9
|
+
const thousands = (n) => n.toLocaleString('en-US');
|
|
10
|
+
const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? '' : 's'}`;
|
|
11
|
+
const cut = (s, width) => (s.length > width ? `${s.slice(0, width - 1)}…` : s);
|
|
12
|
+
const tilde = (path, home) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
|
|
13
|
+
const paint = (color, code, off, s) => (color ? `\x1b[${code}m${s}\x1b[${off}m` : s);
|
|
14
|
+
/** The sections to read, never read (their rules unchecked) or to read again (their rules from before count). */
|
|
15
|
+
const toRead = (r) => [
|
|
16
|
+
...(r.notRead ? [` ${plural(r.notRead, 'section')} not read yet: `
|
|
17
|
+
+ `${r.notRead === 1 ? "its rules aren't checked until heeded reads it" : "their rules aren't checked until heeded reads them"}`] : []),
|
|
18
|
+
...(r.toReread ? [` ${plural(r.toReread, 'section')} to read again: ${r.toReread === 1 ? 'its rules' : 'their rules'} from before`
|
|
19
|
+
+ ' count meanwhile'] : []),
|
|
20
|
+
];
|
|
21
|
+
/** Tokens as heeded says them: ~106k, ~1.1M. */
|
|
22
|
+
export const approx = (tokens) => (tokens >= 999_500 ? `~${Number((tokens / 1_000_000).toFixed(1))}M` : `~${Math.round(tokens / 1000)}k`);
|
|
23
|
+
/** The instruction files in the order the report and `heeded rules` list them: the folder's own first, the shallowest
|
|
24
|
+
* first (a worktree's copy after the file it copies), then the files from outside it (the user's own file, a parent
|
|
25
|
+
* folder's); otherwise in the order heeded met them. */
|
|
26
|
+
const inOrder = (r) => {
|
|
27
|
+
const inside = (f) => f.startsWith(`${r.folder}/`);
|
|
28
|
+
const depth = (f) => f.split('/').length;
|
|
29
|
+
return [...r.files.filter(inside).toSorted((a, b) => depth(a) - depth(b)), ...r.files.filter((f) => !inside(f))];
|
|
30
|
+
};
|
|
31
|
+
/** The file a rule is listed under: of the files it is written in, the first listed. */
|
|
32
|
+
const under = (order, x) => order.find((f) => x.files.includes(f));
|
|
33
|
+
/** A file as the user finds it: by its path in the folder, or from home. */
|
|
34
|
+
const where = (r, path, home) => (path.startsWith(`${r.folder}/`) ? path.slice(r.folder.length + 1) : tilde(path, home));
|
|
35
|
+
/** Floored, so never 100% with a violation; and never 0% with a case kept. */
|
|
36
|
+
function kept(applied, broken) {
|
|
37
|
+
const p = Math.floor((100 * (applied - broken)) / applied);
|
|
38
|
+
return `${applied > broken && p === 0 ? 1 : p}%`;
|
|
39
|
+
}
|
|
40
|
+
/** Days between two times, by the local calendar. */
|
|
41
|
+
function ago(iso, now) {
|
|
42
|
+
const day = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
43
|
+
const days = Math.round((day(now) - day(new Date(iso))) / 86_400_000);
|
|
44
|
+
return days === 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`;
|
|
45
|
+
}
|
|
46
|
+
/** Cells in columns two spaces apart; a row's last cell sets no width. */
|
|
47
|
+
function columns(rows) {
|
|
48
|
+
const widths = [];
|
|
49
|
+
for (const row of rows)
|
|
50
|
+
row.slice(0, -1).forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, c.length)));
|
|
51
|
+
return rows.map((row) => row.map((c, i) => (i < row.length - 1 ? c.padEnd(widths[i] + 2) : c)).join(''));
|
|
52
|
+
}
|
|
53
|
+
/** The report for a terminal, `columns` wide. */
|
|
54
|
+
export function text(r, o) {
|
|
55
|
+
const home = (path) => tilde(path, o.home);
|
|
56
|
+
const color = (code, off, s) => paint(o.color, code, off, s);
|
|
57
|
+
// Only rules and evidence are cut to the width: a folder's path is never cut.
|
|
58
|
+
const out = (blocks) => `\n${blocks.filter((b) => b.length).map((b) => b.join('\n')).join('\n\n')}\n`.replace(/^( {4})([✗✓○?·])/gm, (_, indent, mark) => indent + (mark === '✗' ? color(31, 39, mark) : mark === '✓' ? color(32, 39, mark) : color(2, 22, mark)))
|
|
59
|
+
.replace(/^( {8}[a-z]{3} \d.*)$/gm, (line) => color(2, 22, line));
|
|
60
|
+
const unrecognized = r.unrecognized.length
|
|
61
|
+
? [` ${plural(r.unrecognized.length, 'transcript')} unrecognized: claude code may have changed its format`] : [];
|
|
62
|
+
if (!r.sessions)
|
|
63
|
+
return out([[` no claude code transcripts for ${home(r.folder)} in ${home(r.configDir)}`, ...unrecognized]]);
|
|
64
|
+
const span = r.from && r.to ? ` · ${date(r.from) === date(r.to) ? date(r.from) : `${date(r.from)} – ${date(r.to)}`}` : '';
|
|
65
|
+
const header = ` ${home(r.folder)} · ${plural(r.sessions, 'session')}, ${plural(r.subagents, 'subagent')}${span}`;
|
|
66
|
+
if (!r.files.length)
|
|
67
|
+
return out([[header, ' no instructions recorded', ...unrecognized]]);
|
|
68
|
+
const checked = r.rules.filter((x) => ['broken', 'kept', 'to-confirm', 'never-applied'].includes(x.status));
|
|
69
|
+
const judgment = r.rules.filter((x) => x.status === 'judgment');
|
|
70
|
+
const demoted = r.rules.filter((x) => x.status === 'demoted').length;
|
|
71
|
+
const counts = ` ${plural(r.files.length, 'file')} · ${plural(r.rules.length, 'rule')} · ${thousands(checked.length)} checked`
|
|
72
|
+
+ ` · ${thousands(judgment.length)} need a judge${demoted ? ` · ${thousands(demoted)} set aside` : ''}`;
|
|
73
|
+
// The rules' cells, the name column narrowed until the widest row fits: the checked rules; the judgment rules that
|
|
74
|
+
// never applied (their sample ended with every case none, or they had no case) with the others never applied; then
|
|
75
|
+
// the judgment rules judged on a sample, whose share is of their cases judged. A judgment rule whose cases still
|
|
76
|
+
// wait, or that no call or reply can show, is counted at the end.
|
|
77
|
+
const unapplied = judgment.filter((x) => !x.reason && !x.applied && !x.toJudge);
|
|
78
|
+
const never = (x) => x.status === 'never-applied' || unapplied.includes(x);
|
|
79
|
+
// Grouped by the file each rule is written in (the first, when it is in several), each file in that order.
|
|
80
|
+
const listed = [...checked, ...unapplied, ...judgment.filter((x) => x.applied)];
|
|
81
|
+
const order = inOrder(r);
|
|
82
|
+
const shown = order.flatMap((f) => listed.filter((x) => under(order, x) === f));
|
|
83
|
+
const rows = shown.map((x) => [x.name, ...(never(x)
|
|
84
|
+
? [`never applied${x.judged ? ` in ${thousands(x.judged)} judged` : ''}`, `~${thousands(x.tokens)} tokens every session`]
|
|
85
|
+
: x.status === 'to-confirm' ? []
|
|
86
|
+
: [`kept ${kept(x.applied, x.broken)} of ${thousands(x.applied)}${x.status === 'judgment' ? ' judged' : ''}`,
|
|
87
|
+
...(x.broken ? [`broken ${thousands(x.broken)}×`, `last ${ago(x.evidence[0].at, o.now)}`] : [])]),
|
|
88
|
+
...(x.status !== 'judgment' && x.toJudge ? [`${thousands(x.toJudge)} to confirm`] : [])]);
|
|
89
|
+
const laid = (width) => columns(rows.map(([name, ...rest]) => [cut(name, width).padEnd(width), ...rest]));
|
|
90
|
+
let width = Math.max(...rows.map((row) => row[0].length));
|
|
91
|
+
while (width > 10 && Math.max(...laid(width).map((l) => l.length + 6)) > o.columns)
|
|
92
|
+
width--;
|
|
93
|
+
const mark = { broken: '✗', kept: '✓', 'to-confirm': '?', judgment: '·' };
|
|
94
|
+
const lines = laid(width).map((l, i) => cut(` ${never(shown[i]) ? '○' : mark[shown[i].status]} ${l}`, o.columns));
|
|
95
|
+
const rules = [];
|
|
96
|
+
shown.forEach((x, i) => {
|
|
97
|
+
const file = under(order, x);
|
|
98
|
+
if (i === 0 || file !== under(order, shown[i - 1]))
|
|
99
|
+
rules.push(` ${where(r, file, o.home)}`);
|
|
100
|
+
rules.push(lines[i]);
|
|
101
|
+
if (x.afterCompaction.applied >= 20) {
|
|
102
|
+
const { applied, broken } = x.afterCompaction;
|
|
103
|
+
rules.push(` after compaction kept ${kept(applied, broken)} of ${thousands(applied)}`);
|
|
104
|
+
}
|
|
105
|
+
// A judgment rule's evidence is the judge's words; a tool rule's, what in the call broke it.
|
|
106
|
+
for (const e of x.broken ? x.evidence.slice(0, 3) : []) {
|
|
107
|
+
const what = x.status === 'judgment' ? e.quote ?? e.text : e.text;
|
|
108
|
+
rules.push(cut(` ${date(e.at).padEnd(6)} ${e.session.slice(0, 8)} ${what}`, o.columns));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const labels = ['0–100k', '100–200k', '200–400k', '400k+'];
|
|
112
|
+
const depth = r.byDepth.some((b) => b.applied) ? [
|
|
113
|
+
` kept by context depth${labels.map((l) => ` ${l}`).join('')}`,
|
|
114
|
+
` ${' '.repeat(21)}${r.byDepth.map((b, i) => ` ${(b.applied >= 20 ? kept(b.applied, b.broken) : '–').padStart(labels[i].length)}`).join('')}`,
|
|
115
|
+
] : [];
|
|
116
|
+
const unseen = judgment.filter((x) => x.reason).length;
|
|
117
|
+
const aside = [unseen && `${plural(unseen, 'rule')} no call or reply shows`, demoted && `${thousands(demoted)} set aside`]
|
|
118
|
+
.filter(Boolean).join(', ');
|
|
119
|
+
const todo = r.toJudgeTokens > BUDGET ? `heeded judges ${approx(BUDGET)} a run, heeded --all all of them` : 'run heeded again';
|
|
120
|
+
const end = [
|
|
121
|
+
...(aside ? [` ${aside}: heeded rules`] : []),
|
|
122
|
+
...toRead(r),
|
|
123
|
+
...(r.toJudge ? [` ${plural(r.toJudge, 'case')} waiting for the judge, ${approx(r.toJudgeTokens)} tokens: ${todo}`] : []),
|
|
124
|
+
// Nothing a run could read or judge: said, or a user can't tell a finished report from one with work left.
|
|
125
|
+
...(r.notRead || r.toReread || r.toJudge ? []
|
|
126
|
+
: [' up to date: new sessions and changes to instruction files are read on the next run']),
|
|
127
|
+
...unrecognized,
|
|
128
|
+
];
|
|
129
|
+
return out([[header, counts], rules, depth, end]);
|
|
130
|
+
}
|
|
131
|
+
/** How a rule is checked, in words: a tool check shows every pattern, whole; a judgment rule, the cases it is judged on. */
|
|
132
|
+
function how(x) {
|
|
133
|
+
if (x.status === 'judgment') {
|
|
134
|
+
return x.reason ? `needs a judge, no cases: ${x.reason}` : x.check?.reply ? 'judged on replies' : `judged on ${patterns(x.check)}`;
|
|
135
|
+
}
|
|
136
|
+
if (x.status === 'demoted')
|
|
137
|
+
return x.reason;
|
|
138
|
+
if (x.check.language)
|
|
139
|
+
return `language ${x.check.language}`;
|
|
140
|
+
return patterns(x.check);
|
|
141
|
+
}
|
|
142
|
+
function patterns(c) {
|
|
143
|
+
const tool = c.when?.tool;
|
|
144
|
+
return [
|
|
145
|
+
`${tool === undefined ? 'any tool' : /^\^\w+\$$/.test(tool) ? tool.slice(1, -1) : `tool ${tool}`}${c.field ? ` ${c.field}` : ''}`,
|
|
146
|
+
c.when?.value !== undefined && `when ${c.when.value}`,
|
|
147
|
+
c.when?.data !== undefined && `when data ${c.when.data}`,
|
|
148
|
+
c.broken?.tool !== undefined && `broken tool ${c.broken.tool}`,
|
|
149
|
+
c.broken?.value !== undefined && `broken ${c.broken.value}`,
|
|
150
|
+
c.broken?.data !== undefined && `broken data ${c.broken.data}`,
|
|
151
|
+
c.ignoreCase && 'ignoring case',
|
|
152
|
+
].filter(Boolean).join(' ');
|
|
153
|
+
}
|
|
154
|
+
/** The rules found, by file, and how each one is checked. Never cut to the terminal's width: the patterns are what this
|
|
155
|
+
* list is for. */
|
|
156
|
+
export function rules(r, o) {
|
|
157
|
+
const width = Math.max(...r.rules.map((x) => x.name.length));
|
|
158
|
+
const lines = ['', ` ${tilde(r.folder, o.home)} · ${plural(r.files.length, 'file')} · ${plural(r.rules.length, 'rule')}`, ''];
|
|
159
|
+
const order = inOrder(r);
|
|
160
|
+
for (const file of order) {
|
|
161
|
+
const mine = r.rules.filter((x) => under(order, x) === file);
|
|
162
|
+
if (mine.length)
|
|
163
|
+
lines.push(` ${where(r, file, o.home)}`);
|
|
164
|
+
for (const x of mine) {
|
|
165
|
+
const mark = x.status === 'judgment' ? paint(o.color, 2, 22, '·') : x.status === 'demoted' ? paint(o.color, 33, 39, '!')
|
|
166
|
+
: paint(o.color, 32, 39, '✓');
|
|
167
|
+
lines.push(` ${mark} ${x.name.padEnd(width + 2)}${how(x)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (r.notRead || r.toReread)
|
|
171
|
+
lines.push('', ...toRead(r));
|
|
172
|
+
return `${lines.join('\n')}\n`;
|
|
173
|
+
}
|