@kolisachint/hoocode-agent 0.5.18 → 0.5.20

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 (61) hide show
  1. package/CHANGELOG.md +179 -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.map +1 -1
  7. package/dist/core/learn/cache.js +14 -2
  8. package/dist/core/learn/cache.js.map +1 -1
  9. package/dist/core/learn/cluster.d.ts +78 -0
  10. package/dist/core/learn/cluster.d.ts.map +1 -0
  11. package/dist/core/learn/cluster.js +184 -0
  12. package/dist/core/learn/cluster.js.map +1 -0
  13. package/dist/core/learn/coverage.d.ts.map +1 -1
  14. package/dist/core/learn/coverage.js +2 -0
  15. package/dist/core/learn/coverage.js.map +1 -1
  16. package/dist/core/learn/digest.d.ts +12 -0
  17. package/dist/core/learn/digest.d.ts.map +1 -1
  18. package/dist/core/learn/digest.js +86 -14
  19. package/dist/core/learn/digest.js.map +1 -1
  20. package/dist/core/learn/extract.d.ts +39 -4
  21. package/dist/core/learn/extract.d.ts.map +1 -1
  22. package/dist/core/learn/extract.js +170 -34
  23. package/dist/core/learn/extract.js.map +1 -1
  24. package/dist/core/learn/mine.d.ts +78 -23
  25. package/dist/core/learn/mine.d.ts.map +1 -1
  26. package/dist/core/learn/mine.js +142 -37
  27. package/dist/core/learn/mine.js.map +1 -1
  28. package/dist/core/learn/reduce.d.ts +19 -8
  29. package/dist/core/learn/reduce.d.ts.map +1 -1
  30. package/dist/core/learn/reduce.js +69 -13
  31. package/dist/core/learn/reduce.js.map +1 -1
  32. package/dist/core/learn/state.d.ts +11 -18
  33. package/dist/core/learn/state.d.ts.map +1 -1
  34. package/dist/core/learn/state.js +23 -34
  35. package/dist/core/learn/state.js.map +1 -1
  36. package/dist/core/settings-defaults.d.ts +1 -1
  37. package/dist/core/settings-defaults.d.ts.map +1 -1
  38. package/dist/core/settings-defaults.js +1 -1
  39. package/dist/core/settings-defaults.js.map +1 -1
  40. package/dist/core/settings-manager.d.ts +2 -2
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +1 -1
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/core/settings-types.d.ts +1 -1
  45. package/dist/core/settings-types.d.ts.map +1 -1
  46. package/dist/core/settings-types.js.map +1 -1
  47. package/dist/extensions/core/learn.d.ts.map +1 -1
  48. package/dist/extensions/core/learn.js +128 -73
  49. package/dist/extensions/core/learn.js.map +1 -1
  50. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  51. package/dist/modes/interactive/components/settings-selector.js +1 -1
  52. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  53. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/docs/settings.md +9 -6
  57. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  58. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  59. package/examples/extensions/sandbox/package.json +1 -1
  60. package/examples/extensions/with-deps/package.json +1 -1
  61. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,184 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.20] - 2026-08-16
