heeded 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,16 +12,16 @@ $ npx heeded
12
12
  3 files · 41 rules · 12 checked · 29 need a judge
13
13
 
14
14
  CLAUDE.md
15
- ✗ never git add -A kept 95% of 214 broken 9× last 2 days ago
15
+ ✗ :12 never git add -A kept 95% of 214 broken 9× last 2 days ago
16
16
  nov 8 19df6c20 git add -A && git commit -m "fix: …"
17
17
  nov 2 8c0e8fab git add -A
18
- ✓ never force-push kept 100% of 61
19
- ○ use pnpm, not npm never applied ~40 tokens every session
20
- · failing test first kept 80% of 20 judged broken 4× last 3 days ago
18
+ ✓ :14 never force-push kept 100% of 61
19
+ ○ :21 use pnpm, not npm never applied ~40 tokens every session
20
+ · :30 failing test first kept 80% of 20 judged broken 4× last 3 days ago
21
21
  ~/.claude/CLAUDE.md
22
- ✗ answer in italian kept 96% of 3,812 broken 152× last today
22
+ ✗ :3 answer in italian kept 96% of 3,812 broken 152× last today
23
23
  after compaction kept 69% of 410
24
- ○ ask before deploys never applied in 8 judged ~30 tokens every session
24
+ ○ :17 ask before deploys never applied in 8 judged ~30 tokens every session
25
25
 
26
26
  kept by context depth 0–100k 100–200k 200–400k 400k+
27
27
  98% 97% 91% 84%
@@ -44,6 +44,8 @@ $ npx heeded
44
44
  - `after compaction`: the same rule, counted only after claude code compacted the context.
45
45
  - each rule is under the file it is written in: the folder's files first, then the others (`~/.claude/CLAUDE.md` is
46
46
  loaded in every project, so a rule there can be never applied here and needed elsewhere).
47
+ - `:12`: the line the rule starts on in that file, as the file is now. no number: its words aren't in the file any
48
+ more.
47
49
  - `kept by context depth`: every rule together, by how full the context was at the time.
48
50
  - `never applied in 8 judged`: none of the 8 cases the judge read was about the rule.
49
51
  - `rules no call or reply shows`: rules about how to work ("plan before coding"), which no single call or reply can
@@ -51,6 +53,34 @@ $ npx heeded
51
53
 
52
54
  ## use
53
55
 
56
+ needs node 22 or later and claude code, signed in: heeded calls your own `claude` to read rules and to judge.
57
+
58
+ 1. **run it in a project** you've worked on with claude code:
59
+
60
+ ```sh
61
+ cd ~/code/my-app
62
+ npx heeded
63
+ ```
64
+
65
+ 2. **say yes, or not yet.** reading the rules and judging cost tokens from your claude account, so heeded shows the
66
+ estimate and asks before spending anything:
67
+
68
+ ```text
69
+ 11 rule sections to read, then cases to judge · claude sonnet and haiku · up to ~175k tokens
70
+ go ahead? [y/N]
71
+ ```
72
+
73
+ `y` reads the rules, judges a first round of cases and prints the report. enter spends nothing: the report shows
74
+ what is already cached, on a first run the sessions found and the sections still to read.
75
+
76
+ 3. **act on the report.** each rule is under its file, with its line (`:12`). a rule broken often may need
77
+ rewriting, or saying why it matters. a rule never applied costs its tokens in every session: move it to the project
78
+ it's for, or remove it. `heeded rules` shows how each rule is checked, and the ones no call or reply can show.
79
+
80
+ 4. **run it again** whenever you like: after more sessions, or after editing an instruction file. every run reads the
81
+ transcripts again, for free, and asks only when something new costs tokens. what was read or judged is cached and
82
+ never paid for twice; with nothing new, the report ends `up to date`.
83
+
54
84
  ```sh
55
85
  npx heeded # the report, for the claude code sessions run in this folder
56
86
  npx heeded rules # the rules found, and how each one is checked
@@ -58,7 +88,7 @@ npx heeded --all # judge every case waiting, not one run's 150k tokens
58
88
  npx heeded --json # everything, as json
59
89
  ```
60
90
 
61
- needs node 22 or later and claude code, signed in: heeded calls your own `claude` to read rules and to judge.
91
+ to type `heeded` instead of `npx heeded`, install it: `npm install -g heeded`.
62
92
 
63
93
  ## how it works
