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/rules.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// From instruction files to rules: sections, the extraction call, verification, the cache.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { applies, compile, LANGUAGES, verdict } from './check.js';
|
|
6
|
+
import { ask, batches, pool } from './model.js';
|
|
7
|
+
import { redact } from './redact.js';
|
|
8
|
+
/** The extraction prompt's version: part of every cache key, so a new prompt reads every section again. */
|
|
9
|
+
export const PROMPT_VERSION = 4;
|
|
10
|
+
export const MODEL = 'sonnet';
|
|
11
|
+
/** A section's key in the cache, under a version of the prompt. */
|
|
12
|
+
export const sectionHash = (path, text, version = PROMPT_VERSION) => createHash('sha256').update(JSON.stringify([version, MODEL, path, text])).digest('hex');
|
|
13
|
+
export function sections(content) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const ancestors = [];
|
|
16
|
+
let path = [];
|
|
17
|
+
let lines = [];
|
|
18
|
+
let heading = '';
|
|
19
|
+
// A heading with nothing under it holds no rule: it is context for its subsections.
|
|
20
|
+
const close = () => {
|
|
21
|
+
const text = lines.join('\n').trim();
|
|
22
|
+
if (text && text !== heading)
|
|
23
|
+
out.push({ path, text, hash: sectionHash(path, text) });
|
|
24
|
+
};
|
|
25
|
+
let fence = ''; // the marker of the open code block: it closes with its own kind, at least as long
|
|
26
|
+
for (const line of content.replace(/\r\n?/g, '\n').split('\n')) {
|
|
27
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
28
|
+
if (marker && !fence)
|
|
29
|
+
fence = marker;
|
|
30
|
+
else if (marker && marker[0] === fence[0] && marker.length >= fence.length)
|
|
31
|
+
fence = '';
|
|
32
|
+
const h = fence ? null : /^(#{1,6})[ \t]+(.+)$/.exec(line);
|
|
33
|
+
if (h) {
|
|
34
|
+
close();
|
|
35
|
+
const level = h[1].length;
|
|
36
|
+
while (ancestors.length && ancestors[ancestors.length - 1].level >= level)
|
|
37
|
+
ancestors.pop();
|
|
38
|
+
path = ancestors.map((a) => a.title);
|
|
39
|
+
ancestors.push({ level, title: h[2].trim() });
|
|
40
|
+
heading = line.trim();
|
|
41
|
+
lines = [];
|
|
42
|
+
}
|
|
43
|
+
lines.push(line);
|
|
44
|
+
}
|
|
45
|
+
close();
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
const collapse = (text) => text.replace(/\s+/g, ' ').trim();
|
|
49
|
+
/**
|
|
50
|
+
* A rule as the extraction returned it, checked here: its quote must be in its section as the model read it (secrets
|
|
51
|
+
* redacted), word for word (spacing aside), or the rule is dropped; a check that can't be trusted is demoted, with the
|
|
52
|
+
* reason, and never gives a violation.
|
|
53
|
+
*/
|
|
54
|
+
export function verify(raw, section) {
|
|
55
|
+
const r = raw;
|
|
56
|
+
if (typeof r?.quote !== 'string' || !collapse(redact(section.text)).includes(collapse(r.quote)))
|
|
57
|
+
return null;
|
|
58
|
+
const id = createHash('sha256').update(collapse(r.quote).toLowerCase()).digest('hex').slice(0, 8);
|
|
59
|
+
const name = [...String(r.name).replace(/[\p{Cc}\s]+/gu, ' ').trim()].slice(0, 40).join('').trimEnd();
|
|
60
|
+
const own = /^#{1,6}[ \t]+(.+)$/.exec(section.text.split('\n')[0])?.[1].trim();
|
|
61
|
+
const heading = [...section.path, ...(own ? [own] : [])].join(' › ');
|
|
62
|
+
// What the section says around the rule: from 500 characters before its words to 1,000 after.
|
|
63
|
+
const text = collapse(redact(section.text));
|
|
64
|
+
const at = text.indexOf(collapse(r.quote));
|
|
65
|
+
const [from, to] = [Math.max(0, at - 500), Math.min(text.length, at + collapse(r.quote).length + 1000)];
|
|
66
|
+
const context = `${from ? '…' : ''}${text.slice(from, to)}${to < text.length ? '…' : ''}`;
|
|
67
|
+
const rule = { ...r, id, name, heading, context };
|
|
68
|
+
if (rule.kind === 'judgment') {
|
|
69
|
+
// What breaks a judgment rule is for the judge to say: its check only finds the cases.
|
|
70
|
+
const { broken: _, ...check } = rule.check ?? {};
|
|
71
|
+
const judged = rule.check ? { ...rule, check } : rule;
|
|
72
|
+
const noCases = casesless(judged);
|
|
73
|
+
return noCases ? { ...judged, noCases } : judged;
|
|
74
|
+
}
|
|
75
|
+
const language = rule.check?.language;
|
|
76
|
+
const demoted = rule.kind === 'tool' ? demotion(rule)
|
|
77
|
+
: rule.kind === 'language' && !LANGUAGES.includes(String(language)) ? `language ${language} not supported` : undefined;
|
|
78
|
+
return demoted ? { ...rule, demoted } : rule;
|
|
79
|
+
}
|
|
80
|
+
function demotion(rule) {
|
|
81
|
+
const b = rule.check?.broken;
|
|
82
|
+
if (b?.tool === undefined && b?.value === undefined && b?.data === undefined)
|
|
83
|
+
return 'no broken pattern';
|
|
84
|
+
const compiled = compile(rule.check);
|
|
85
|
+
if (typeof compiled === 'string')
|
|
86
|
+
return compiled;
|
|
87
|
+
if (!rule.breaking?.length)
|
|
88
|
+
return 'no breaking example';
|
|
89
|
+
if (!rule.keeping?.length)
|
|
90
|
+
return 'no keeping example';
|
|
91
|
+
const call = (e) => verdict(compiled, e.tool, compiled.field === undefined ? {} : { [compiled.field]: e.value });
|
|
92
|
+
const breaks = rule.breaking.find((e) => call(e) !== 'broken');
|
|
93
|
+
if (breaks)
|
|
94
|
+
return `failed its examples: breaks "${breaks.value}"`;
|
|
95
|
+
// A keeping example may be a near miss the rule doesn't apply to (a search for the forbidden word); none may break it.
|
|
96
|
+
const keeps = rule.keeping.find((e) => call(e) === 'broken');
|
|
97
|
+
if (keeps)
|
|
98
|
+
return `failed its examples: keeps "${keeps.value}"`;
|
|
99
|
+
if (!rule.keeping.some((e) => call(e) === 'kept'))
|
|
100
|
+
return 'failed its examples: none of the keeping examples applies';
|
|
101
|
+
}
|
|
102
|
+
/** Why a judgment rule has no case a judge could read, if so: a rule about replies has every reply; another needs a
|
|
103
|
+
* `when` that compiles and finds each of its keeping examples. */
|
|
104
|
+
function casesless(rule) {
|
|
105
|
+
const c = rule.check;
|
|
106
|
+
if (c?.reply === true)
|
|
107
|
+
return;
|
|
108
|
+
if (c?.when?.tool === undefined && c?.when?.value === undefined && c?.when?.data === undefined)
|
|
109
|
+
return 'no case to judge it on';
|
|
110
|
+
const compiled = compile(c);
|
|
111
|
+
if (typeof compiled === 'string')
|
|
112
|
+
return compiled;
|
|
113
|
+
if (!rule.keeping?.length)
|
|
114
|
+
return 'no example of a case';
|
|
115
|
+
const missed = rule.keeping.find((e) => !applies(compiled, e.tool, compiled.field === undefined ? {} : { [compiled.field]: e.value }));
|
|
116
|
+
if (missed)
|
|
117
|
+
return `failed its examples: "${missed.value}" is not a case`;
|
|
118
|
+
}
|
|
119
|
+
/** A section's rules as the extraction returned them, or null when they were never read (or the file is damaged). */
|
|
120
|
+
export function load(cacheDir, hash) {
|
|
121
|
+
try {
|
|
122
|
+
const rules = JSON.parse(readFileSync(join(cacheDir, 'heeded', `${hash}.json`), 'utf8'));
|
|
123
|
+
return Array.isArray(rules) ? rules : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Written whole or not at all: a temporary file, then a rename. */
|
|
130
|
+
export function write(dir, name, value) {
|
|
131
|
+
mkdirSync(dir, { recursive: true });
|
|
132
|
+
const tmp = join(dir, `${name}.${process.pid}.tmp`);
|
|
133
|
+
writeFileSync(tmp, JSON.stringify(value));
|
|
134
|
+
renameSync(tmp, join(dir, `${name}.json`));
|
|
135
|
+
}
|
|
136
|
+
export function save(cacheDir, hash, rules) {
|
|
137
|
+
write(join(cacheDir, 'heeded'), hash, rules);
|
|
138
|
+
}
|
|
139
|
+
/** The same section across versions of its file: the file, the headings above it, its own heading, and which one of
|
|
140
|
+
* that name it is when a file repeats a heading. */
|
|
141
|
+
function identity(path, s, seen) {
|
|
142
|
+
const key = JSON.stringify([s.path, /^#{1,6}[ \t]/.test(s.text) ? s.text.split('\n')[0] : '']);
|
|
143
|
+
const n = seen.get(key) ?? 0;
|
|
144
|
+
seen.set(key, n + 1);
|
|
145
|
+
return JSON.stringify([path, ...JSON.parse(key), ...(n ? [n] : [])]);
|
|
146
|
+
}
|
|
147
|
+
const latest = (cacheDir) => join(cacheDir, 'heeded', 'latest');
|
|
148
|
+
// Under the prompt's version and the model, like each section's hash: a new prompt reads every section again.
|
|
149
|
+
const idHash = (id, version) => createHash('sha256').update(JSON.stringify([version, MODEL, id])).digest('hex');
|
|
150
|
+
/** The version of a section read last, by the time it was in effect: the one older versions take their rules from. */
|
|
151
|
+
function recall(cacheDir, id, version = PROMPT_VERSION) {
|
|
152
|
+
try {
|
|
153
|
+
const known = JSON.parse(readFileSync(join(latest(cacheDir), `${idHash(id, version)}.json`), 'utf8'));
|
|
154
|
+
return typeof known?.hash === 'string' && typeof known.at === 'string' ? known : null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Remembers a read version of a section, unless one in effect as late or later is remembered already: the cache is
|
|
161
|
+
* shared by every folder and config dir, whose histories interleave. */
|
|
162
|
+
export function remember(cacheDir, id, hash, at, version = PROMPT_VERSION) {
|
|
163
|
+
const known = recall(cacheDir, id, version);
|
|
164
|
+
if (!known || at > known.at)
|
|
165
|
+
write(latest(cacheDir), idHash(id, version), { hash, at });
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A file's rules from the cache, and its sections never read, memoized by path and content: worktrees and sessions
|
|
169
|
+
* record the same files over and over. A section never read takes the rules of the version of it read last, each kept
|
|
170
|
+
* only where its words are written. Null for a file not measured: `AutoMem` is Claude Code's memory index, one-line
|
|
171
|
+
* pointers edited constantly.
|
|
172
|
+
*/
|
|
173
|
+
export function rulesOf(cacheDir) {
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
return (file) => {
|
|
176
|
+
if (file.type === 'AutoMem')
|
|
177
|
+
return null;
|
|
178
|
+
let byContent = seen.get(file.path);
|
|
179
|
+
if (!byContent)
|
|
180
|
+
seen.set(file.path, (byContent = new Map()));
|
|
181
|
+
let found = byContent.get(file.content);
|
|
182
|
+
if (!found) {
|
|
183
|
+
found = { rules: [], versions: [] };
|
|
184
|
+
const names = new Map();
|
|
185
|
+
for (const section of sections(file.content)) {
|
|
186
|
+
const id = identity(file.path, section, names);
|
|
187
|
+
const known = recall(cacheDir, id);
|
|
188
|
+
let raw = load(cacheDir, section.hash);
|
|
189
|
+
const read = raw !== null;
|
|
190
|
+
if (raw === null && known)
|
|
191
|
+
raw = load(cacheDir, known.hash);
|
|
192
|
+
// Read under the previous prompt only: those rules stand until the section is read under this one. One step
|
|
193
|
+
// back: a prompt older than that is read again from nothing.
|
|
194
|
+
if (raw === null) {
|
|
195
|
+
const older = recall(cacheDir, id, PROMPT_VERSION - 1);
|
|
196
|
+
raw = load(cacheDir, sectionHash(section.path, section.text, PROMPT_VERSION - 1)) ?? (older && load(cacheDir, older.hash));
|
|
197
|
+
}
|
|
198
|
+
const rules = (raw ?? []).map((r) => verify(r, section)).filter((r) => r !== null);
|
|
199
|
+
for (const rule of rules)
|
|
200
|
+
found.rules.push({ ...rule, file: file.path });
|
|
201
|
+
// Read before: found to hold no rule, or with rules whose words are still written in it. Rules none of which is
|
|
202
|
+
// still there stand for nothing: it is as never read.
|
|
203
|
+
const before = !read && raw !== null && (raw.length === 0 || rules.length > 0);
|
|
204
|
+
found.versions.push({ section, identity: id, read, readAt: known?.at ?? null, before });
|
|
205
|
+
}
|
|
206
|
+
byContent.set(file.content, found);
|
|
207
|
+
}
|
|
208
|
+
return found;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Sections go to claude in calls of at most 30,000 characters of their text. */
|
|
212
|
+
const BATCH = 30_000;
|
|
213
|
+
/** What reading `sections` would cost: each call carries ~5.6k tokens of prompt, schema and claude's own; the text is
|
|
214
|
+
* read once and answered with about five times as much (a JSON object per rule, with its check and examples). Fitted on
|
|
215
|
+
* real calls with prompt v4 (13 sections, 24.6k and 27.3k tokens; a short section, 5.6k–5.8k); the real count is shown
|
|
216
|
+
* after. */
|
|
217
|
+
export function estimate(sections) {
|
|
218
|
+
const calls = batches(sections, (s) => s.text.length, BATCH).length;
|
|
219
|
+
const chars = sections.reduce((n, s) => n + s.text.length, 0);
|
|
220
|
+
return { calls, tokens: 5600 * calls + 6 * Math.ceil(chars / 4) };
|
|
221
|
+
}
|
|
222
|
+
const PATTERNS = { type: 'object', properties: { tool: { type: 'string' }, value: { type: 'string' }, data: { type: 'string' } } };
|
|
223
|
+
const EXAMPLES = {
|
|
224
|
+
type: 'array',
|
|
225
|
+
items: { type: 'object', properties: { tool: { type: 'string' }, value: { type: 'string' } }, required: ['tool', 'value'] },
|
|
226
|
+
};
|
|
227
|
+
const SCHEMA = JSON.stringify({
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
sections: {
|
|
231
|
+
type: 'array',
|
|
232
|
+
items: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
id: { type: 'string' },
|
|
236
|
+
rules: {
|
|
237
|
+
type: 'array',
|
|
238
|
+
items: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
quote: { type: 'string' },
|
|
242
|
+
name: { type: 'string' },
|
|
243
|
+
kind: { enum: ['tool', 'language', 'judgment'] },
|
|
244
|
+
trigger: { type: 'string' },
|
|
245
|
+
check: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {
|
|
248
|
+
field: { type: 'string' }, when: PATTERNS, broken: PATTERNS, ignoreCase: { type: 'boolean' },
|
|
249
|
+
language: { type: 'string' }, reply: { type: 'boolean' },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
breaking: EXAMPLES,
|
|
253
|
+
keeping: EXAMPLES,
|
|
254
|
+
},
|
|
255
|
+
required: ['quote', 'name', 'kind', 'trigger'],
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
required: ['id', 'rules'],
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
required: ['sections'],
|
|
264
|
+
});
|
|
265
|
+
// Raw: the regexes in it reach the model as written.
|
|
266
|
+
const PROMPT = String.raw `You read the instruction files a coding agent (Claude Code) is given, and list the rules in them, so that a tool
|
|
267
|
+
can check later whether the agent followed each one. Below are sections of those files. Return every section by its id
|
|
268
|
+
with the rules it states: an empty list when it states none. A rule tells the agent to do or not to do something;
|
|
269
|
+
descriptions, explanations and context are not rules. List every rule, whether a tool can check it or not: the user
|
|
270
|
+
reads this list to know what their files ask.
|
|
271
|
+
|
|
272
|
+
For each rule:
|
|
273
|
+
- quote: the rule's own words, copied exactly from the section: never reworded, typos and formatting (backticks) kept.
|
|
274
|
+
- name: a summary of at most 40 characters, in the rule's own language.
|
|
275
|
+
- kind: "tool" when a single tool call shows, without doubt and whatever came before it, whether the rule was kept or
|
|
276
|
+
broken; "language" for the language the agent must use in its replies to the user (not in code, commits or files);
|
|
277
|
+
"judgment" for everything else. A rule with an exception, or a condition one call can't show (the user's go or
|
|
278
|
+
request, being asked first, a folder or a sub-project, a time, before or after something else), is "judgment".
|
|
279
|
+
- trigger: when the rule applies, in a few words ("staging files with git").
|
|
280
|
+
|
|
281
|
+
A "language" rule has check: { "language": "<two-letter ISO 639-1 code>" }.
|
|
282
|
+
|
|
283
|
+
A "tool" rule has a check over one call: its tool name and one top-level string field of its input.
|
|
284
|
+
- Claude Code tools: Bash (field "command"), Edit (file_path, old_string, new_string), Write (file_path, content),
|
|
285
|
+
Read (file_path); MCP tools are named mcp__<server>__<tool>. A file_path is absolute: match a folder in it
|
|
286
|
+
(/vendor/) or its end (\.lock$), not its start.
|
|
287
|
+
- field: the input field matched; left out to match on the tool name alone.
|
|
288
|
+
- A "command" is read the way a shell reads it, one simple command at a time (split on &&, ||, ;, |, & and line
|
|
289
|
+
breaks). "value" sees what a simple command does: its words, with quoted text emptied ('' and "" stay) and heredoc
|
|
290
|
+
bodies left out; it may start with assignments (CI=1), sudo, env, time or nohup. Match a program or a subcommand as
|
|
291
|
+
a whole word of the command, (^|\s)tsc(\s|$) or (^|\s)git\s+push(\s|$), never with \b: it matches inside paths and
|
|
292
|
+
hyphenated words (tsc-after.txt, git merge-base). "data" sees what it carries: its heredoc bodies, then its quoted
|
|
293
|
+
texts, one per line, such as a commit message; it is empty when there are none, and ^ and $ anchor the whole of it.
|
|
294
|
+
A rule on a message's form needs a "data" in "when" too, like \S, so a command without a message is not counted.
|
|
295
|
+
What sh -c, bash -c, ssh, eval and $( … ) run is read as commands too. Any other field is matched whole by "value".
|
|
296
|
+
- when: the calls the rule is about: "tool", a regex over the tool name (anchored, like ^Bash$), and "value" and "data",
|
|
297
|
+
regexes as above; any may be left out.
|
|
298
|
+
- broken: what makes such a call break the rule: a "tool", "value" or "data" regex, at least one. For a command, when
|
|
299
|
+
and broken must match the same simple command.
|
|
300
|
+
- ignoreCase: true when letter case doesn't matter.
|
|
301
|
+
- The regexes are JavaScript, without flags, and never repeat a group that holds a quantifier or an alternation, like
|
|
302
|
+
(a+)+ or (a|b)+.
|
|
303
|
+
- breaking: two or three example calls { "tool", "value" } that break the rule; keeping: two or three calls that keep
|
|
304
|
+
it, at least one the rule is about, and near misses chosen for the rule that must not break it: a path or a file
|
|
305
|
+
name holding the forbidden word, a read-only form of the command (git tag alone lists tags), its --help, a search or
|
|
306
|
+
an echo naming it. A value is the field's whole value: for Bash, a whole command line.
|
|
307
|
+
|
|
308
|
+
A "judgment" rule has a check that finds the cases a judge will read whenever some calls or replies can show it kept
|
|
309
|
+
or broken, however many they are: { "reply": true } for a rule about what the agent tells the user; else the calls it
|
|
310
|
+
is about, as a tool rule's "field" and "when", without "broken", and always with keeping: one or two calls that are
|
|
311
|
+
cases of the rule, each matched by "when", never a call that only keeps the rule without being one of its cases (a
|
|
312
|
+
read of a file the rule forbids editing). A check without them is not used.
|
|
313
|
+
- A rule about what the agent writes in files (code, comments, tests, docs, their language or their style) has as
|
|
314
|
+
cases the Edit and Write calls on the files it is about ("field": "file_path"; left out for any file): the judge
|
|
315
|
+
reads what each one writes.
|
|
316
|
+
- A rule about commit messages has the git commit commands with a message ("data": "\S" in "when").
|
|
317
|
+
- A rule about a command, a setting or a tool has the calls that run it.
|
|
318
|
+
Leave the check out only when no call or reply can show the rule (how to plan, what to remember, the order of the
|
|
319
|
+
work): the rule is listed all the same.
|
|
320
|
+
`;
|
|
321
|
+
/**
|
|
322
|
+
* Reads the rules of the pending sections through the user's own claude, sonnet, one call per batch, up to 3 at once,
|
|
323
|
+
* and caches each batch as it comes, each section remembered as its section's latest read version: an interrupted run
|
|
324
|
+
* keeps what it paid for. Only the sections' text leaves the machine, secrets redacted; never a file's path. A batch
|
|
325
|
+
* that fails is reported and nothing of it is cached. Errors come in the order of the batches. `progress` hears of the
|
|
326
|
+
* calls done, of how many.
|
|
327
|
+
*/
|
|
328
|
+
export async function extract(pending, env, cacheDir, progress) {
|
|
329
|
+
const errors = [];
|
|
330
|
+
let read = 0;
|
|
331
|
+
let tokens = 0;
|
|
332
|
+
let done = 0;
|
|
333
|
+
let fatal = false;
|
|
334
|
+
// A text under several paths (a worktree's copy of a file) is sent once, and remembered for each.
|
|
335
|
+
const byHash = new Map();
|
|
336
|
+
for (const p of pending)
|
|
337
|
+
byHash.set(p.section.hash, [...(byHash.get(p.section.hash) ?? []), p]);
|
|
338
|
+
const all = batches([...byHash.values()], (group) => group[0].section.text.length, BATCH);
|
|
339
|
+
progress?.(0, all.length);
|
|
340
|
+
await pool(all, async (batch, b, stop) => {
|
|
341
|
+
const body = batch.map(([{ section: s }], i) => `<section id="${i + 1}" path="${redact(s.path.join(' › ')).replaceAll('"', "'")}">\n${redact(s.text)}\n</section>`);
|
|
342
|
+
const run = await ask(MODEL, SCHEMA, `${PROMPT}\n${body.join('\n\n')}\n`, env);
|
|
343
|
+
progress?.(++done, all.length);
|
|
344
|
+
tokens += run.tokens;
|
|
345
|
+
if (run.error) {
|
|
346
|
+
// No claude to run: said once, though the calls already started fail the same way.
|
|
347
|
+
if (!fatal)
|
|
348
|
+
errors[b] = run.error;
|
|
349
|
+
if (run.fatal) {
|
|
350
|
+
fatal = true;
|
|
351
|
+
stop();
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const answered = run.output?.sections;
|
|
356
|
+
if (!Array.isArray(answered) || !answered.every((a) => typeof a?.id === 'string' && Array.isArray(a.rules))) {
|
|
357
|
+
errors[b] = "claude's answer didn't match the schema";
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// A section left out of the answer is not cached as holding no rule: it is asked about again.
|
|
361
|
+
const byId = new Map(answered.map((a) => [a?.id, a?.rules]));
|
|
362
|
+
let left = 0;
|
|
363
|
+
batch.forEach((group, i) => {
|
|
364
|
+
const rules = byId.get(String(i + 1));
|
|
365
|
+
if (!Array.isArray(rules))
|
|
366
|
+
return void left++;
|
|
367
|
+
save(cacheDir, group[0].section.hash, rules);
|
|
368
|
+
for (const p of group)
|
|
369
|
+
remember(cacheDir, p.identity, p.section.hash, p.at);
|
|
370
|
+
});
|
|
371
|
+
if (left)
|
|
372
|
+
errors[b] = `claude's answer left out ${left} of ${batch.length} sections: they will be asked about again`;
|
|
373
|
+
read += batch.length - left;
|
|
374
|
+
});
|
|
375
|
+
return { read, tokens, errors: errors.filter((e) => e !== undefined) };
|
|
376
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// A shell command line read the way a shell reads it, without running one: its simple commands, each with what it
|
|
2
|
+
// does (its quoted text emptied, heredoc bodies out) and what it carries (that quoted text and those bodies). Rules
|
|
3
|
+
// about running something look at the first; rules about a commit message look at the second; a search for a word, or
|
|
4
|
+
// a file written with it, does neither.
|
|
5
|
+
/** Where the substitution opened at `i` ends: past its closing `)` or backtick, its quotes and nested parentheses read
|
|
6
|
+
* as a shell reads them; the end of the line when it is left open. */
|
|
7
|
+
function substitution(line, i) {
|
|
8
|
+
let j = i + 1;
|
|
9
|
+
if (line[i] === '`') {
|
|
10
|
+
while (j < line.length && line[j] !== '`')
|
|
11
|
+
j += line[j] === '\\' ? 2 : 1;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (let depth = 0; j < line.length; j++) {
|
|
15
|
+
const c = line[j];
|
|
16
|
+
if (c === '\\')
|
|
17
|
+
j++;
|
|
18
|
+
else if (c === "'" || c === '"')
|
|
19
|
+
j = quoted(line, j).end - 1;
|
|
20
|
+
else if (c === '`')
|
|
21
|
+
j = substitution(line, j).end - 1;
|
|
22
|
+
else if (c === '(')
|
|
23
|
+
depth++;
|
|
24
|
+
else if (c === ')' && --depth === 0)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const end = Math.min(j + 1, line.length);
|
|
29
|
+
// $(( … )) is arithmetic, not a command.
|
|
30
|
+
const inner = line.startsWith('$((', i) ? '' : line.slice(line[i] === '`' ? i + 1 : i + 2, j);
|
|
31
|
+
return { end, text: line.slice(i, end), inner };
|
|
32
|
+
}
|
|
33
|
+
/** The quoted text opened at `i`: where it ends (past its closing quote, or the end of the line), its content, the
|
|
34
|
+
* quotes as they stay in what the command does (one when it is left open), and the substitutions in a `"…"`. */
|
|
35
|
+
function quoted(line, i) {
|
|
36
|
+
const open = line[i] === '$' ? "$'" : line[i];
|
|
37
|
+
const close = open.at(-1);
|
|
38
|
+
const subs = [];
|
|
39
|
+
let j = i + open.length;
|
|
40
|
+
while (j < line.length && line[j] !== close) {
|
|
41
|
+
// In "…" a backslash escapes only these, and $( … ) and backticks run commands; in $'…' a backslash escapes
|
|
42
|
+
// anything; in '…' nothing is special.
|
|
43
|
+
if (open === '"' && (line.startsWith('$(', j) || line[j] === '`')) {
|
|
44
|
+
const sub = substitution(line, j);
|
|
45
|
+
subs.push(sub);
|
|
46
|
+
j = sub.end;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (line[j] === '\\' && (open === "$'" || (open === '"' && '"\\$`'.includes(line[j + 1] ?? ''))))
|
|
50
|
+
j++;
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
const closed = j < line.length;
|
|
54
|
+
return { end: closed ? j + 1 : line.length, content: line.slice(i + open.length, j), quotes: closed ? open + close : open, subs };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Heredoc bodies taken out of the raw lines, each with the offset of its `<<` in the text left. Found before quotes
|
|
58
|
+
* are read, since a heredoc inside `"$( … )"` (how commit messages are often written) holds any quotes; and only with
|
|
59
|
+
* its closing line, so `$((1 << 2))` is not one.
|
|
60
|
+
*/
|
|
61
|
+
function heredocs(line) {
|
|
62
|
+
const lines = line.replace(/\r\n?/g, '\n').split('\n');
|
|
63
|
+
const kept = [];
|
|
64
|
+
const bodies = [];
|
|
65
|
+
// A closing line not found from some line on is not found from any later one: each is looked for once.
|
|
66
|
+
const missing = new Map();
|
|
67
|
+
const closing = (word, tabs, from) => {
|
|
68
|
+
const key = `${tabs}${word}`;
|
|
69
|
+
if ((missing.get(key) ?? Infinity) <= from)
|
|
70
|
+
return -1;
|
|
71
|
+
for (let k = from; k < lines.length; k++)
|
|
72
|
+
if ((tabs ? lines[k].replace(/^\t+/, '') : lines[k]) === word)
|
|
73
|
+
return k;
|
|
74
|
+
missing.set(key, from);
|
|
75
|
+
return -1;
|
|
76
|
+
};
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
let next = i + 1;
|
|
80
|
+
let line = '';
|
|
81
|
+
let from = 0;
|
|
82
|
+
// `\EOF` is a heredoc like `'EOF'`; a line that is a comment opens none.
|
|
83
|
+
const markers = /^\s*#/.test(lines[i]) ? [] : lines[i].matchAll(/(?<!<)<<(?!<)(-?)\s*\\?(['"]?)([\w.-]+)\2/g);
|
|
84
|
+
for (const m of markers) {
|
|
85
|
+
const end = closing(m[3], m[1] === '-', next);
|
|
86
|
+
if (end < 0)
|
|
87
|
+
continue;
|
|
88
|
+
// The marker is kept without its quotes: they only say how the body is expanded.
|
|
89
|
+
line += lines[i].slice(from, m.index);
|
|
90
|
+
bodies.push({ at: offset + line.length, body: lines.slice(next, end).join('\n') });
|
|
91
|
+
line += `<<${m[1]}${m[3]}`;
|
|
92
|
+
from = m.index + m[0].length;
|
|
93
|
+
next = end + 1;
|
|
94
|
+
}
|
|
95
|
+
line += lines[i].slice(from);
|
|
96
|
+
kept.push(line);
|
|
97
|
+
offset += line.length + 1;
|
|
98
|
+
i = next - 1;
|
|
99
|
+
}
|
|
100
|
+
return { text: kept.join('\n'), bodies };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A command that runs what it carries: a shell given `-c` (`bash -c '…'`, `docker exec app sh -c '…'`) or only options
|
|
104
|
+
* and a heredoc (`sh <<EOF`), `ssh`, `eval`; wherever it sits in the command, its path optional. A shell given a script
|
|
105
|
+
* (`bash run.sh "x"`) is not one.
|
|
106
|
+
*/
|
|
107
|
+
const SHELL = String.raw `(?:^|\s)(?:\S*/)?(?:sh|bash|zsh|dash)`;
|
|
108
|
+
const WRAPPER = new RegExp(String.raw `${SHELL}\s(?:.*\s)?-[a-zA-Z]*c\b|${SHELL}(?:\s+-\S+)*\s*(?:$|<<)|(?:^|\s)(?:ssh|eval)\s`);
|
|
109
|
+
/** Shell syntax that may open a simple command without being part of what it runs. */
|
|
110
|
+
const SYNTAX = /^(?:[({!]\s*|(?:if|then|else|elif|do|while|until)\s+)+/;
|
|
111
|
+
export function commands(raw) {
|
|
112
|
+
const { text: line, bodies } = heredocs(raw);
|
|
113
|
+
const out = [];
|
|
114
|
+
let does = '';
|
|
115
|
+
let carried = [];
|
|
116
|
+
let inner = []; // the commands of its substitutions
|
|
117
|
+
let start = 0;
|
|
118
|
+
const close = (end, next) => {
|
|
119
|
+
// Heredoc bodies first: a commit message written through one is what the command carries, from its first line.
|
|
120
|
+
const carries = [...bodies.filter((b) => b.at >= start && b.at < end).map((b) => b.body), ...carried].filter(Boolean);
|
|
121
|
+
const command = does.trim().replace(SYNTAX, '');
|
|
122
|
+
if (command) {
|
|
123
|
+
out.push({ does: command, carries: carries.join('\n'), text: line.slice(start, end).trim() });
|
|
124
|
+
if (WRAPPER.test(command))
|
|
125
|
+
for (const c of carries)
|
|
126
|
+
out.push(...commands(c));
|
|
127
|
+
out.push(...inner);
|
|
128
|
+
}
|
|
129
|
+
does = '';
|
|
130
|
+
carried = [];
|
|
131
|
+
inner = [];
|
|
132
|
+
start = next;
|
|
133
|
+
};
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < line.length) {
|
|
136
|
+
const c = line[i];
|
|
137
|
+
const two = line.slice(i, i + 2);
|
|
138
|
+
if (c === "'" || c === '"' || two === "$'") {
|
|
139
|
+
const q = quoted(line, i);
|
|
140
|
+
does += q.quotes;
|
|
141
|
+
for (const sub of q.subs)
|
|
142
|
+
inner.push(...commands(sub.inner));
|
|
143
|
+
// A quoted text that is one substitution carries what its commands carry: `"$(printf 'feat: x')"` carries the
|
|
144
|
+
// message, not the command that prints it.
|
|
145
|
+
const only = q.subs.length === 1 && q.content.trim() === q.subs[0].text;
|
|
146
|
+
carried.push(only ? commands(q.subs[0].inner).map((x) => x.carries).filter(Boolean).join('\n') : q.content);
|
|
147
|
+
i = q.end;
|
|
148
|
+
}
|
|
149
|
+
else if (two === '$(' || c === '`') {
|
|
150
|
+
const sub = substitution(line, i);
|
|
151
|
+
does += sub.text;
|
|
152
|
+
inner.push(...commands(sub.inner));
|
|
153
|
+
i = sub.end;
|
|
154
|
+
}
|
|
155
|
+
else if (c === '\\') {
|
|
156
|
+
// A backslash before a line break continues the line; before anything else it escapes it.
|
|
157
|
+
if (line[i + 1] !== '\n')
|
|
158
|
+
does += two;
|
|
159
|
+
i += 2;
|
|
160
|
+
}
|
|
161
|
+
else if (c === '#' && (i === start || /\s/.test(line[i - 1]))) {
|
|
162
|
+
// A comment runs to the end of its line.
|
|
163
|
+
while (i < line.length && line[i] !== '\n')
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
else if (two === '&&' || two === '||' || two === '|&') {
|
|
167
|
+
close(i, i + 2);
|
|
168
|
+
i += 2;
|
|
169
|
+
}
|
|
170
|
+
else if (c === ';' || c === '\n' || (c === '|' && line[i - 1] !== '>')
|
|
171
|
+
|| (c === '&' && !/[<>]/.test(line[i - 1] ?? '') && line[i + 1] !== '>')) {
|
|
172
|
+
close(i, i + 1);
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
does += c;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
close(line.length, line.length);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heeded",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "did your coding agent follow its instructions?",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Francesco Musso",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/frmusso/heeded.git" },
|
|
8
|
+
"bugs": "https://github.com/frmusso/heeded/issues",
|
|
9
|
+
"homepage": "https://github.com/frmusso/heeded#readme",
|
|
10
|
+
"keywords": ["claude-code", "claude", "coding-agent", "claude-md", "instructions", "transcripts", "cli"],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "heeded": "dist/cli.js" },
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"engines": { "node": ">=22" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
|
17
|
+
"prepack": "npm run build",
|
|
18
|
+
"typecheck": "tsc",
|
|
19
|
+
"test": "node --test --test-concurrency=1 'tests/**/*.test.ts'"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "22.20.5",
|
|
23
|
+
"typescript": "7.0.2"
|
|
24
|
+
}
|
|
25
|
+
}
|