@kolisachint/hoocode-agent 0.5.17 → 0.5.19

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/dist/core/learn/audit.d.ts +136 -0
  3. package/dist/core/learn/audit.d.ts.map +1 -0
  4. package/dist/core/learn/audit.js +316 -0
  5. package/dist/core/learn/audit.js.map +1 -0
  6. package/dist/core/learn/cache.d.ts +58 -0
  7. package/dist/core/learn/cache.d.ts.map +1 -0
  8. package/dist/core/learn/cache.js +132 -0
  9. package/dist/core/learn/cache.js.map +1 -0
  10. package/dist/core/learn/cluster.d.ts +78 -0
  11. package/dist/core/learn/cluster.d.ts.map +1 -0
  12. package/dist/core/learn/cluster.js +184 -0
  13. package/dist/core/learn/cluster.js.map +1 -0
  14. package/dist/core/learn/coverage.d.ts +58 -0
  15. package/dist/core/learn/coverage.d.ts.map +1 -0
  16. package/dist/core/learn/coverage.js +144 -0
  17. package/dist/core/learn/coverage.js.map +1 -0
  18. package/dist/core/learn/digest.d.ts +13 -0
  19. package/dist/core/learn/digest.d.ts.map +1 -1
  20. package/dist/core/learn/digest.js +113 -14
  21. package/dist/core/learn/digest.js.map +1 -1
  22. package/dist/core/learn/extract.d.ts +108 -105
  23. package/dist/core/learn/extract.d.ts.map +1 -1
  24. package/dist/core/learn/extract.js +308 -447
  25. package/dist/core/learn/extract.js.map +1 -1
  26. package/dist/core/learn/mine.d.ts +178 -0
  27. package/dist/core/learn/mine.d.ts.map +1 -0
  28. package/dist/core/learn/mine.js +390 -0
  29. package/dist/core/learn/mine.js.map +1 -0
  30. package/dist/core/learn/reduce.d.ts +89 -0
  31. package/dist/core/learn/reduce.d.ts.map +1 -0
  32. package/dist/core/learn/reduce.js +179 -0
  33. package/dist/core/learn/reduce.js.map +1 -0
  34. package/dist/core/learn/state.d.ts +19 -18
  35. package/dist/core/learn/state.d.ts.map +1 -1
  36. package/dist/core/learn/state.js +35 -31
  37. package/dist/core/learn/state.js.map +1 -1
  38. package/dist/core/settings-defaults.d.ts +1 -1
  39. package/dist/core/settings-defaults.d.ts.map +1 -1
  40. package/dist/core/settings-defaults.js +1 -1
  41. package/dist/core/settings-defaults.js.map +1 -1
  42. package/dist/core/settings-manager.d.ts +4 -2
  43. package/dist/core/settings-manager.d.ts.map +1 -1
  44. package/dist/core/settings-manager.js +5 -1
  45. package/dist/core/settings-manager.js.map +1 -1
  46. package/dist/core/settings-types.d.ts +1 -1
  47. package/dist/core/settings-types.d.ts.map +1 -1
  48. package/dist/core/settings-types.js.map +1 -1
  49. package/dist/core/startup-progress.d.ts +12 -7
  50. package/dist/core/startup-progress.d.ts.map +1 -1
  51. package/dist/core/startup-progress.js +12 -7
  52. package/dist/core/startup-progress.js.map +1 -1
  53. package/dist/extensions/core/learn.d.ts +8 -4
  54. package/dist/extensions/core/learn.d.ts.map +1 -1
  55. package/dist/extensions/core/learn.js +292 -56
  56. package/dist/extensions/core/learn.js.map +1 -1
  57. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  58. package/dist/modes/interactive/components/footer.js +7 -25
  59. package/dist/modes/interactive/components/footer.js.map +1 -1
  60. package/dist/modes/interactive/components/progress-bar.d.ts +50 -0
  61. package/dist/modes/interactive/components/progress-bar.d.ts.map +1 -0
  62. package/dist/modes/interactive/components/progress-bar.js +77 -0
  63. package/dist/modes/interactive/components/progress-bar.js.map +1 -0
  64. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  65. package/dist/modes/interactive/components/settings-selector.js +1 -1
  66. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  67. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  68. package/dist/modes/interactive/interactive-mode.js +1 -1
  69. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  70. package/dist/modes/interactive/voice/voice-panel.d.ts +6 -1
  71. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  72. package/dist/modes/interactive/voice/voice-panel.js +18 -14
  73. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  74. package/docs/settings.md +9 -6
  75. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  76. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  77. package/examples/extensions/sandbox/package.json +1 -1
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/package.json +4 -4
  80. package/dist/core/learn/normalize.d.ts +0 -65
  81. package/dist/core/learn/normalize.d.ts.map +0 -1
  82. package/dist/core/learn/normalize.js +0 -245
  83. package/dist/core/learn/normalize.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,252 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.19] - 2026-08-16