64
94
 
@@ -78,23 +108,13 @@ needs node 22 or later and claude code, signed in: heeded calls your own `claude
78
108
  itself. it answers broken, kept, or doesn't apply. only your words grant a permission; an agent's never do. each
79
109
  verdict is cached. a rule the judge decides is tried on 8 of its cases first: if none is about it, it stops there;
80
110
  if some are, it goes on to 20 cases that applied, or 40 judged. up to 3 calls run at once.
81
- 5. **report.** each rule under its file: kept, broken with the evidence, never applied; by context depth and after
82
- compaction.
83
-
84
- steps 2 and 4 cost tokens. heeded shows the estimate and asks once, before spending anything:
85
-
86
- ```text
87
- 11 rule sections to read, then cases to judge · claude sonnet and haiku · up to ~175k tokens
88
- go ahead? [y/N]
89
- ```
90
-
91
- a run judges within 150k tokens and nothing is paid for twice, so coverage grows run after run. `heeded --all` goes to
92
- the end instead: its question shows what waits now and the most it can reach, then it judges round after round until
93
- nothing waits. off a terminal (ci, a script) it never spends.
111
+ 5. **report.** each rule under its file, at its line in the file as it is now: kept, broken with the evidence, never
112
+ applied; by context depth and after compaction.
94
113
 
95
- run it again whenever you like: every run reads the transcripts again, for free, and asks only when something new
96
- costs tokens (sessions since, a section edited). what was read or judged is cached and never paid for twice; with
97
- nothing new, the report ends `up to date`.
114
+ steps 2 and 4 cost tokens: heeded asks once, before spending anything. a run judges within 150k tokens and nothing is
115
+ paid for twice, so coverage grows run after run. `heeded --all` goes to the end instead: its question shows what waits
116
+ now and the most it can reach, then it judges round after round until nothing waits. off a terminal (ci, a script) it
117
+ never spends.
98
118
 
99
119
  `heeded rules` shows what each rule became:
100
120
 
@@ -104,11 +124,11 @@ $ npx heeded rules
104
124
  ~/repos/app · 1 file · 5 rules
105
125
 
106
126
  CLAUDE.md
107
- ✓ never git add -A Bash command when (^|\s)git\s+add(\s|$) broken \s(-A|--all)\b
108
- ✓ answer in italian language it
109
- · commit only when asked judged on Bash command when (^|\s)git\s+commit(\s|$)
110
- · say what you changed judged on replies
111
- ! never kill the dev server failed its examples: keeps "pkill -f vite-test"
127
+ ✓ :8 never git add -A Bash command when (^|\s)git\s+add(\s|$) broken \s(-A|--all)\b
128
+ ✓ :12 answer in italian language it
129
+ · :15 commit only when asked judged on Bash command when (^|\s)git\s+commit(\s|$)
130
+ · :19 say what you changed judged on replies
131
+ ! :23 never kill the dev server failed its examples: keeps "pkill -f vite-test"
112
132
  ```
113
133
 
114
134
  `✓` a pattern decides it: `when` picks the calls it applies to, `broken` the ones that break it. `·` the judge