4
+
5
+ ## [0.5.19] - 2026-08-16
6
+
7
+ ### Breaking Changes
8
+
9
+ - The `workflow` category is gone, and `learnMinWorkflowRepeats` with it. It
10
+ mined tool sequences and proposed them as skills, and in practice it proposed
11
+ nothing at all: on a real corpus, 30 candidates were mined and not one ever
12
+ cleared its threshold, because the threshold was three identical
13
+ model-invented names for a tool sequence across separate sessions. Nor is it
14
+ worth reimplementing deterministically — counting tool trigrams over the same
15
+ corpus yields 170 "workflows" led by `bash -> bash -> bash` at 1142
16
+ occurrences. Tool names carry no procedural meaning.
17
+ - `learnMinWorkflowRepeats` is replaced by `learnMinRequestRepeats` (default 3),
18
+ which gates the new `request` category below.
19
+
20
+ ### Added
21
+
22
+ - `/learn` proposes **slash commands** for work you keep asking for by name. The
23
+ miner was explicitly told not to report task requests — and those discarded
24
+ requests were the strongest unexploited signal in the corpus. A directive is
25
+ *how* work should be done; a request repeated across sessions is *what you
26
+ keep asking for*, which is the definition of a slash command. Held to a higher
27
+ bar than directives: a rule stated twice is a rule, but a job asked for twice
28
+ may just be a job that came up twice.
29
+ - The digest routes a proposal three ways instead of two — rule, skill, or slash
30
+ command — and names the destination for each, along with what it costs: a
31
+ context file is loaded every turn, a skill's description is always loaded but
32
+ its body on demand, and a slash command costs nothing until it is invoked.
33
+ - The digest states the boundary with `ProposePlugin`: skills and commands
34
+ proposed from mined evidence are local habits written under `.agents/`;
35
+ `ProposePlugin` packages something already proven useful into a portable
36
+ artifact. Hooks and MCP servers are never proposed from this evidence, which
37
+ records what was said and what failed — far too weak a warrant for anything
38
+ that executes.
39
+
40
+ ### Changed
41
+
42
+ - `/learn` names occurrences in one global pass instead of asking the miner to
43
+ name them per session. The miner was told to emit a label — its canonical name
44
+ for what was meant — and the reduce step grouped on exact label equality, but
45
+ a session mined in isolation is being asked to hit a shared vocabulary it has
46
+ never seen. On a real corpus that produced 188 distinct labels from 191
47
+ candidates: `use-bun-not-npm` and `prefer-bun-over-npm` are the same rule and
48
+ never met, and nothing ever reached the repeat threshold. Naming now happens
49
+ once, with every candidate visible at the same time, which is the only
50
+ vantage point from which "is this the same point as that" is answerable.
51
+ - Cached candidates no longer carry a label, which makes the mining cache
52
+ model-independent. A label was frozen at mining time, so changing the `fast`
53
+ model tier forked the vocabulary permanently — old sessions and new ones
54
+ naming the same thing differently, with every count split across the seam and
55
+ no sign that it had happened.
56
+ - The naming pass is given the labels already on record and told to reuse them.
57
+ State keys are `directive:<label>`, so a label that drifts between runs
58
+ silently breaks suppression and re-proposes everything already decided on.
59
+ - A failed naming call falls back to naming each candidate after its own
60
+ wording, which groups identical sentences and nothing else — the behaviour the
61
+ pipeline had before the pass existed. An outage costs recall, not the run.
62
+
63
+ ### Fixed
64
+
65
+ - `/learn stale` audited nothing when run from a package subdirectory. Context
66
+ files are collected by walking up from the working directory, so in a monorepo
67
+ the repo's `AGENTS.md` sits *above* the package you are in — and the audit
68
+ anchored on the working directory, declared that file "outside this working
69
+ tree", skipped it, and reported a clean run. Running from a package root is
70
+ the normal case, so the check was passing by checking nothing. Referents now
71
+ resolve against the project root.
72
+ - A cluster formed by merging two labels carrying the same sentence took its
73
+ name from whichever session came first, and session order changes whenever a
74
+ session is added. The name is the state key, so it drifted between runs, the
75
+ bookmark stopped matching, and items already decided on came back as new. The
76
+ merged name is now a pure function of what was merged.
77
+ - The clustering vocabulary is trimmed from both ends rather than by taking a
78
+ prefix. It holds labels already on record followed by names coined earlier in
79
+ the same run, which anchor different things — one keeps the bookmark matching
80
+ across runs, the other stops a split window inventing rival names for one
81
+ point. A prefix dropped the second exactly when a window was large enough to
82
+ be split, which is the only time it mattered.
83
+ - Request quotes are flattened to one line and capped. A request is a whole task
84
+ message rather than a sentence — a slash-command body runs to thousands of
85
+ characters — so rendering several raw swamped the digest and a multi-line one
86
+ broke the list it sat in.
87
+ - `/learn` no longer mines text the user never typed. A `user`-type slash
88
+ command is persisted as an ordinary user message holding the whole template
89
+ body, with nothing marking it as machinery, so running `/pr` thirty times
90
+ wrote the same two thousand characters into thirty transcripts — making it the
91
+ most repeated "user statement" in a real corpus by a wide margin. Every
92
+ proposal in a recent 100-session run turned out to be `/pr` template text,
93
+ counted as though it had been said aloud. User turns matching a slash-command
94
+ body are now skipped. Detection is retroactive rather than a flag written at
95
+ turn time, so it fixes transcripts that already exist instead of only future
96
+ ones.
97
+ - `/learn` no longer reads successful tool output. It was fed 600 characters per
98
+ call, so lines out of plan files and configs were mined as directives and
99
+ attributed to the user — a tenth of them on a real corpus. Tool calls and
100
+ errors are still shown, which is what fix detection actually needs.
101
+ - Candidates whose quote cannot be found in what the user said are dropped. The
102
+ miner is told to quote verbatim and the digest renders every quote inside
103
+ quotation marks, but on a real corpus a third of them appeared nowhere in the
104
+ session. A quote that cannot be located is evidence that cannot be shown. This
105
+ also backstops the case above: a command file deleted since a transcript was
106
+ written leaves no fingerprint to match, but its text is still unfindable in
107
+ anything the user said.
108
+ - Two labels carrying the same sentence are merged into one proposal, before the
109
+ repeat threshold rather than after. The model labels each session
110
+ independently and cannot see what it called the same thing last time, so
111
+ identical quotes arrived under two labels and were proposed twice, word for
112
+ word — each below the threshold alone.
113
+ - The mining cache version is bumped: entries mined before these changes were
114
+ read from a different transcript than the pipeline now produces, so the next
115
+ run re-reads the window once.
116
+ - An item is dated by when it was said, not by when its session was opened.
117
+ Suppression compares an item's newest occurrence against the last run, and the
118
+ occurrence was taking the session header's timestamp — so in a session opened
119
+ yesterday and worked in today, something said minutes ago read as older than
120
+ the last run and was held back as "nothing new". It now takes the session's
121
+ last activity.
122
+ - The repeat threshold counts distinct sessions rather than total occurrences.
123
+ Saying a thing twice inside one session is the commonest thing in a transcript
124
+ and usually means the opposite of durable — the agent ignored it the first
125
+ time, so it was restated.
126
+ - Coverage rules are sent to the judge with their heading path and scope
127
+ (`[repo] Git Rules > - Stage only your own files`) instead of bare lines.
128
+ Headings were stripped and the lines under them sent alone, which asks the
129
+ model to decide whether a proposal is in scope using text with the scope
130
+ removed. Fenced code blocks are dropped: a sample illustrates a rule, it is
131
+ not one.
132
+ - A run whose coverage judge failed no longer writes the bookmark. Everything
133
+ reads `new` when that call fails, and recording that as "was not written down
134
+ when shown" made a later run report proposals as passed over that were never
135
+ shown. Skipping the write costs one round of re-proposing.
136
+ - An empty run now reports its funnel: occurrences read, distinct points named,
137
+ and how many fell below the repeat threshold. The pipeline filters hard —
138
+ replayed command bodies, tool output, unfindable quotes, then a
139
+ distinct-session bar — and all of it was silent, so "nothing to propose" could
140
+ not be told from "over-filtered" and gave the reader no idea which knob to
141
+ reach for. The below-threshold figure is computed by running the same reduce
142
+ with the threshold at 1, so it is what the threshold cost rather than an
143
+ estimate of it.
144
+ - Proposals cut by the per-run cap are reported instead of vanishing. Eight
145
+ shown out of twenty read as "twenty was all there was", which sends the reader
146
+ to the wrong knob.
147
+ - Added an evaluation harness for the naming pass (`test/support/learn-eval.ts`
148
+ over a hand-grouped corpus). Every existing test injected a clusterer that
149
+ already agreed with itself, so none of them could fail on the assumption the
150
+ design rests on — which is how a 1.6% collision rate shipped and stayed for
151
+ months. The harness scores merges, false merges, and cluster ratio, and takes
152
+ a real model's clusterer so the assumption can be measured rather than
153
+ assumed.
154
+
155
+ - `/learn stats` no longer reports an adoption rate, and no longer makes a model
156
+ call. It re-judged coverage and called the delta "adopted", which moved with a
157
+ failed judge at either end and could not tell a proposal correctly rejected as
158
+ junk from one ignored; it shipped with two disclaimers explaining how not to
159
+ misread it. It now reports what has been proposed, and the always-loaded token
160
+ cost of the context files, which is the question the number was reaching for
161
+ and one the filesystem answers exactly.
162
+
163
+ - `/learn stale` — the subtractive half of `/learn`. The mining path can only
164
+ ever propose additions, so nothing in the command moved the always-loaded
165
+ token surface down: a rule naming a deleted workflow or a command that was
166
+ removed keeps costing tokens on every request, and the agent believes it. The
167
+ audit resolves every backticked path and `bun run` script named by the context
168
+ files in force against the working tree and every package root in it, and
169
+ reports the lines whose referents are gone, priced in tokens. It is
170
+ deterministic — no model call, no cache, no state — so it costs nothing and
171
+ can be run often. On this repo it found four: a documented slash command
172
+ deleted three months ago, and three contribution-gate workflows that do not
173
+ exist.
174
+ - Precision comes from exclusions rather than cleverness, because a noisy
175
+ report is one nobody reads: only path-like referents with a separator are
176
+ checked (a bare `stream.test.ts` in a monorepo is under-specified, not stale),
177
+ paths resolve against every package root rather than the repo root alone, and
178
+ lines that assert absence ("was removed", "e.g. `x.ts`") are skipped, since
179
+ they are correct precisely because the file is missing. Findings are presented
180
+ to the model as candidates to verify, not as a licence to delete.
181
+
3
182
  ## [0.5.18] - 2026-08-15
4
183
 
5
184
  ### Changed
@@ -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"]}