4
+
5
+ ### Breaking Changes
6
+
7
+ - The `workflow` category is gone, and `learnMinWorkflowRepeats` with it. It
8
+ mined tool sequences and proposed them as skills, and in practice it proposed
9
+ nothing at all: on a real corpus, 30 candidates were mined and not one ever
10
+ cleared its threshold, because the threshold was three identical
11
+ model-invented names for a tool sequence across separate sessions. Nor is it
12
+ worth reimplementing deterministically — counting tool trigrams over the same
13
+ corpus yields 170 "workflows" led by `bash -> bash -> bash` at 1142
14
+ occurrences. Tool names carry no procedural meaning.
15
+ - `learnMinWorkflowRepeats` is replaced by `learnMinRequestRepeats` (default 3),
16
+ which gates the new `request` category below.
17
+
18
+ ### Added
19
+
20
+ - `/learn` proposes **slash commands** for work you keep asking for by name. The
21
+ miner was explicitly told not to report task requests — and those discarded
22
+ requests were the strongest unexploited signal in the corpus. A directive is
23
+ *how* work should be done; a request repeated across sessions is *what you
24
+ keep asking for*, which is the definition of a slash command. Held to a higher
25
+ bar than directives: a rule stated twice is a rule, but a job asked for twice
26
+ may just be a job that came up twice.
27
+ - The digest routes a proposal three ways instead of two — rule, skill, or slash
28
+ command — and names the destination for each, along with what it costs: a
29
+ context file is loaded every turn, a skill's description is always loaded but
30
+ its body on demand, and a slash command costs nothing until it is invoked.
31
+ - The digest states the boundary with `ProposePlugin`: skills and commands
32
+ proposed from mined evidence are local habits written under `.agents/`;
33
+ `ProposePlugin` packages something already proven useful into a portable
34
+ artifact. Hooks and MCP servers are never proposed from this evidence, which
35
+ records what was said and what failed — far too weak a warrant for anything
36
+ that executes.
37
+
38
+ ### Changed
39
+
40
+ - `/learn` names occurrences in one global pass instead of asking the miner to
41
+ name them per session. The miner was told to emit a label — its canonical name
42
+ for what was meant — and the reduce step grouped on exact label equality, but
43
+ a session mined in isolation is being asked to hit a shared vocabulary it has
44
+ never seen. On a real corpus that produced 188 distinct labels from 191
45
+ candidates: `use-bun-not-npm` and `prefer-bun-over-npm` are the same rule and
46
+ never met, and nothing ever reached the repeat threshold. Naming now happens
47
+ once, with every candidate visible at the same time, which is the only
48
+ vantage point from which "is this the same point as that" is answerable.
49
+ - Cached candidates no longer carry a label, which makes the mining cache
50
+ model-independent. A label was frozen at mining time, so changing the `fast`
51
+ model tier forked the vocabulary permanently — old sessions and new ones
52
+ naming the same thing differently, with every count split across the seam and
53
+ no sign that it had happened.
54
+ - The naming pass is given the labels already on record and told to reuse them.
55
+ State keys are `directive:<label>`, so a label that drifts between runs
56
+ silently breaks suppression and re-proposes everything already decided on.
57
+ - A failed naming call falls back to naming each candidate after its own
58
+ wording, which groups identical sentences and nothing else — the behaviour the
59
+ pipeline had before the pass existed. An outage costs recall, not the run.
60
+
61
+ ### Fixed
62
+
63
+ - `/learn stale` audited nothing when run from a package subdirectory. Context
64
+ files are collected by walking up from the working directory, so in a monorepo
65
+ the repo's `AGENTS.md` sits *above* the package you are in — and the audit
66
+ anchored on the working directory, declared that file "outside this working
67
+ tree", skipped it, and reported a clean run. Running from a package root is
68
+ the normal case, so the check was passing by checking nothing. Referents now
69
+ resolve against the project root.
70
+ - A cluster formed by merging two labels carrying the same sentence took its
71
+ name from whichever session came first, and session order changes whenever a
72
+ session is added. The name is the state key, so it drifted between runs, the
73
+ bookmark stopped matching, and items already decided on came back as new. The
74
+ merged name is now a pure function of what was merged.
75
+ - The clustering vocabulary is trimmed from both ends rather than by taking a
76
+ prefix. It holds labels already on record followed by names coined earlier in
77
+ the same run, which anchor different things — one keeps the bookmark matching
78
+ across runs, the other stops a split window inventing rival names for one
79
+ point. A prefix dropped the second exactly when a window was large enough to
80
+ be split, which is the only time it mattered.
81
+ - Request quotes are flattened to one line and capped. A request is a whole task
82
+ message rather than a sentence — a slash-command body runs to thousands of
83
+ characters — so rendering several raw swamped the digest and a multi-line one
84
+ broke the list it sat in.
85
+ - `/learn` no longer mines text the user never typed. A `user`-type slash
86
+ command is persisted as an ordinary user message holding the whole template
87
+ body, with nothing marking it as machinery, so running `/pr` thirty times
88
+ wrote the same two thousand characters into thirty transcripts — making it the
89
+ most repeated "user statement" in a real corpus by a wide margin. Every
90
+ proposal in a recent 100-session run turned out to be `/pr` template text,
91
+ counted as though it had been said aloud. User turns matching a slash-command
92
+ body are now skipped. Detection is retroactive rather than a flag written at
93
+ turn time, so it fixes transcripts that already exist instead of only future
94
+ ones.
95
+ - `/learn` no longer reads successful tool output. It was fed 600 characters per
96
+ call, so lines out of plan files and configs were mined as directives and
97
+ attributed to the user — a tenth of them on a real corpus. Tool calls and
98
+ errors are still shown, which is what fix detection actually needs.
99
+ - Candidates whose quote cannot be found in what the user said are dropped. The
100
+ miner is told to quote verbatim and the digest renders every quote inside
101
+ quotation marks, but on a real corpus a third of them appeared nowhere in the
102
+ session. A quote that cannot be located is evidence that cannot be shown. This
103
+ also backstops the case above: a command file deleted since a transcript was
104
+ written leaves no fingerprint to match, but its text is still unfindable in
105
+ anything the user said.
106
+ - Two labels carrying the same sentence are merged into one proposal, before the
107
+ repeat threshold rather than after. The model labels each session
108
+ independently and cannot see what it called the same thing last time, so
109
+ identical quotes arrived under two labels and were proposed twice, word for
110
+ word — each below the threshold alone.
111
+ - The mining cache version is bumped: entries mined before these changes were
112
+ read from a different transcript than the pipeline now produces, so the next
113
+ run re-reads the window once.
114
+ - An item is dated by when it was said, not by when its session was opened.
115
+ Suppression compares an item's newest occurrence against the last run, and the
116
+ occurrence was taking the session header's timestamp — so in a session opened
117
+ yesterday and worked in today, something said minutes ago read as older than
118
+ the last run and was held back as "nothing new". It now takes the session's
119
+ last activity.
120
+ - The repeat threshold counts distinct sessions rather than total occurrences.
121
+ Saying a thing twice inside one session is the commonest thing in a transcript
122
+ and usually means the opposite of durable — the agent ignored it the first
123
+ time, so it was restated.
124
+ - Coverage rules are sent to the judge with their heading path and scope
125
+ (`[repo] Git Rules > - Stage only your own files`) instead of bare lines.
126
+ Headings were stripped and the lines under them sent alone, which asks the
127
+ model to decide whether a proposal is in scope using text with the scope
128
+ removed. Fenced code blocks are dropped: a sample illustrates a rule, it is
129
+ not one.
130
+ - A run whose coverage judge failed no longer writes the bookmark. Everything
131
+ reads `new` when that call fails, and recording that as "was not written down
132
+ when shown" made a later run report proposals as passed over that were never
133
+ shown. Skipping the write costs one round of re-proposing.
134
+ - An empty run now reports its funnel: occurrences read, distinct points named,
135
+ and how many fell below the repeat threshold. The pipeline filters hard —
136
+ replayed command bodies, tool output, unfindable quotes, then a
137
+ distinct-session bar — and all of it was silent, so "nothing to propose" could
138
+ not be told from "over-filtered" and gave the reader no idea which knob to
139
+ reach for. The below-threshold figure is computed by running the same reduce
140
+ with the threshold at 1, so it is what the threshold cost rather than an
141
+ estimate of it.
142
+ - Proposals cut by the per-run cap are reported instead of vanishing. Eight
143
+ shown out of twenty read as "twenty was all there was", which sends the reader
144
+ to the wrong knob.
145
+ - Added an evaluation harness for the naming pass (`test/support/learn-eval.ts`
146
+ over a hand-grouped corpus). Every existing test injected a clusterer that
147
+ already agreed with itself, so none of them could fail on the assumption the
148
+ design rests on — which is how a 1.6% collision rate shipped and stayed for
149
+ months. The harness scores merges, false merges, and cluster ratio, and takes
150
+ a real model's clusterer so the assumption can be measured rather than
151
+ assumed.
152
+
153
+ - `/learn stats` no longer reports an adoption rate, and no longer makes a model
154
+ call. It re-judged coverage and called the delta "adopted", which moved with a
155
+ failed judge at either end and could not tell a proposal correctly rejected as
156
+ junk from one ignored; it shipped with two disclaimers explaining how not to
157
+ misread it. It now reports what has been proposed, and the always-loaded token
158
+ cost of the context files, which is the question the number was reaching for
159
+ and one the filesystem answers exactly.
160
+
161
+ - `/learn stale` — the subtractive half of `/learn`. The mining path can only
162
+ ever propose additions, so nothing in the command moved the always-loaded
163
+ token surface down: a rule naming a deleted workflow or a command that was
164
+ removed keeps costing tokens on every request, and the agent believes it. The
165
+ audit resolves every backticked path and `bun run` script named by the context
166
+ files in force against the working tree and every package root in it, and
167
+ reports the lines whose referents are gone, priced in tokens. It is
168
+ deterministic — no model call, no cache, no state — so it costs nothing and
169
+ can be run often. On this repo it found four: a documented slash command
170
+ deleted three months ago, and three contribution-gate workflows that do not
171
+ exist.
172
+ - Precision comes from exclusions rather than cleverness, because a noisy
173
+ report is one nobody reads: only path-like referents with a separator are
174
+ checked (a bare `stream.test.ts` in a monorepo is under-specified, not stale),
175
+ paths resolve against every package root rather than the repo root alone, and
176
+ lines that assert absence ("was removed", "e.g. `x.ts`") are skipped, since
177
+ they are correct precisely because the file is missing. Findings are presented
178
+ to the model as candidates to verify, not as a licence to delete.
179
+
180
+ ## [0.5.18] - 2026-08-15
181
+
182
+ ### Changed
183
+
184
+ - Every surface that shows measurable progress now uses one progress bar. The footer (tool
185
+ downloads, the semantic index, `/learn`) and the voice panel each had their
186
+ own, written months apart, and they had drifted three ways: one drew `▰▱` and
187
+ the other `·` over `·`, one said `2.0 MB` and the other `2 MB`, and each
188
+ carried its own copy of the percent-and-detail layout. Bar width is still per
189
+ surface — a footer line shares its row, a panel has room to be finer — but
190
+ nothing else is.
191
+ - Progress bars distinguish filled from empty by shape (`▰▱`) rather than by
192
+ colour alone, matching the context gauge beside them. The previous `·`-over-`·`
193
+ fill put the entire reading in the colour, so at a glance, on a low-contrast
194
+ theme, or anywhere styling is dropped, 10% and 90% looked identical. A started
195
+ bar also keeps one filled cell instead of rounding down to empty, so the first
196
+ of forty files reads differently from none of them.
197
+ - `/learn` now reads session transcripts with a model instead of pre-filtering
198
+ them with regexes. The old extractor only considered user turns matching a
199
+ whitelist of imperative words, so anything phrased another way — "we're on bun
200
+ now", "that's not how our error handling works", a constraint stated once in
201
+ passing — was not ranked low, it was invisible. Length and line-count caps
202
+ dropped long explanations too, which is where the reasoning behind a rule
203
+ usually is. Every user turn now goes to the model whole.
204
+ - Grouping is semantic rather than lexical. The model labels each occurrence
205
+ with what was *meant*, so "we're on bun now" and "stop using npm" count as one
206
+ recurring point instead of two unrelated ones. Counting still happens in code:
207
+ a model asked to count across a long context is approximately right, and the
208
+ count is what the digest is for.
209
+ - Coverage — whether a proposal is already written down — is a model judgement
210
+ instead of word overlap. The old 0.6-overlap test both called unrelated rules a
211
+ match (marking a working rule `restated`) and missed real paraphrases that
212
+ picked different vocabulary (proposing a duplicate).
213
+ - Mining results are cached per session file, keyed on content hash, so each
214
+ transcript is read once in its life. Counts are still recomputed over every
215
+ session in the window on every run, so caching the expensive step never costs
216
+ the cross-session evidence. A run reports what it read versus reused, and asks
217
+ before reading more than a few new sessions.
218
+ - The digest names the mode it ran in, so "nothing new since last time" and
219
+ "nothing here at all" no longer read alike.
220
+ - `/learn`'s per-directory memory is discarded once on upgrade. Its keys used to
221
+ be normalized directive text and are now the miner's semantic label, so old
222
+ entries could never match a new proposal — harmless for suppression, but every
223
+ one of them would have counted in `/learn stats` as a proposal that was never
224
+ adopted, holding the rate down permanently. The cost of discarding is one round
225
+ of re-proposing.
226
+
227
+ ### Added
228
+
229
+ - `/learn` shows a progress bar in the footer while it reads transcripts — the
230
+ same one the semantic index uses — with the count of sessions done, how many
231
+ came from cache, and the reminder that escape stops it. Cached sessions count
232
+ as done: the bar measures progress through the window, so a mostly-cached run
233
+ looks nearly finished from the start, which it is.
234
+ - A long backfill can be stopped with escape. Everything read up to that point is
235
+ already cached, so resuming picks up where it left off. A stopped run neither
236
+ shows nor records its proposals: it counted only part of the window, so its
237
+ numbers are low, and bookmarking them would hide those items on the next
238
+ complete run.
239
+ - `/learn` reads transcripts with the `fast` model category — the same tier
240
+ subagents already use for bulk reads — rather than the session's model. Set
241
+ `modelCategories.fast` to change it; left unset it is derived from the models
242
+ you have, so nothing here is provider-specific. `/learn settings` names the
243
+ model it resolved and how much it sends per call.
244
+ - Transcripts are chunked to fit the reading model's context window instead of a
245
+ fixed 120k characters. Rendering already compresses the two real transcripts in
246
+ this repo from 0.93 MB and 2.26 MB to roughly 47k and 68k tokens, so on a
247
+ 200k-token model each is now a single call rather than two and three. Fewer
248
+ boundaries also means fewer blind spots: a failure and the fix that resolved it
249
+ can otherwise land on opposite sides of one.
3
250
  ## [0.5.17] - 2026-08-14