package/dist/check.js CHANGED
@@ -455,6 +455,8 @@ export function counter(rulesOf, verdictOf = () => null) {
455
455
  name: rule.name,
456
456
  quote: rule.quote,
457
457
  files: [...files],
458
+ heading: rule.heading,
459
+ lines: {},
458
460
  status: rule.kind === 'judgment' ? 'judgment' : rule.demoted ? 'demoted'
459
461
  : all.broken ? 'broken' : all.applied ? 'kept' : unjudged ? 'to-confirm' : 'never-applied',
460
462
  ...(rule.demoted || rule.noCases ? { reason: rule.demoted ?? rule.noCases } : {}),
package/dist/cli.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // The heeded command: arguments, the two commands, the question before spending, exit codes.
3
- import { readFileSync, realpathSync } from 'node:fs';
3
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { createInterface } from 'node:readline/promises';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { counter } from './check.js';
9
9
  import { read } from './claude-code.js';
10
10
  import { BUDGET, estimate as judgingEstimate, JUDGE_MODEL, judge, select, verdicts } from './judge.js';
11
11
  import { approx, json, rules, text } from './report.js';
12
- import { estimate, extract, MODEL, remember, rulesOf } from './rules.js';
12
+ import { redact } from './redact.js';
13
+ import { estimate, extract, lineOf, MODEL, remember, rulesOf } from './rules.js';
13
14
  const HELP = `heeded — did your coding agent follow its instructions?
14
15
 
15
16
  usage
@@ -25,12 +26,22 @@ reads CLAUDE_CONFIG_DIR, else ~/.claude. caches rules and verdicts in XDG_CACHE_
25
26
  `;
26
27
  /** One pass over the folder's transcripts with the rules and verdicts cached so far: the report, the sections to read,
27
28
  * 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
+ async function scan(configDir, folder, cacheDir, home) {
29
30
  const count = counter(rulesOf(cacheDir), verdicts(cacheDir));
30
31
  const { unrecognized } = await read(configDir, folder, count.on);
31
32
  for (const v of count.readVersions())
32
33
  remember(cacheDir, v.identity, v.section.hash, v.at);
33
34
  const data = count.result();
35
+ // Where each rule is written now, what the user opens to rewrite or remove it.
36
+ // Claude Code reads the user's own file from ~/.claude whatever the config dir.
37
+ const content = onDisk(folder, [configDir, join(home, '.claude')]);
38
+ for (const rule of data.rules) {
39
+ for (const file of rule.files) {
40
+ const line = lineOf(content(file), rule.quote, rule.heading);
41
+ if (line)
42
+ rule.lines[file] = line;
43
+ }
44
+ }
34
45
  const waiting = count.waiting();
35
46
  // A tool rule lists 40 of its candidates: the others cost what those listed cost on average.
36
47
  const listed = [...waiting.tool, ...waiting.judgment].flat();
@@ -38,6 +49,30 @@ async function scan(configDir, folder, cacheDir) {
38
49
  return { report: { folder, configDir, unrecognized, ...data, toJudgeTokens: Math.round(each * data.toJudge) },
39
50
  pending: count.pending(), waiting, mostTokens: Math.round(each * data.atMost) };
40
51
  }
52
+ /** Instruction files as they are on disk now, secrets redacted as in what their rules were read from (a redaction
53
+ * keeps every line); empty for a file gone. Read only in the folder, the folders above it and the agent's own: where
54
+ * the user already gave access, so no privacy prompt for another folder. And only a regular file under 1 MB: a path
55
+ * from a transcript may name a pipe, which would never end. */
56
+ function onDisk(folder, own) {
57
+ const read = new Map();
58
+ const allowed = (file) => [folder, ...own].some((dir) => file.startsWith(`${dir}/`))
59
+ || `${folder}/`.startsWith(dirname(file).replace(/\/?$/, '/'));
60
+ return (file) => {
61
+ if (!read.has(file)) {
62
+ let text = '';
63
+ try {
64
+ const stat = allowed(file) ? statSync(file) : null;
65
+ if (stat?.isFile() && stat.size < 1_000_000)
66
+ text = redact(readFileSync(file, 'utf8'));
67
+ }
68
+ catch {
69
+ // gone, or not readable: no line
70
+ }
71
+ read.set(file, text);
72
+ }
73
+ return read.get(file);
74
+ };
75
+ }
41
76
  const thousands = (n) => n.toLocaleString('en-US');
42
77
  const plural = (n, word) => `${thousands(n)} ${word}${n === 1 ? '' : 's'}`;
43
78
  const clock = (ms) => `${Math.floor(ms / 60_000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
@@ -79,6 +114,8 @@ export async function main(argv, io) {
79
114
  return usage(`unknown command ${command}`);
80
115
  if (rest.length)
81
116
  return usage(`unexpected ${rest.join(' ')}`);
117
+ if (command === 'rules' && args.values.all)
118
+ return usage('--all judges cases, and heeded rules never judges');
82
119
  if (args.values.help) {
83
120
  io.out(HELP);
84
121
  return 0;
@@ -101,7 +138,7 @@ async function run(command, asJson, all, io) {
101
138
  const home = io.env.HOME || homedir();
102
139
  const configDir = io.env.CLAUDE_CONFIG_DIR || join(home, '.claude');
103
140
  const cacheDir = io.env.XDG_CACHE_HOME || join(home, '.cache');
104
- let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir);
141
+ let { report, pending, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home);
105
142
  let code = 0;
106
143
  const failed = (errors) => {
107
144
  for (const e of errors)
@@ -120,9 +157,15 @@ async function run(command, asJson, all, io) {
120
157
  io.err(`read ${plural(found.read, 'section')} · ${thousands(found.tokens)} tokens\n`);
121
158
  failed(found.errors);
122
159
  if (found.read)
123
- ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
160
+ ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
124
161
  return found.read > 0;
125
162
  };
163
+ /** Asks about the new sections alone, and reads them on yes. */
164
+ const askToRead = async (then) => {
165
+ io.err(`${plural(distinct.length, 'rule section')} to read${then} · claude ${MODEL}`
166
+ + ` · ${approx(estimate(distinct).tokens)} tokens\n`);
167
+ return await yes() && await readSections();
168
+ };
126
169
  /** Judges the cases a round picks, then counts again; with `all`, again while cases wait and the last round judged
127
170
  * some. One line for every round. */
128
171
  const judgeCases = async () => {
@@ -138,7 +181,7 @@ async function run(command, asJson, all, io) {
138
181
  tokens += done.tokens;
139
182
  errors.push(...done.errors);
140
183
  if (done.judged)
141
- ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir));
184
+ ({ report, waiting, mostTokens } = await scan(configDir, io.cwd, cacheDir, home));
142
185
  if (!all || !done.judged)
143
186
  break;
144
187
  }
@@ -148,7 +191,12 @@ async function run(command, asJson, all, io) {
148
191
  };
149
192
  // Reading sections and judging cases spend the user's quota: only when asked on a terminal, after what the run may
150
193
  // spend. Every call costs ~4k tokens: an estimate is never under a thousand.
151
- if (io.terminal && (distinct.length || report.toJudge > 0)) {
194
+ if (io.terminal && command === 'rules') {
195
+ // The rules and how each is checked: no verdict changes them, only new sections do.
196
+ if (distinct.length)
197
+ await askToRead('');
198
+ }
199
+ else if (io.terminal && (distinct.length || report.toJudge > 0)) {
152
200
  if (!all) {
153
201
  // One question. The cases of the rules about to be read aren't known yet: with sections to read, it shows the
154
202
  // ceiling.
@@ -170,12 +218,7 @@ async function run(command, asJson, all, io) {
170
218
  }
171
219
  else {
172
220
  // 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
- }
221
+ const reading = !distinct.length || await askToRead(' first');
179
222
  if (reading && report.toJudge > 0) {
180
223
  const more = report.atMost > report.toJudge;
181
224
  io.err(`${plural(report.toJudge, 'case')} to judge${more ? ', more if their rules apply' : ''}`
package/dist/report.js CHANGED
@@ -30,6 +30,16 @@ const inOrder = (r) => {
30
30
  };
31
31
  /** The file a rule is listed under: of the files it is written in, the first listed. */
32
32
  const under = (order, x) => order.find((f) => x.files.includes(f));
33
+ /** Each rule's line in the file it is listed under, as `:12`, padded to the widest: where the user goes to rewrite or
34
+ * remove it. No column when no rule has one (its file gone, or edited since and its words with it). */
35
+ const lineCells = (order, rules) => {
36
+ const line = (x) => {
37
+ const n = x.lines[under(order, x) ?? ''];
38
+ return n ? `:${n}` : '';
39
+ };
40
+ const width = Math.max(0, ...rules.map((x) => line(x).length));
41
+ return new Map(rules.map((x) => [x, width ? `${line(x).padEnd(width)} ` : '']));
42
+ };
33
43
  /** A file as the user finds it: by its path in the folder, or from home. */
34
44
  const where = (r, path, home) => (path.startsWith(`${r.folder}/`) ? path.slice(r.folder.length + 1) : tilde(path, home));
35
45
  /** Floored, so never 100% with a violation; and never 0% with a case kept. */
@@ -86,7 +96,9 @@ export function text(r, o) {
86
96
  : [`kept ${kept(x.applied, x.broken)} of ${thousands(x.applied)}${x.status === 'judgment' ? ' judged' : ''}`,
87
97
  ...(x.broken ? [`broken ${thousands(x.broken)}×`, `last ${ago(x.evidence[0].at, o.now)}`] : [])]),
88
98
  ...(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]));
99
+ const at = lineCells(order, shown);
100
+ const laid = (width) => columns(rows.map(([name, ...rest]) => [cut(name, width).padEnd(width), ...rest]))
101
+ .map((l, i) => `${at.get(shown[i])}${l}`);
90
102
  let width = Math.max(...rows.map((row) => row[0].length));
91
103
  while (width > 10 && Math.max(...laid(width).map((l) => l.length + 6)) > o.columns)
92
104
  width--;
@@ -157,6 +169,7 @@ export function rules(r, o) {
157
169
  const width = Math.max(...r.rules.map((x) => x.name.length));
158
170
  const lines = ['', ` ${tilde(r.folder, o.home)} · ${plural(r.files.length, 'file')} · ${plural(r.rules.length, 'rule')}`, ''];
159
171
  const order = inOrder(r);
172
+ const at = lineCells(order, r.rules);
160
173
  for (const file of order) {
161
174
  const mine = r.rules.filter((x) => under(order, x) === file);
162
175
  if (mine.length)
@@ -164,7 +177,7 @@ export function rules(r, o) {
164
177
  for (const x of mine) {
165
178
  const mark = x.status === 'judgment' ? paint(o.color, 2, 22, '·') : x.status === 'demoted' ? paint(o.color, 33, 39, '!')
166
179
  : paint(o.color, 32, 39, '✓');
167
- lines.push(` ${mark} ${x.name.padEnd(width + 2)}${how(x)}`);
180
+ lines.push(` ${mark} ${at.get(x)}${x.name.padEnd(width + 2)}${how(x)}`);
168
181
  }
169
182
  }
170
183
  if (r.notRead || r.toReread)
package/dist/rules.js CHANGED
@@ -46,6 +46,31 @@ export function sections(content) {
46
46
  return out;
47
47
  }
48
48
  const collapse = (text) => text.replace(/\s+/g, ' ').trim();
49
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ const WORD = /[\p{L}\p{N}_]/u;
51
+ /** The line a rule's words start on in a file's text, however they are spaced and wrapped there: under its section's
52
+ * headings (` › ` between them), as whole words, so the same words earlier (an intro, another section) aren't taken
53
+ * for it. Null when the text no longer holds them. What the user opens to rewrite or remove the rule. */
54
+ export function lineOf(content, quote, heading) {
55
+ const words = quote.split(/\s+/).filter(Boolean);
56
+ if (!words.length)
57
+ return null;
58
+ // Down the section's headings, as far as the file still has them: from the line of the last one found.
59
+ let from = 0;
60
+ let next = 0;
61
+ for (const title of heading ? heading.split(' › ') : []) {
62
+ const at = new RegExp(`^#{1,6}[ \\t]+${escape(title)}[ \\t]*\\r?$`, 'mu').exec(content.slice(next));
63
+ if (!at)
64
+ break;
65
+ from = next + at.index;
66
+ next = from + at[0].length;
67
+ }
68
+ const [first, last] = [[...words[0]][0], [...words.at(-1)].at(-1)];
69
+ const whole = new RegExp(`${WORD.test(first) ? '(?<![\\p{L}\\p{N}_])' : ''}${words.map(escape).join('\\s+')}`
70
+ + `${WORD.test(last) ? '(?![\\p{L}\\p{N}_])' : ''}`, 'u');
71
+ const at = whole.exec(content.slice(from))?.index;
72
+ return at === undefined ? null : content.slice(0, from + at).split('\n').length;
73
+ }
49
74
  /**
50
75
  * A rule as the extraction returned it, checked here: its quote must be in its section as the model read it (secrets
51
76
  * redacted), word for word (spacing aside), or the rule is dropped; a check that can't be trusted is demoted, with the
@@ -56,7 +81,8 @@ export function verify(raw, section) {
56
81
  if (typeof r?.quote !== 'string' || !collapse(redact(section.text)).includes(collapse(r.quote)))
57
82
  return null;
58
83
  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();
84
+ const chars = [...String(r.name).replace(/[\p{Cc}\s]+/gu, ' ').trim()];
85
+ const name = chars.length > 40 ? `${chars.slice(0, 39).join('').trimEnd()}…` : chars.join('');
60
86
  const own = /^#{1,6}[ \t]+(.+)$/.exec(section.text.split('\n')[0])?.[1].trim();
61
87
  const heading = [...section.path, ...(own ? [own] : [])].join(' › ');
62
88
  // What the section says around the rule: from 500 characters before its words to 1,000 after.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heeded",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "did your coding agent follow its instructions?",
5
5
  "license": "MIT",
6
6
  "author": "Francesco Musso",