@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.
- package/CHANGELOG.md +179 -0
- package/dist/core/learn/audit.d.ts +136 -0
- package/dist/core/learn/audit.d.ts.map +1 -0
- package/dist/core/learn/audit.js +316 -0
- package/dist/core/learn/audit.js.map +1 -0
- package/dist/core/learn/cache.d.ts.map +1 -1
- package/dist/core/learn/cache.js +14 -2
- package/dist/core/learn/cache.js.map +1 -1
- package/dist/core/learn/cluster.d.ts +78 -0
- package/dist/core/learn/cluster.d.ts.map +1 -0
- package/dist/core/learn/cluster.js +184 -0
- package/dist/core/learn/cluster.js.map +1 -0
- package/dist/core/learn/coverage.d.ts.map +1 -1
- package/dist/core/learn/coverage.js +2 -0
- package/dist/core/learn/coverage.js.map +1 -1
- package/dist/core/learn/digest.d.ts +12 -0
- package/dist/core/learn/digest.d.ts.map +1 -1
- package/dist/core/learn/digest.js +86 -14
- package/dist/core/learn/digest.js.map +1 -1
- package/dist/core/learn/extract.d.ts +39 -4
- package/dist/core/learn/extract.d.ts.map +1 -1
- package/dist/core/learn/extract.js +170 -34
- package/dist/core/learn/extract.js.map +1 -1
- package/dist/core/learn/mine.d.ts +78 -23
- package/dist/core/learn/mine.d.ts.map +1 -1
- package/dist/core/learn/mine.js +142 -37
- package/dist/core/learn/mine.js.map +1 -1
- package/dist/core/learn/reduce.d.ts +19 -8
- package/dist/core/learn/reduce.d.ts.map +1 -1
- package/dist/core/learn/reduce.js +69 -13
- package/dist/core/learn/reduce.js.map +1 -1
- package/dist/core/learn/state.d.ts +11 -18
- package/dist/core/learn/state.d.ts.map +1 -1
- package/dist/core/learn/state.js +23 -34
- package/dist/core/learn/state.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -1
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -2
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/extensions/core/learn.d.ts.map +1 -1
- package/dist/extensions/core/learn.js +128 -73
- package/dist/extensions/core/learn.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +1 -1
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +1 -1
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/settings.md +9 -6
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- 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"]}
|