4
251
 
5
252
  ### Fixed
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The subtractive half of `/learn`: which lines in a context file describe
3
+ * things that no longer exist?
4
+ *
5
+ * The mining pipeline can only ever propose additions. Nothing in it moves the
6
+ * always-loaded token surface down, so a context file accumulates: a rule
7
+ * naming a deleted workflow, a command that was removed, a file that moved two
8
+ * refactors ago. Those lines cost tokens on every request forever and are worse
9
+ * than useless, because the agent believes them.
10
+ *
11
+ * This is deliberately deterministic — no model call, no cache, no state. It
12
+ * reads the context files already in force, pulls out the referents they name
13
+ * in backticks, and asks the filesystem. That makes it instant and free, which
14
+ * is what lets it be the half you run most often.
15
+ *
16
+ * Precision is bought with exclusions rather than cleverness, because a noisy
17
+ * audit is one nobody reads. Three rules do most of the work:
18
+ *
19
+ * - **Only path-like referents with a separator.** A bare `auth.json` could be
20
+ * anywhere or nowhere; `docs/providers.md` is a claim about this repo.
21
+ * - **Resolve against every package root, not just the repo root.** A monorepo
22
+ * names `src/cli/args.ts` relative to the package being discussed, and
23
+ * checking only the repo root reports the entire contributing guide as stale.
24
+ * - **Skip lines that assert absence.** "these are all gone", "e.g.
25
+ * `bedrock-utils.ts`", "create `foo.ts`" legitimately name files that do not
26
+ * exist. Deciding this in general is a judgement call; a short vocabulary of
27
+ * assertive forms catches the cases that occur in practice.
28
+ *
29
+ * What is left is a short list where a wrong entry costs one glance and a right
30
+ * one costs a line of always-loaded context. That asymmetry is the reason the
31
+ * remaining false positives are acceptable and silent misses are not.
32
+ */
33
+ /** One referent that did not resolve, with the line that claimed it. */
34
+ export interface StaleReference {
35
+ /** Context file the claim lives in. */
36
+ file: string;
37
+ /** 1-based line number. */
38
+ line: number;
39
+ /** The line, trimmed — what the reader would delete or fix. */
40
+ lineText: string;
41
+ /** The referent that could not be found. */
42
+ referent: string;
43
+ kind: "path" | "script";
44
+ /** Rough token cost of the line, so the value of deleting it is visible. */
45
+ tokens: number;
46
+ }
47
+ /** Why a referent was not checked. Reported as counts so the audit's reach is visible. */
48
+ export interface AuditSkips {
49
+ /** Contains a placeholder or a glob, e.g. an angle-bracket stand-in or a star. */
50
+ placeholder: number;
51
+ /** Home-relative or absolute: a runtime location, not a repo artifact. */
52
+ runtime: number;
53
+ /** A URL, or a git ref like `origin/main`. */
54
+ external: number;
55
+ /** No path separator, so the claim is not about a specific location. */
56
+ ambiguous: number;
57
+ /** The line asserts the referent is absent, optional, or to be created. */
58
+ assertsAbsence: number;
59
+ }
60
+ export interface AuditReport {
61
+ /** Context files audited, with their recurring cost. */
62
+ files: Array<{
63
+ path: string;
64
+ tokens: number;
65
+ }>;
66
+ /** Context files skipped because they live outside the working tree. */
67
+ skippedFiles: string[];
68
+ /** Referents actually resolved against the filesystem. */
69
+ checked: number;
70
+ skipped: AuditSkips;
71
+ stale: StaleReference[];
72
+ /** Directories referents were resolved against, nearest first. */
73
+ roots: string[];
74
+ }
75
+ /**
76
+ * Directories a relative referent may be resolved against.
77
+ *
78
+ * The repo root alone is not enough: a monorepo's contributing notes name
79
+ * `src/cli/args.ts` meaning "inside the package under discussion", and resolving
80
+ * that only from the root reports every such line as stale. Every directory
81
+ * holding a `package.json` is therefore a root, nearest-shallowest first.
82
+ */
83
+ export declare function resolutionRoots(base: string): string[];
84
+ type Classification = {
85
+ kind: "path";
86
+ } | {
87
+ kind: "script";
88
+ script: string;
89
+ } | {
90
+ kind: "skip";
91
+ reason: keyof AuditSkips;
92
+ };
93
+ /**
94
+ * Decide whether a backticked token is a checkable claim about this repo.
95
+ *
96
+ * Ordering matters: the skip reasons are reported as counts, and a token that
97
+ * matches several should be attributed to the most specific one, so a
98
+ * placeholder is a placeholder rather than "ambiguous".
99
+ */
100
+ export declare function classifyReferent(token: string): Classification;
101
+ /**
102
+ * The project a referent is resolved against: the nearest ancestor holding a
103
+ * `.git`, or `cwd` when there is none.
104
+ *
105
+ * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in
106
+ * a monorepo the repo's `AGENTS.md` sits *above* the package you are working
107
+ * in — and running from a package root is the normal case, not the exception.
108
+ * Anchoring on `cwd` meant the file with all the claims in it was declared "not
109
+ * in this working tree" and skipped, so the audit passed by checking nothing.
110
+ */
111
+ export declare function findProjectRoot(cwd: string): string;
112
+ export interface AuditOptions {
113
+ cwd: string;
114
+ /** Context files in force, as loaded for the system prompt. */
115
+ files: Array<{
116
+ path: string;
117
+ tokens?: number;
118
+ }>;
119
+ }
120
+ /**
121
+ * Check every referent named by the repo-scope context files.
122
+ *
123
+ * Files outside the project are listed but not audited: a rule in
124
+ * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo
125
+ * it was written for, and resolving it here would report another project's
126
+ * rules as broken.
127
+ *
128
+ * File contents are re-read from disk rather than taken from the loader, which
129
+ * truncates oversized files for the prompt — auditing the truncation would
130
+ * silently stop checking exactly the files most likely to have gone stale.
131
+ */
132
+ export declare function auditContextFiles(options: AuditOptions): AuditReport;
133
+ /** Total recurring cost of the lines the audit flagged. */
134
+ export declare function staleTokens(report: AuditReport): number;
135
+ export {};
136
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/core/learn/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC9B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,iEAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAC;IACxB,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CACf;AAED,0FAA0F;AAC1F,MAAM,WAAW,UAAU;IAC1B,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC3B,wDAAwD;IACxD,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/C,wEAAwE;IACxE,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,UAAU,CAAC;IACpB,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,kEAAkE;IAClE,KAAK,EAAE,MAAM,EAAE,CAAC;CAChB;AAgDD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAqBtD;AA0BD,KAAK,cAAc,GAChB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,UAAU,CAAA;CAAE,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,CAwB9D;AA0BD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQnD;AASD,MAAM,WAAW,YAAY;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,+DAA+D;IAC/D,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,WAAW,CA6EpE;AAED,2DAA2D;AAC3D,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAUvD","sourcesContent":["/**\n * The subtractive half of `/learn`: which lines in a context file describe\n * things that no longer exist?\n *\n * The mining pipeline can only ever propose additions. Nothing in it moves the\n * always-loaded token surface down, so a context file accumulates: a rule\n * naming a deleted workflow, a command that was removed, a file that moved two\n * refactors ago. Those lines cost tokens on every request forever and are worse\n * than useless, because the agent believes them.\n *\n * This is deliberately deterministic — no model call, no cache, no state. It\n * reads the context files already in force, pulls out the referents they name\n * in backticks, and asks the filesystem. That makes it instant and free, which\n * is what lets it be the half you run most often.\n *\n * Precision is bought with exclusions rather than cleverness, because a noisy\n * audit is one nobody reads. Three rules do most of the work:\n *\n * - **Only path-like referents with a separator.** A bare `auth.json` could be\n * anywhere or nowhere; `docs/providers.md` is a claim about this repo.\n * - **Resolve against every package root, not just the repo root.** A monorepo\n * names `src/cli/args.ts` relative to the package being discussed, and\n * checking only the repo root reports the entire contributing guide as stale.\n * - **Skip lines that assert absence.** \"these are all gone\", \"e.g.\n * `bedrock-utils.ts`\", \"create `foo.ts`\" legitimately name files that do not\n * exist. Deciding this in general is a judgement call; a short vocabulary of\n * assertive forms catches the cases that occur in practice.\n *\n * What is left is a short list where a wrong entry costs one glance and a right\n * one costs a line of always-loaded context. That asymmetry is the reason the\n * remaining false positives are acceptable and silent misses are not.\n */\n\nimport { type Dirent, existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { join, resolve, sep } from \"node:path\";\n\n/** One referent that did not resolve, with the line that claimed it. */\nexport interface StaleReference {\n\t/** Context file the claim lives in. */\n\tfile: string;\n\t/** 1-based line number. */\n\tline: number;\n\t/** The line, trimmed — what the reader would delete or fix. */\n\tlineText: string;\n\t/** The referent that could not be found. */\n\treferent: string;\n\tkind: \"path\" | \"script\";\n\t/** Rough token cost of the line, so the value of deleting it is visible. */\n\ttokens: number;\n}\n\n/** Why a referent was not checked. Reported as counts so the audit's reach is visible. */\nexport interface AuditSkips {\n\t/** Contains a placeholder or a glob, e.g. an angle-bracket stand-in or a star. */\n\tplaceholder: number;\n\t/** Home-relative or absolute: a runtime location, not a repo artifact. */\n\truntime: number;\n\t/** A URL, or a git ref like `origin/main`. */\n\texternal: number;\n\t/** No path separator, so the claim is not about a specific location. */\n\tambiguous: number;\n\t/** The line asserts the referent is absent, optional, or to be created. */\n\tassertsAbsence: number;\n}\n\nexport interface AuditReport {\n\t/** Context files audited, with their recurring cost. */\n\tfiles: Array<{ path: string; tokens: number }>;\n\t/** Context files skipped because they live outside the working tree. */\n\tskippedFiles: string[];\n\t/** Referents actually resolved against the filesystem. */\n\tchecked: number;\n\tskipped: AuditSkips;\n\tstale: StaleReference[];\n\t/** Directories referents were resolved against, nearest first. */\n\troots: string[];\n}\n\n/** Directories never descended into when discovering package roots. */\nconst IGNORED_DIRS = new Set([\"node_modules\", \".git\", \"dist\", \"build\", \"coverage\", \"out\", \".next\", \".turbo\"]);\n\n/** How deep to look for package roots. Deep enough for a monorepo, shallow enough to stay instant. */\nconst MAX_ROOT_DEPTH = 3;\n\n/** Extensions that make a token a file reference even without an obvious path shape. */\nconst FILE_EXTENSIONS =\n\t/\\.(md|markdown|ts|tsx|js|jsx|mjs|cjs|json|jsonc|ya?ml|toml|sh|bash|zsh|py|rs|go|lock|txt|css|html)$/i;\n\n/**\n * Forms that legitimately name something absent.\n *\n * Every one of these was a false positive on a real context file before it was\n * excluded. `removed`/`gone` describe a deletion; `e.g.`/`for example` name a\n * pattern rather than a file; `create`/`scaffold` describe an artifact the\n * reader is being told to write.\n *\n * The list is deliberately short. Broad prohibitions (\"never edit X\", \"do not\n * run Y\") name things that exist and are exactly the claims worth checking, so\n * matching on those words would trade the audit's whole purpose for a little\n * precision.\n */\nconst ABSENCE_MARKERS = [\n\t\"removed\",\n\t\"deleted\",\n\t\" gone\",\n\t\"no longer\",\n\t\"used to\",\n\t\"if present\",\n\t\"if it exists\",\n\t\"optional\",\n\t\"e.g.\",\n\t\"for example\",\n\t\"create \",\n\t\"scaffold\",\n];\n\n/** Referents that look like a git ref rather than a path. */\nconst GIT_REF_PREFIXES = [\"origin/\", \"upstream/\", \"refs/\", \"HEAD\"];\n\n/** Rough token estimate, matching the convention used elsewhere for context files. */\nfunction estimateTokens(text: string): number {\n\treturn Math.round(Buffer.byteLength(text, \"utf-8\") / 4);\n}\n\n/**\n * Directories a relative referent may be resolved against.\n *\n * The repo root alone is not enough: a monorepo's contributing notes name\n * `src/cli/args.ts` meaning \"inside the package under discussion\", and resolving\n * that only from the root reports every such line as stale. Every directory\n * holding a `package.json` is therefore a root, nearest-shallowest first.\n */\nexport function resolutionRoots(base: string): string[] {\n\tconst roots: string[] = [resolve(base)];\n\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > MAX_ROOT_DEPTH) return;\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tfor (const entry of entries) {\n\t\t\tif (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) continue;\n\t\t\tconst child = join(dir, entry.name);\n\t\t\tif (existsSync(join(child, \"package.json\"))) roots.push(child);\n\t\t\twalk(child, depth + 1);\n\t\t}\n\t};\n\twalk(resolve(base), 1);\n\n\treturn roots;\n}\n\n/** Every backticked span in a line, in order. */\nfunction backtickedSpans(line: string): string[] {\n\tconst out: string[] = [];\n\tconst pattern = /`([^`\\n]+)`/g;\n\tlet match = pattern.exec(line);\n\twhile (match) {\n\t\tif (match[1]) out.push(match[1]);\n\t\tmatch = pattern.exec(line);\n\t}\n\treturn out;\n}\n\n/** True when the line reads as a statement about something absent, optional, or yet to be written. */\nfunction assertsAbsence(line: string): boolean {\n\tconst lower = line.toLowerCase();\n\treturn ABSENCE_MARKERS.some((marker) => lower.includes(marker));\n}\n\n/** The script name a `bun run x` / `npm run x` reference names, if it is one. */\nfunction scriptReference(token: string): string | undefined {\n\tconst match = /^(?:bun|npm|pnpm|yarn)\\s+run\\s+([A-Za-z0-9:_-]+)$/.exec(token.trim());\n\treturn match?.[1];\n}\n\ntype Classification =\n\t| { kind: \"path\" }\n\t| { kind: \"script\"; script: string }\n\t| { kind: \"skip\"; reason: keyof AuditSkips };\n\n/**\n * Decide whether a backticked token is a checkable claim about this repo.\n *\n * Ordering matters: the skip reasons are reported as counts, and a token that\n * matches several should be attributed to the most specific one, so a\n * placeholder is a placeholder rather than \"ambiguous\".\n */\nexport function classifyReferent(token: string): Classification {\n\tconst value = token.trim();\n\tconst script = scriptReference(value);\n\tif (script) return { kind: \"script\", script };\n\n\tif (/[<>*{}$|]/.test(value)) return { kind: \"skip\", reason: \"placeholder\" };\n\tif (/^https?:\\/\\//i.test(value)) return { kind: \"skip\", reason: \"external\" };\n\tif (GIT_REF_PREFIXES.some((prefix) => value.startsWith(prefix))) return { kind: \"skip\", reason: \"external\" };\n\t// A shell command, a prose fragment, or a flag list — not a path.\n\tif (/\\s/.test(value)) return { kind: \"skip\", reason: \"ambiguous\" };\n\tif (value.startsWith(\"~\") || value.startsWith(sep) || /^[A-Za-z]:[\\\\/]/.test(value)) {\n\t\treturn { kind: \"skip\", reason: \"runtime\" };\n\t}\n\tif (value === \".\" || value === \"..\") return { kind: \"skip\", reason: \"ambiguous\" };\n\t// The load-bearing precision rule: without a separator the token names a\n\t// filename that could be anywhere, and \"anywhere\" is not a claim worth\n\t// contradicting. A bare `stream.test.ts` in a monorepo is not stale, it is\n\t// under-specified.\n\tif (!value.includes(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\t// Past the separator rule, something that is neither a known file type nor an\n\t// obvious directory is more likely prose than a path.\n\tif (!FILE_EXTENSIONS.test(value) && !value.endsWith(\"/\")) return { kind: \"skip\", reason: \"ambiguous\" };\n\n\treturn { kind: \"path\" };\n}\n\n/** True when the referent resolves against any root. Directories count. */\nfunction pathResolves(referent: string, roots: string[]): boolean {\n\tconst relative = referent.replace(/\\/+$/, \"\");\n\tif (!relative) return false;\n\treturn roots.some((root) => existsSync(join(root, relative)));\n}\n\n/** Script names declared by any `package.json` at any resolution root. */\nfunction declaredScripts(roots: string[]): Set<string> {\n\tconst names = new Set<string>();\n\tfor (const root of roots) {\n\t\tconst manifest = join(root, \"package.json\");\n\t\tif (!existsSync(manifest)) continue;\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(readFileSync(manifest, \"utf-8\")) as { scripts?: Record<string, unknown> };\n\t\t\tfor (const name of Object.keys(parsed.scripts ?? {})) names.add(name);\n\t\t} catch {\n\t\t\t// A malformed manifest is not this command's problem; treat it as\n\t\t\t// declaring nothing rather than failing the audit.\n\t\t}\n\t}\n\treturn names;\n}\n\n/**\n * The project a referent is resolved against: the nearest ancestor holding a\n * `.git`, or `cwd` when there is none.\n *\n * Not `cwd` itself. Context files are collected by walking up from `cwd`, so in\n * a monorepo the repo's `AGENTS.md` sits *above* the package you are working\n * in — and running from a package root is the normal case, not the exception.\n * Anchoring on `cwd` meant the file with all the claims in it was declared \"not\n * in this working tree\" and skipped, so the audit passed by checking nothing.\n */\nexport function findProjectRoot(cwd: string): string {\n\tlet dir = resolve(cwd);\n\twhile (true) {\n\t\tif (existsSync(join(dir, \".git\"))) return dir;\n\t\tconst parent = resolve(dir, \"..\");\n\t\tif (parent === dir) return resolve(cwd);\n\t\tdir = parent;\n\t}\n}\n\n/** True when the path is inside the project, so its claims are about this repo. */\nfunction insideTree(path: string, root: string): boolean {\n\tconst base = resolve(root);\n\tconst target = resolve(path);\n\treturn target === base || target.startsWith(base + sep);\n}\n\nexport interface AuditOptions {\n\tcwd: string;\n\t/** Context files in force, as loaded for the system prompt. */\n\tfiles: Array<{ path: string; tokens?: number }>;\n}\n\n/**\n * Check every referent named by the repo-scope context files.\n *\n * Files outside the project are listed but not audited: a rule in\n * `~/.agents/AGENTS.md` naming `src/index.ts` is a claim about whichever repo\n * it was written for, and resolving it here would report another project's\n * rules as broken.\n *\n * File contents are re-read from disk rather than taken from the loader, which\n * truncates oversized files for the prompt — auditing the truncation would\n * silently stop checking exactly the files most likely to have gone stale.\n */\nexport function auditContextFiles(options: AuditOptions): AuditReport {\n\t// Rooted at the project, not at `cwd`: a path in the repo's context file is\n\t// written relative to the repo, and is being read from wherever you happen to\n\t// be working.\n\tconst projectRoot = findProjectRoot(options.cwd);\n\tconst roots = resolutionRoots(projectRoot);\n\tconst scripts = declaredScripts(roots);\n\n\tconst report: AuditReport = {\n\t\tfiles: [],\n\t\tskippedFiles: [],\n\t\tchecked: 0,\n\t\tskipped: { placeholder: 0, runtime: 0, external: 0, ambiguous: 0, assertsAbsence: 0 },\n\t\tstale: [],\n\t\troots,\n\t};\n\n\tfor (const file of options.files) {\n\t\tif (!insideTree(file.path, projectRoot)) {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = readFileSync(file.path, \"utf-8\");\n\t\t} catch {\n\t\t\treport.skippedFiles.push(file.path);\n\t\t\tcontinue;\n\t\t}\n\t\treport.files.push({ path: file.path, tokens: file.tokens ?? estimateTokens(content) });\n\n\t\tconst lines = content.split(\"\\n\");\n\t\tlet inFence = false;\n\t\tfor (const [index, raw] of lines.entries()) {\n\t\t\t// A fenced block is example code, not a claim about the repo. Its\n\t\t\t// contents are also where most of a context file's plausible-looking\n\t\t\t// paths live, so auditing it is almost pure noise.\n\t\t\tif (raw.trimStart().startsWith(\"```\")) {\n\t\t\t\tinFence = !inFence;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (inFence) continue;\n\n\t\t\tconst spans = backtickedSpans(raw);\n\t\t\tif (spans.length === 0) continue;\n\n\t\t\tconst absence = assertsAbsence(raw);\n\t\t\tfor (const span of spans) {\n\t\t\t\tconst classification = classifyReferent(span);\n\t\t\t\tif (classification.kind === \"skip\") {\n\t\t\t\t\treport.skipped[classification.reason]++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (absence) {\n\t\t\t\t\treport.skipped.assertsAbsence++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\treport.checked++;\n\t\t\t\tconst resolved =\n\t\t\t\t\tclassification.kind === \"script\" ? scripts.has(classification.script) : pathResolves(span, roots);\n\t\t\t\tif (resolved) continue;\n\n\t\t\t\treport.stale.push({\n\t\t\t\t\tfile: file.path,\n\t\t\t\t\tline: index + 1,\n\t\t\t\t\tlineText: raw.trim(),\n\t\t\t\t\treferent: span,\n\t\t\t\t\tkind: classification.kind,\n\t\t\t\t\ttokens: estimateTokens(raw),\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn report;\n}\n\n/** Total recurring cost of the lines the audit flagged. */\nexport function staleTokens(report: AuditReport): number {\n\tconst seen = new Set<string>();\n\tlet total = 0;\n\tfor (const item of report.stale) {\n\t\tconst key = `${item.file}:${item.line}`;\n\t\tif (seen.has(key)) continue;\n\t\tseen.add(key);\n\t\ttotal += item.tokens;\n\t}\n\treturn total;\n}\n"]}