@bglocation/tune-context 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +75 -24
- package/eval/adoption-report.mjs +128 -8
- package/eval/mcp-probe.mjs +136 -0
- package/hooks/context-reminder.mjs +86 -31
- package/package.json +2 -2
- package/skills/tune-context/SKILL.md +8 -4
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "tune-context",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.3.1",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Szymon Walczak",
|
|
16
16
|
"url": "https://bglocation.dev"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "tune-context",
|
|
3
3
|
"displayName": "tune-context",
|
|
4
4
|
"description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.3.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Szymon Walczak",
|
|
8
8
|
"url": "https://bglocation.dev"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,90 @@ All notable changes to `@bglocation/tune-context` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.3.1] — 2026-07-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The context reminder no longer goes quiet for 120k tokens at a stretch.** A
|
|
13
|
+
middle rung at **1.5× the threshold** (180k by default) joins the existing
|
|
14
|
+
120k and 240k, cutting the worst-case silence between notices from 120k to
|
|
15
|
+
60k. Reported from use: 120k is often too early to close a work thread, while
|
|
16
|
+
240k is far enough away that a session has to be dragged out to reach it, and
|
|
17
|
+
180k is where a phase boundary tends to land. The ladder is expressed as
|
|
18
|
+
multipliers (1×/1.5×/2×), so `TUNE_CONTEXT_REMIND_TOKENS` still scales all
|
|
19
|
+
three — a 400k threshold gives 400k/600k/800k.
|
|
20
|
+
- **Each rung is now louder than the last, instead of repeating itself.** The
|
|
21
|
+
reminder's mid-task sentence is what escalates: wait for a natural break at
|
|
22
|
+
120k, raise it at the next pause anyway at 180k, say it this turn at 240k. A
|
|
23
|
+
third identical notice would just be the same coin flipped again. This also
|
|
24
|
+
fixes a real defect the third rung would otherwise have introduced — the
|
|
25
|
+
second notice used to call itself "second and final", which on a three-rung
|
|
26
|
+
ladder is false, and Claude weighs exactly that ("nothing more is coming")
|
|
27
|
+
when deciding whether to stay quiet.
|
|
28
|
+
- **Docs describing the reminder as firing once.** `README.md` and the
|
|
29
|
+
`tune-context` skill both still said "a single short note" / "fires once per
|
|
30
|
+
crossing" — wrong since 2.1.0 added the second notice, not just since this
|
|
31
|
+
release.
|
|
32
|
+
|
|
33
|
+
### Notes
|
|
34
|
+
|
|
35
|
+
- The level-1 text is byte-identical to previous releases. A session live across
|
|
36
|
+
the upgrade whose state already recorded the old final notice earns one extra
|
|
37
|
+
notice, once, and then goes quiet — the two-rung and three-rung ladders write
|
|
38
|
+
the same number for different meanings, and a permanent ladder-version field
|
|
39
|
+
was not worth one notice.
|
|
40
|
+
- This release touches `hooks/`, so **re-run `tune-context init`** (or the
|
|
41
|
+
dev-loop sync) after upgrading — `npm i -g` does not refresh hooks already
|
|
42
|
+
copied into `~/.claude/hooks/`. `tune-context verify` reports the drift.
|
|
43
|
+
|
|
44
|
+
## [2.3.0] — 2026-07-28
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **The adoption report can now tell "RAG installed and ignored" from "no RAG
|
|
49
|
+
here"** — the blind spot 2.0.0 accepted on purpose, and the finding the report
|
|
50
|
+
most wants to surface. A new `~ RAG likely available, not reached for` line
|
|
51
|
+
appears when a project's `.mcp.json` declares a semantic-search MCP server, no
|
|
52
|
+
settings file rejects it, and that file is **older than every session logged in
|
|
53
|
+
that project**. It is never a ✗: config lists servers, not their tools, so the
|
|
54
|
+
claim is "probably within reach", not "it existed". Four guards keep it from
|
|
55
|
+
fabricating one — rejected servers (`disabledMcpjsonServers`) don't count, a
|
|
56
|
+
config file touched during or after the sessions drops back to unknown,
|
|
57
|
+
observation always wins (a project that actually called `search_codebase` is
|
|
58
|
+
never judged from config), and the frozen session floor now applies **per
|
|
59
|
+
project** as well as globally, because the claim is per project: a busy log can
|
|
60
|
+
still contain a project with a single session, and absence over one session is
|
|
61
|
+
not absence. User-scope servers stay unknown by design: `~/.claude.json` is
|
|
62
|
+
rewritten by Claude Code continuously, so its timestamp can't date anything
|
|
63
|
+
(TASK-083).
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **One RAG-using project no longer counts as coverage for all the others.**
|
|
68
|
+
Search was tallied globally while the verdict spoke about "these sessions", so
|
|
69
|
+
a single project with RAG lifted the counter above zero and every other project
|
|
70
|
+
read as covered. Search is now counted per project as well, which is what
|
|
71
|
+
TASK-070 specified and never landed — visible in the report as an availability
|
|
72
|
+
verdict decided project by project.
|
|
73
|
+
|
|
74
|
+
- **A malformed log line can no longer take the whole report down.** `parseLog`
|
|
75
|
+
validates only `lever`, so `cwd` could be any JSON value — and the new probe
|
|
76
|
+
joins it into a filesystem path. Non-string `cwd`s are now ignored as projects
|
|
77
|
+
while still counting toward the global figures.
|
|
78
|
+
|
|
79
|
+
### Documentation
|
|
80
|
+
|
|
81
|
+
- **The README said the `/tune-context` skill needs the plugin. It doesn't.**
|
|
82
|
+
`tune-context init` installs the skill into `~/.claude/skills/` along with the
|
|
83
|
+
doctrine skills, so a plain `npm i -g` gives you both entry points (and the
|
|
84
|
+
plugin gives you both too, by putting `bin/tune-context` on `PATH`). Adds a
|
|
85
|
+
skill-vs-CLI comparison covering what a reader asks first — session, tokens,
|
|
86
|
+
repeatability — not just the two steps the CLI omits.
|
|
87
|
+
|
|
88
|
+
- **`--force-claude-md` is documented outside `--help` for the first time.** It is
|
|
89
|
+
the one flag that discards content the tool otherwise refuses to touch, so it
|
|
90
|
+
should be the best-documented flag rather than the least.
|
|
91
|
+
|
|
8
92
|
## [2.2.0] — 2026-07-27
|
|
9
93
|
|
|
10
94
|
### Changed
|
|
@@ -380,6 +464,8 @@ action needed on upgrade.
|
|
|
380
464
|
(`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
|
|
381
465
|
suite, and the adoption report (`eval/adoption-report.mjs`).
|
|
382
466
|
|
|
467
|
+
[2.3.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.1
|
|
468
|
+
[2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
|
|
383
469
|
[2.2.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.2.0
|
|
384
470
|
[2.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.1.0
|
|
385
471
|
[2.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.0.1
|
package/README.md
CHANGED
|
@@ -61,8 +61,11 @@ npm install -g @bglocation/tune-context
|
|
|
61
61
|
tune-context init # in any repo
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
The command above also installs the **`/tune-context` skill** into
|
|
65
|
+
`~/.claude/skills/`, so you can type it in a Claude session straight away — it does
|
|
66
|
+
the same thing plus wiki wiring and richer MCP detection. Installing the plugin
|
|
67
|
+
instead gets you the skill without the npm install, and updates through
|
|
68
|
+
`/plugin marketplace update`:
|
|
66
69
|
|
|
67
70
|
```text
|
|
68
71
|
/plugin marketplace add https://gitlab.com/bglocation/tune-context.git
|
|
@@ -79,11 +82,19 @@ The section below breaks down all three entry points and when to use each.
|
|
|
79
82
|
|
|
80
83
|
Three entry points, same doctrine. Pick whichever fits how you work:
|
|
81
84
|
|
|
82
|
-
**1. `/tune-context` skill (recommended).**
|
|
83
|
-
|
|
84
|
-
then generates a lean project `CLAUDE.md`, wires the doctrine
|
|
85
|
-
hooks + permissions — idempotently, never clobbering
|
|
86
|
-
the full-fidelity path (it also handles wiki wiring
|
|
85
|
+
**1. `/tune-context` skill (recommended).** Type `/tune-context` in a Claude Code
|
|
86
|
+
session, in the repo you want to configure. It detects the stack, MCP servers and
|
|
87
|
+
existing config, then generates a lean project `CLAUDE.md`, wires the doctrine
|
|
88
|
+
skills, and merges hooks + permissions — idempotently, never clobbering
|
|
89
|
+
hand-written content. This is the full-fidelity path (it also handles wiki wiring
|
|
90
|
+
and semantic-search detection).
|
|
91
|
+
|
|
92
|
+
**Either channel gives you the skill.** The plugin installs it, and so does
|
|
93
|
+
`tune-context init` — the CLI copies `skills/tune-context/` into
|
|
94
|
+
`~/.claude/skills/` along with the doctrine skills. So a plain `npm i -g` gives you
|
|
95
|
+
*both* entry points, and so does the plugin (it puts `bin/tune-context` on `PATH`
|
|
96
|
+
while enabled). Claude never reaches for it on its own: the skill is marked
|
|
97
|
+
`disable-model-invocation`, so it runs only when you type it.
|
|
87
98
|
|
|
88
99
|
**2. CLI `tune-context init`.** The deterministic **subset** of the skill — same
|
|
89
100
|
managed-block rules, same never-clobber guarantee, no model in the loop. Good for
|
|
@@ -99,15 +110,38 @@ node bin/tune-context.mjs init --cwd /path/to/your/repo
|
|
|
99
110
|
It prints what it detected, what it synced/merged, and a verification report; a
|
|
100
111
|
second run is a no-op.
|
|
101
112
|
|
|
102
|
-
Two
|
|
103
|
-
|
|
113
|
+
Two flags are worth knowing:
|
|
114
|
+
|
|
115
|
+
- **`--cwd <dir>`** — configure another repo instead of the current directory.
|
|
116
|
+
- **`--force-claude-md`** — *destructive, opt-in, and normally unnecessary.* If a
|
|
117
|
+
`CLAUDE.md` still has hand-written content **inside** the managed block (the
|
|
118
|
+
layout used before 2.2.0), `init` refuses to regenerate it and says so —
|
|
119
|
+
"hand-written content sits inside the managed block — regenerating would erase
|
|
120
|
+
it". The fix is to move that prose below the closing marker, where the tool
|
|
121
|
+
never reaches. This flag regenerates anyway and **discards** it; use it only
|
|
122
|
+
when there is nothing in there worth keeping.
|
|
123
|
+
|
|
124
|
+
### `/tune-context` vs `tune-context init`
|
|
104
125
|
|
|
105
|
-
|
|
126
|
+
They are two different kinds of thing. The skill is a **procedure Claude follows**,
|
|
127
|
+
so it can look at your repo and decide; the CLI is **code that runs**, so it does
|
|
128
|
+
exactly what it was written to do, every time. Same doctrine, same managed-block
|
|
129
|
+
rules, same never-clobber guarantee.
|
|
130
|
+
|
|
131
|
+
| | `/tune-context` (skill) | `tune-context init` (CLI) |
|
|
106
132
|
|---|---|---|
|
|
133
|
+
| Where you run it | Claude Code chat | terminal, incl. CI |
|
|
134
|
+
| Needs a Claude session | yes | no — plain Node ≥ 18 |
|
|
135
|
+
| Costs tokens | yes | no |
|
|
136
|
+
| Two runs, same repo | judgment may vary | byte-identical |
|
|
107
137
|
| Project `CLAUDE.md`, doctrine skills, hooks, permissions | ✅ | ✅ |
|
|
108
138
|
| Merge `.mcp.json` — add a semantic-search server if none was detected | ✅ | ❌ reads it, never writes it |
|
|
109
139
|
| Wiki lever — RAG segment vs its own MCP vs a plain pointer | ✅ | ❌ not detected, not wired |
|
|
110
140
|
|
|
141
|
+
Both write the same block into the same files, so mixing them is safe: script the
|
|
142
|
+
CLI where you want repeatability, and run the skill when you want the two judgment
|
|
143
|
+
steps it alone can do.
|
|
144
|
+
|
|
111
145
|
**3. Plugin install.** Load the plugin so the skills, agents and hooks are available
|
|
112
146
|
in every session. The best UX is the **marketplace** — install once, get updates
|
|
113
147
|
with `/plugin marketplace update`:
|
|
@@ -183,23 +217,27 @@ many tokens it is re-sending, and Claude Code has no timer or context-threshold
|
|
|
183
217
|
hook event. So `context-reminder.mjs` (registered under `UserPromptSubmit`)
|
|
184
218
|
supplies the missing measurement: it reads the tail of the session transcript,
|
|
185
219
|
sums the tokens actually re-sent, and once that passes **120,000** it injects a
|
|
186
|
-
|
|
220
|
+
short note suggesting `/compact`.
|
|
187
221
|
|
|
188
222
|
- **The note goes to Claude, not to your screen.** Hooks can only reach you
|
|
189
223
|
through the model — a `systemMessage` on this event was measured and does not
|
|
190
224
|
render — so what you actually see is Claude bringing it up in its reply.
|
|
191
|
-
- **The model picks the moment.** The
|
|
192
|
-
|
|
193
|
-
the
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
225
|
+
- **The model picks the moment — at first.** The hook can measure size, but only
|
|
226
|
+
Claude can see whether you're mid-refactor, so the first note tells it to stay
|
|
227
|
+
quiet mid-task and raise it at the next natural break. That deference runs out:
|
|
228
|
+
see the ladder below. A session that never reaches a "natural break" is exactly
|
|
229
|
+
the one paying the most for the wait.
|
|
230
|
+
- **Three notes per crossing at most — 120,000 / 180,000 / 240,000.** A note that
|
|
231
|
+
lands mid-task can be correctly absorbed and then forgotten. Measured on one
|
|
232
|
+
session: it fired at 166k, Claude stayed silent, and nothing spoke again until
|
|
233
|
+
346k. Each rung halves that blind spot, and each one is *louder* than the last:
|
|
234
|
+
at 120k Claude is told to wait for a natural break, at 180k to raise it at the
|
|
235
|
+
next pause anyway, at 240k to say it this turn whatever it is doing. There is
|
|
236
|
+
deliberately no fourth — the reminder costs tokens on the prompt it appears in.
|
|
199
237
|
- **Raise it for a large context window:** `export TUNE_CONTEXT_REMIND_TOKENS=400000`.
|
|
200
238
|
120k is ~60% of a standard 200k window; the transcript doesn't record the window
|
|
201
|
-
size, so this can't be auto-scaled.
|
|
202
|
-
|
|
239
|
+
size, so this can't be auto-scaled. The whole ladder scales with the override —
|
|
240
|
+
the rungs are always 1×, 1.5× and 2× the threshold (400k/600k/800k here).
|
|
203
241
|
- **Turn it off:** delete the `UserPromptSubmit` entry from `settings.json`.
|
|
204
242
|
|
|
205
243
|
Depth lives in the `phase-workflow` and `token-efficiency` skills (both installed
|
|
@@ -212,12 +250,25 @@ override's `tune-context/adoption.jsonl` — see above), and `eval/adoption-repo
|
|
|
212
250
|
turns that into a report — code search (semantic RAG vs the Grep tool, with
|
|
213
251
|
shell-plumbing greps kept out of the RAG share), doctrine skills, subagents, and
|
|
214
252
|
re-read churn — closing with an *earned* verdict: withheld below a PO-frozen floor of
|
|
215
|
-
distinct sessions, and never a false ✗ for a lever the log can't see (
|
|
216
|
-
|
|
217
|
-
It's registered under two Claude Code hook events —
|
|
253
|
+
distinct sessions, and never a false ✗ for a lever the log can't see (method-level
|
|
254
|
+
doctrine leaves no tool call at all). It's registered under two Claude Code hook events —
|
|
218
255
|
`PostToolUse` (every tool call) and `SubagentStart` (subagent spawns, which aren't
|
|
219
256
|
regular tools).
|
|
220
257
|
|
|
258
|
+
**"RAG installed but ignored" vs "no RAG here"** is the one gap the log alone cannot
|
|
259
|
+
close, and it happens to be the finding the report most wants — so it asks the config,
|
|
260
|
+
per project, and only as far as the config can honestly answer. A
|
|
261
|
+
`~ RAG likely available, not reached for` line appears when that project's `.mcp.json`
|
|
262
|
+
declares a semantic-search server, no settings file rejects it, and the file is *older*
|
|
263
|
+
than every session logged there. Everything else stays `·` unknown: config written
|
|
264
|
+
during or after those sessions says nothing about them, and user-scope servers
|
|
265
|
+
(`~/.claude.json`) can't be dated at all, since Claude Code rewrites that file
|
|
266
|
+
continuously. It is never a ✗ — config names servers, not their tools, so the line
|
|
267
|
+
claims the lever was probably within reach, not that it existed. It also answers to
|
|
268
|
+
the same frozen session floor as the rest of the verdict, applied **per project**:
|
|
269
|
+
"available and never reached for" is a claim about absence, and one logged session
|
|
270
|
+
cannot establish absence.
|
|
271
|
+
|
|
221
272
|
**Using both the plugin and `tune-context init` (CLI) on the same repo** registers
|
|
222
273
|
the hook twice — once per channel — so every event logs twice. This is harmless
|
|
223
274
|
(same script, same log, just doubled counts) and the report detects it: past a
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
// (`claude_code.cost.usage`, needs a collector) — never a hook. This report
|
|
11
11
|
// measures *behavior* (a proxy), not dollars. OTEL is the opt-in complement.
|
|
12
12
|
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
|
+
// • RAG availability (the `~` line in the verdict) rests on a project's
|
|
14
|
+
// `.mcp.json` declaring a semantic-search server that predates the sessions
|
|
15
|
+
// — config, not introspection. It does NOT prove the server exposed
|
|
16
|
+
// `search_codebase`, and it says nothing about user-scope servers, whose
|
|
17
|
+
// config file carries no usable timestamp. See eval/mcp-probe.mjs (TASK-083).
|
|
13
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
19
|
import { join } from 'node:path';
|
|
15
20
|
import { parseArgs } from 'node:util';
|
|
16
21
|
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
22
|
+
import { classifyAvailability, probeProjectMcp } from './mcp-probe.mjs';
|
|
17
23
|
|
|
18
24
|
const DOCTRINE_SKILLS = new Set([
|
|
19
25
|
'token-efficiency',
|
|
@@ -127,13 +133,29 @@ function countDuplicateRegistrationPairs(records) {
|
|
|
127
133
|
return { pairs, assessable };
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
/**
|
|
137
|
+
* @param {object[]} records
|
|
138
|
+
* @param {{probe?: (cwd: string) => {names: string[], mtimeMs: number|null}}} [options]
|
|
139
|
+
* probe - MCP config probe, injectable for tests (see eval/mcp-probe.mjs).
|
|
140
|
+
*/
|
|
141
|
+
export function aggregate(records, { probe = probeProjectMcp } = {}) {
|
|
131
142
|
const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
|
|
132
143
|
const skills = new Map();
|
|
133
144
|
const subagents = new Map();
|
|
134
145
|
const readCounts = new Map(); // `${session}\u0000${file}` -> times read in that session
|
|
135
146
|
const sessionSet = new Set();
|
|
136
147
|
const cwdSet = new Set();
|
|
148
|
+
// TASK-083: search counted per project as well as globally. The global
|
|
149
|
+
// counter alone reads one RAG-using project as coverage for all the others —
|
|
150
|
+
// TASK-070 already specified this denominator as per-cwd, and it never landed.
|
|
151
|
+
const searchByCwd = new Map(); // cwd -> {rag, grepTool, shell, glob}
|
|
152
|
+
// Inputs to the availability probe's temporal guard. Session start is the
|
|
153
|
+
// earliest ts of ANY record in that session, not the earliest one in this
|
|
154
|
+
// cwd: a session that began elsewhere and moved here started before its first
|
|
155
|
+
// record here, and dating the config against the later value would loosen the
|
|
156
|
+
// guard in exactly the direction that fabricates verdicts.
|
|
157
|
+
const sessionStart = new Map(); // session key -> earliest ts (ms), or null if any record lacks one
|
|
158
|
+
const cwdSessions = new Map(); // cwd -> Set(session key)
|
|
137
159
|
let reads = 0;
|
|
138
160
|
let edits = 0;
|
|
139
161
|
let bash = 0;
|
|
@@ -143,7 +165,30 @@ export function aggregate(records) {
|
|
|
143
165
|
|
|
144
166
|
for (const r of records) {
|
|
145
167
|
if (r.session != null) sessionSet.add(r.session);
|
|
146
|
-
|
|
168
|
+
// A record with no cwd gets NO bucket of its own and can never promote a
|
|
169
|
+
// project to 'configured': there is no project whose config we could check.
|
|
170
|
+
// It still counts toward every global figure. Non-string cwds are treated
|
|
171
|
+
// the same way rather than trusted: the probe joins this value into a path,
|
|
172
|
+
// and parseLog vets only `lever`, so a malformed line could otherwise take
|
|
173
|
+
// the whole report down.
|
|
174
|
+
if (typeof r.cwd === 'string' && r.cwd !== '') {
|
|
175
|
+
cwdSet.add(r.cwd);
|
|
176
|
+
if (!searchByCwd.has(r.cwd)) searchByCwd.set(r.cwd, { rag: 0, grepTool: 0, shell: 0, glob: 0 });
|
|
177
|
+
if (!cwdSessions.has(r.cwd)) cwdSessions.set(r.cwd, new Set());
|
|
178
|
+
cwdSessions.get(r.cwd).add(r.session ?? '');
|
|
179
|
+
}
|
|
180
|
+
// One undateable record poisons its session's start: the missing ts could
|
|
181
|
+
// have been the earliest, so the guard would have nothing sound to compare
|
|
182
|
+
// against and every project that session touched falls back to 'unknown'.
|
|
183
|
+
const startKey = r.session ?? '';
|
|
184
|
+
const startedAt = Date.parse(r.ts);
|
|
185
|
+
if (!sessionStart.has(startKey)) {
|
|
186
|
+
sessionStart.set(startKey, Number.isNaN(startedAt) ? null : startedAt);
|
|
187
|
+
} else {
|
|
188
|
+
const known = sessionStart.get(startKey);
|
|
189
|
+
if (known !== null) sessionStart.set(startKey, Number.isNaN(startedAt) ? null : Math.min(known, startedAt));
|
|
190
|
+
}
|
|
191
|
+
const perCwd = searchByCwd.get(r.cwd) ?? null;
|
|
147
192
|
switch (r.lever) {
|
|
148
193
|
case 'search': {
|
|
149
194
|
// Split content search by TOOL — the only reliable "is this a codebase
|
|
@@ -154,10 +199,19 @@ export function aggregate(records) {
|
|
|
154
199
|
// so the split reinterprets existing logs — no format change, hook
|
|
155
200
|
// untouched. See TASK-069.
|
|
156
201
|
const tool = typeof r.tool === 'string' ? r.tool : '';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
202
|
+
// One classification, counted twice: globally and into this project's
|
|
203
|
+
// bucket. Splitting the rule in two would let the per-project view and
|
|
204
|
+
// the global view disagree about what a RAG call is.
|
|
205
|
+
const kind =
|
|
206
|
+
r.detail === 'rag' || tool.includes('search_codebase')
|
|
207
|
+
? 'rag'
|
|
208
|
+
: r.detail === 'glob' || tool === 'Glob'
|
|
209
|
+
? 'glob'
|
|
210
|
+
: tool === 'Grep'
|
|
211
|
+
? 'grepTool'
|
|
212
|
+
: 'shell'; // Bash-grep / ambiguous — reported, excluded from RAG-share
|
|
213
|
+
search[kind]++;
|
|
214
|
+
if (perCwd) perCwd[kind]++;
|
|
161
215
|
break;
|
|
162
216
|
}
|
|
163
217
|
case 'skill':
|
|
@@ -210,12 +264,54 @@ export function aggregate(records) {
|
|
|
210
264
|
const { pairs: dedupPairs, assessable: dedupAssessable } = countDuplicateRegistrationPairs(records);
|
|
211
265
|
const dedupShare = dedupAssessable > 0 ? dedupPairs / dedupAssessable : 0;
|
|
212
266
|
|
|
267
|
+
// Was RAG even available in each project? Decided per cwd, so one RAG-using
|
|
268
|
+
// project can no longer cover for the rest (TASK-083). A project is dated by
|
|
269
|
+
// the EARLIEST session that touched it: the claim "configured before these
|
|
270
|
+
// sessions" has to hold for all of them, and one unknown start makes the
|
|
271
|
+
// whole project unknown.
|
|
272
|
+
const availability = { observed: 0, configured: 0, unknown: 0, byCwd: [], names: [] };
|
|
273
|
+
const configuredNames = new Set();
|
|
274
|
+
for (const cwd of [...cwdSet].sort()) {
|
|
275
|
+
const sessionsHere = cwdSessions.get(cwd);
|
|
276
|
+
let sessionStartMs = null;
|
|
277
|
+
for (const key of sessionsHere) {
|
|
278
|
+
const start = sessionStart.get(key) ?? null;
|
|
279
|
+
if (start === null) {
|
|
280
|
+
sessionStartMs = null;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
sessionStartMs = sessionStartMs === null ? start : Math.min(sessionStartMs, start);
|
|
284
|
+
}
|
|
285
|
+
const { tier, names } = classifyAvailability(cwd, {
|
|
286
|
+
ragObserved: searchByCwd.get(cwd).rag > 0,
|
|
287
|
+
sessionStartMs,
|
|
288
|
+
// The SAME PO-frozen floor as the global verdict, applied where the claim
|
|
289
|
+
// is actually made. Judgments moved per-project in TASK-083, and the
|
|
290
|
+
// global count stopped guaranteeing a per-project sample: a log with 10
|
|
291
|
+
// sessions can hold a project with one, and "available, never reached
|
|
292
|
+
// for" over one session is exactly the trend-from-a-sample the floor
|
|
293
|
+
// exists to refuse. No new threshold is chosen here — the number stays
|
|
294
|
+
// frozen above, this only stops the probe routing around it.
|
|
295
|
+
sampleSuffices: sessionsHere.size >= MIN_SESSIONS_FOR_VERDICT,
|
|
296
|
+
probe,
|
|
297
|
+
});
|
|
298
|
+
availability[tier]++;
|
|
299
|
+
availability.byCwd.push([cwd, tier]);
|
|
300
|
+
for (const name of names) configuredNames.add(name);
|
|
301
|
+
}
|
|
302
|
+
availability.names = [...configuredNames].sort();
|
|
303
|
+
|
|
213
304
|
return {
|
|
214
305
|
total: records.length,
|
|
215
306
|
sessions: sessionSet.size,
|
|
216
307
|
cwds: cwdSet.size,
|
|
217
308
|
search,
|
|
218
309
|
ragShare,
|
|
310
|
+
// Per-project search counts and the availability tier each one earned.
|
|
311
|
+
// Presentation stays a single aggregate verdict line (PO decision 3,
|
|
312
|
+
// 2026-07-27); these are what a per-project section would be built from.
|
|
313
|
+
searchByCwd: [...searchByCwd.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
|
314
|
+
availability,
|
|
219
315
|
skills: [...skills.entries()].sort((a, b) => b[1] - a[1]),
|
|
220
316
|
subagents: [...subagents.entries()].sort((a, b) => b[1] - a[1]),
|
|
221
317
|
reads,
|
|
@@ -319,16 +415,26 @@ export function formatReport(a) {
|
|
|
319
415
|
' One session is a sample, not a trend; collect more, then re-run.',
|
|
320
416
|
);
|
|
321
417
|
} else {
|
|
322
|
-
|
|
418
|
+
const { configured, unknown } = a.availability;
|
|
419
|
+
// The `~` line speaks only for the projects the probe could judge, so it
|
|
420
|
+
// may replace this one only when that is ALL of them. Otherwise the
|
|
421
|
+
// projects it cannot judge would go unmentioned entirely — a worse
|
|
422
|
+
// silence than the blind spot TASK-070 admitted to out loud.
|
|
423
|
+
const probeSpeaksForEveryProject = configured > 0 && unknown === 0;
|
|
424
|
+
if (a.search.rag === 0 && !probeSpeaksForEveryProject) {
|
|
323
425
|
// Availability (Option 1, PO 2026-07-24): search_codebase never observed
|
|
324
426
|
// → report as unavailable, never ✗ and never ✓. The behavioral log can't
|
|
325
427
|
// tell "RAG not installed here" from "installed but never reached for",
|
|
326
428
|
// so it must not accuse — a known, accepted blind spot (TASK-070 notes).
|
|
429
|
+
// TASK-083 leaves this wording untouched for the case it was written
|
|
430
|
+
// for: no observation AND no config the probe could date. When the probe
|
|
431
|
+
// does have something, the `~` line below replaces this one — it answers
|
|
432
|
+
// the very question this sentence admits it cannot.
|
|
327
433
|
L.push(
|
|
328
434
|
' · RAG not observed — no search_codebase in these sessions. Not counted' +
|
|
329
435
|
' as ✗: can\'t tell "not installed" from "installed but unused".',
|
|
330
436
|
);
|
|
331
|
-
} else {
|
|
437
|
+
} else if (a.search.rag > 0) {
|
|
332
438
|
// rag > 0 ⇒ denominator (rag + grepTool) > 0 ⇒ ragShare is a number.
|
|
333
439
|
L.push(
|
|
334
440
|
a.ragShare >= 0.5
|
|
@@ -336,6 +442,20 @@ export function formatReport(a) {
|
|
|
336
442
|
: ' ✗ RAG lever weak — grep still dominates code search. Revisit index/skill wiring.',
|
|
337
443
|
);
|
|
338
444
|
}
|
|
445
|
+
// Earned middle state (TASK-083, PO decision 1): the project declares a
|
|
446
|
+
// non-rejected semantic-search server in a `.mcp.json` older than every
|
|
447
|
+
// session logged there, and search_codebase still never appears. Soft `~`,
|
|
448
|
+
// never a hard ✗ — config names servers, not tools, so this is evidence
|
|
449
|
+
// the lever was probably within reach, not proof it was.
|
|
450
|
+
if (configured > 0) {
|
|
451
|
+
const names = a.availability.names;
|
|
452
|
+
L.push(
|
|
453
|
+
` ~ RAG likely available, not reached for — ${configured} of ${a.cwds} project(s) declare` +
|
|
454
|
+
` a semantic-search MCP server in .mcp.json (${names.join(', ')}) predating every session` +
|
|
455
|
+
' logged there, yet no search_codebase call. Config, not introspection:' +
|
|
456
|
+
` confirm with \`claude mcp get ${names[0]}\`.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
339
459
|
// Zero `Skill` calls is NOT proof doctrine is unused — narrow reads,
|
|
340
460
|
// contracts-first edits, and delegation are the method working *without*
|
|
341
461
|
// a tool call. A hard ✗ here would penalize exactly the discipline this
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// MCP config probe — the "was RAG even available?" half of the adoption report
|
|
2
|
+
// (TASK-083, closing Option 3 deferred in TASK-070).
|
|
3
|
+
//
|
|
4
|
+
// The report can see that `search_codebase` was never called. It cannot see
|
|
5
|
+
// WHY, and the difference matters: "no RAG installed here" is not a finding,
|
|
6
|
+
// "RAG installed and ignored" is exactly the finding the report exists for.
|
|
7
|
+
// TASK-070 refused to guess and printed neither ✓ nor ✗. This module earns a
|
|
8
|
+
// middle state instead of inventing a hard one.
|
|
9
|
+
//
|
|
10
|
+
// Three gaps sit between "configured" and "was available in that session", and
|
|
11
|
+
// each one alone is enough to manufacture a false accusation:
|
|
12
|
+
//
|
|
13
|
+
// 1. server ≠ tool — config lists servers, not their tools. A server
|
|
14
|
+
// named `rag` may expose `query`, not `search_codebase`
|
|
15
|
+
// (see classifyMcpServer's own warning in cli/detect.mjs).
|
|
16
|
+
// 2. now ≠ then — the probe reads today's config against a historical
|
|
17
|
+
// log. Installing RAG today must not retroactively
|
|
18
|
+
// convict every session that predates the install.
|
|
19
|
+
// 3. configured ≠ approved — a project `.mcp.json` server the user never
|
|
20
|
+
// approved is "⏸ Pending approval": present, not running.
|
|
21
|
+
//
|
|
22
|
+
// Gap 2 is closed by the mtime guard below. Gap 3 is only NARROWED: explicit
|
|
23
|
+
// rejections (`disabledMcpjsonServers`) are honoured, but a server still
|
|
24
|
+
// awaiting approval stays "configured" on purpose — absence from
|
|
25
|
+
// `enabledMcpjsonServers` does not mean disabled (the user is asked, and may
|
|
26
|
+
// well say yes), so reading it as unavailable would drop the very case this
|
|
27
|
+
// module exists to surface. Gap 1 is not closed at all. Which is precisely why
|
|
28
|
+
// the strongest verdict here is a soft `~`, never a hard ✗ (PO decision 1,
|
|
29
|
+
// 2026-07-27).
|
|
30
|
+
//
|
|
31
|
+
// SCOPE — project `.mcp.json` only, measured rather than assumed (TASK-083
|
|
32
|
+
// review, 2026-07-27): `~/.claude.json`, which declares the user and local
|
|
33
|
+
// scopes, is rewritten by Claude Code continuously — it holds project history
|
|
34
|
+
// and session state, not just `mcpServers`, so its mtime is always ~now. The
|
|
35
|
+
// temporal guard would therefore degrade EVERY user-scope server to unknown,
|
|
36
|
+
// silently disabling the probe exactly where people install RAG most often
|
|
37
|
+
// (`claude mcp add --scope user`). A project `.mcp.json` is a static,
|
|
38
|
+
// git-tracked file whose mtime means something. The honest answer for the user
|
|
39
|
+
// scope is "unknown", and it stays "unknown" — recovering it needs the session
|
|
40
|
+
// to record its own server list going forward, not a cleverer inference.
|
|
41
|
+
import { statSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { detectExistingConfig, detectMcpServers, detectRejectedMcpServers } from '../cli/detect.mjs';
|
|
44
|
+
|
|
45
|
+
// The one classifyMcpServer bucket that could plausibly serve search_codebase.
|
|
46
|
+
// Single source of truth, already maintained for the CLAUDE.md MCP rules — a
|
|
47
|
+
// second allowlist here would drift from it (PO decision 2, 2026-07-27).
|
|
48
|
+
const SEMANTIC_SEARCH = 'semantic code search';
|
|
49
|
+
|
|
50
|
+
/** mtime of a path in epoch ms, or null when it is missing/unreadable. */
|
|
51
|
+
function mtimeMs(path) {
|
|
52
|
+
try {
|
|
53
|
+
return statSync(path).mtimeMs;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Semantic-search servers a project's OWN `.mcp.json` declares and no settings
|
|
61
|
+
* file rejects, plus that file's mtime — the raw facts, no judgment.
|
|
62
|
+
*
|
|
63
|
+
* Rejections are read without any temporal guard of their own, deliberately: a
|
|
64
|
+
* server rejected today that was live back then reads as "unknown" instead of
|
|
65
|
+
* "configured", which is an error toward silence. That direction is always
|
|
66
|
+
* allowed here; the opposite one is what this whole module exists to prevent.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd - project directory as recorded in the adoption log
|
|
69
|
+
* @returns {{names: string[], mtimeMs: number|null}}
|
|
70
|
+
*/
|
|
71
|
+
export function probeProjectMcp(cwd) {
|
|
72
|
+
const config = detectExistingConfig(cwd);
|
|
73
|
+
const rejected = detectRejectedMcpServers([
|
|
74
|
+
config.projectSettingsJson,
|
|
75
|
+
config.projectSettingsLocalJson,
|
|
76
|
+
config.userSettingsJson,
|
|
77
|
+
]);
|
|
78
|
+
const names = detectMcpServers(cwd)
|
|
79
|
+
.filter((s) => s.scope === 'project' && s.classification === SEMANTIC_SEARCH && !rejected.has(s.name))
|
|
80
|
+
.map((s) => s.name);
|
|
81
|
+
return { names, mtimeMs: mtimeMs(join(cwd, '.mcp.json')) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How much this project's log entitles us to claim about RAG availability.
|
|
86
|
+
*
|
|
87
|
+
* 'observed' — a search_codebase-class call is in the log for this cwd.
|
|
88
|
+
* Proven. The config is not even consulted.
|
|
89
|
+
* 'configured' — not observed, but the project declares a non-rejected
|
|
90
|
+
* semantic-search server in a `.mcp.json` that predates every
|
|
91
|
+
* session logged here. Likely available, unused. Soft `~`.
|
|
92
|
+
* 'unknown' — everything else. TASK-070's wording, unchanged.
|
|
93
|
+
*
|
|
94
|
+
* The temporal guard is a valid inference, not a heuristic:
|
|
95
|
+
* `mtime < session start` ⇒ the file was not touched from before that session
|
|
96
|
+
* until now ⇒ the content read today IS the content that session saw. The
|
|
97
|
+
* converse buys nothing — a file changed during or after the session says
|
|
98
|
+
* nothing about its state back then — so it drops to 'unknown'. A fresh
|
|
99
|
+
* `git clone` stamps mtime at checkout time and so reads "too new": fail-safe,
|
|
100
|
+
* which is the house direction. The one case that fails the other way is a copy
|
|
101
|
+
* that preserves mtime (`cp -p`, `rsync -a`, a restored backup) — the file can
|
|
102
|
+
* then look older than a session it was never present for. Left undefended
|
|
103
|
+
* deliberately: it is exotic, and what it could inflate is a soft `~`, not an
|
|
104
|
+
* accusation.
|
|
105
|
+
*
|
|
106
|
+
* 'configured' additionally answers to a sample floor, passed in as
|
|
107
|
+
* `sampleSuffices` because the number is the report's to freeze with the PO, not
|
|
108
|
+
* this module's to pick. It applies to this tier and not to 'observed' for a
|
|
109
|
+
* reason: a call either happened or it didn't, but "available and never reached
|
|
110
|
+
* for" is a claim about ABSENCE across sessions, and absence is exactly what one
|
|
111
|
+
* session cannot establish.
|
|
112
|
+
*
|
|
113
|
+
* `probe` is injected so callers can prove the ordering above: on an
|
|
114
|
+
* 'observed' project it is never called, so a probe that throws is harmless.
|
|
115
|
+
*
|
|
116
|
+
* `names` is populated only for 'configured' — the one tier whose claim rests
|
|
117
|
+
* on those servers, and so the one whose report line has to name them for the
|
|
118
|
+
* reader to check. The other tiers carry nothing to confirm.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @param {{ragObserved: boolean, sessionStartMs: number|null, sampleSuffices: boolean, probe?: (cwd: string) => {names: string[], mtimeMs: number|null}}} facts
|
|
122
|
+
* sessionStartMs - earliest logged timestamp of any session that touched
|
|
123
|
+
* this cwd; null when unknown, which alone forces 'unknown'.
|
|
124
|
+
* sampleSuffices - has THIS project logged enough sessions to be spoken
|
|
125
|
+
* about? False forces 'unknown' without consulting the config.
|
|
126
|
+
* @returns {{tier: 'observed'|'configured'|'unknown', names: string[]}}
|
|
127
|
+
*/
|
|
128
|
+
export function classifyAvailability(cwd, { ragObserved, sessionStartMs, sampleSuffices, probe = probeProjectMcp }) {
|
|
129
|
+
if (ragObserved) return { tier: 'observed', names: [] }; // observation beats inference — no probe
|
|
130
|
+
if (!sampleSuffices) return { tier: 'unknown', names: [] }; // too few sessions here to speak of absence
|
|
131
|
+
if (sessionStartMs === null) return { tier: 'unknown', names: [] }; // nothing to date the config against
|
|
132
|
+
const { names, mtimeMs: configMtime } = probe(cwd);
|
|
133
|
+
if (names.length === 0) return { tier: 'unknown', names: [] };
|
|
134
|
+
if (configMtime === null || configMtime >= sessionStartMs) return { tier: 'unknown', names: [] };
|
|
135
|
+
return { tier: 'configured', names };
|
|
136
|
+
}
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
// boundary": a hook is a plain script with no model access, so it cannot know
|
|
14
14
|
// that a work thread just ended. Size is measurable; a phase boundary is a
|
|
15
15
|
// judgment. This hook therefore supplies the measurement the model can't see,
|
|
16
|
-
// and leaves the timing judgment to the model — the reminder explicitly
|
|
17
|
-
// it to stay quiet mid-task.
|
|
18
|
-
//
|
|
16
|
+
// and leaves the timing judgment to the model — the first reminder explicitly
|
|
17
|
+
// tells it to stay quiet mid-task. That deference is finite, though: it is
|
|
18
|
+
// exactly what the LADDER below withdraws rung by rung, because a session that
|
|
19
|
+
// never finds a "natural break" is the one paying the most. The phase-boundary
|
|
20
|
+
// half is the `phase-workflow` skill's job, model-side, where judgment lives.
|
|
19
21
|
//
|
|
20
22
|
// The number is real, not a proxy: Claude Code records per-request `usage` in
|
|
21
23
|
// the transcript, and input_tokens + cache_read_input_tokens +
|
|
@@ -137,43 +139,93 @@ function pruneState(state, now) {
|
|
|
137
139
|
return state;
|
|
138
140
|
}
|
|
139
141
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
+
// The notice ladder, as multipliers of the threshold: 120k / 180k / 240k by
|
|
143
|
+
// default (TASK-080 added the second rung, TASK-084 the middle one).
|
|
142
144
|
//
|
|
143
145
|
// The problem it solves is measured, not hypothetical. Fire-once is consumed
|
|
144
146
|
// when the model reads the reminder, and the reminder itself tells the model to
|
|
145
147
|
// stay quiet mid-task — correctly, since only it can see whether a refactor is
|
|
146
148
|
// half-finished. In session c9329adb that combination cost the user everything
|
|
147
149
|
// between ~166k and ~346k: one firing, silently absorbed, and no re-arm until a
|
|
148
|
-
// compaction that nobody had suggested.
|
|
150
|
+
// compaction that nobody had suggested. Each rung halves the worst-case blind
|
|
151
|
+
// spot: one rung left 120k of silence, two left 120k above the threshold, three
|
|
152
|
+
// leave 60k.
|
|
149
153
|
//
|
|
150
|
-
// Why
|
|
151
|
-
// hook lives under — it is the only hook that ADDS tokens to the
|
|
152
|
-
// exists to shrink.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
154
|
+
// Why a fixed ladder and not "every +N tokens": periodic nagging would break the
|
|
155
|
+
// rule this hook lives under — it is the only hook that ADDS tokens to the
|
|
156
|
+
// context it exists to shrink. Three notices per cycle is O(1) and measurable:
|
|
157
|
+
// the text is ~600 chars ≈ 150 tokens, so the third rung costs ~0.1% of the
|
|
158
|
+
// context it appears in. An unbounded series is the feature eating itself; a
|
|
159
|
+
// bounded rung is not. (PO decision 2026-07-28, TASK-084 — the 1.5x point comes
|
|
160
|
+
// from use: 120k is often too early to close a thread, 240k so far that a
|
|
161
|
+
// session has to be dragged out to reach it.)
|
|
162
|
+
//
|
|
163
|
+
// Multipliers rather than absolute numbers so TUNE_CONTEXT_REMIND_TOKENS scales
|
|
164
|
+
// the whole ladder, as the README promises. A hardcoded 180_000 would fall
|
|
165
|
+
// BELOW the first rung the moment someone raises the threshold for a 1M window.
|
|
166
|
+
//
|
|
167
|
+
// Each rung carries its own wording, in ONE structure, deliberately. The
|
|
168
|
+
// escalation IS that sentence tightening — repeating the level-1 text three
|
|
169
|
+
// times would just be the same coin flipped again, which is what would make a
|
|
170
|
+
// third rung not worth its tokens. Splitting trigger from wording into two
|
|
171
|
+
// parallel structures would let them drift: a fourth multiplier with no fourth
|
|
172
|
+
// sentence falls back to the level-1 text, so the LOUDEST rung would print the
|
|
173
|
+
// SOFTEST wording and nothing would fail. One array makes that unrepresentable.
|
|
174
|
+
//
|
|
175
|
+
// The graded sentence REPLACES the mid-task line rather than riding on top of
|
|
176
|
+
// it, and that is a budget fact rather than taste: the rest of the reminder is
|
|
177
|
+
// 490 chars against a <700 ceiling, and the old append-a-sentence shape had
|
|
178
|
+
// already spent that down to 21 chars of headroom (measured 592 / 679 before
|
|
179
|
+
// TASK-084). Swapping keeps every rung inside budget — 592 / 615 / 606.
|
|
180
|
+
const LADDER = [
|
|
181
|
+
{
|
|
182
|
+
at: 1,
|
|
183
|
+
timing: 'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
at: 1.5,
|
|
187
|
+
timing:
|
|
188
|
+
'If you are mid-task: raise it at the next pause anyway — this is no longer early, and silence now costs the user every turn.',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
at: 2,
|
|
192
|
+
timing:
|
|
193
|
+
'Final automatic notice — say it this turn whatever you are in the middle of; nothing more fires until a compaction.',
|
|
194
|
+
},
|
|
195
|
+
];
|
|
156
196
|
|
|
157
|
-
/** How many notices this session has earned at `total`: 0
|
|
197
|
+
/** How many notices this session has earned at `total`: 0..LADDER.length. */
|
|
158
198
|
function levelFor(total, threshold) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
let level = 0;
|
|
200
|
+
for (const rung of LADDER) {
|
|
201
|
+
if (total >= threshold * rung.at) level++;
|
|
202
|
+
}
|
|
203
|
+
return level;
|
|
162
204
|
}
|
|
163
205
|
|
|
164
206
|
/**
|
|
165
207
|
* Pure decision: given the previous reading for this session and the current
|
|
166
208
|
* one, should a reminder fire, and what is the session's next state?
|
|
167
209
|
*
|
|
168
|
-
* Fires at most
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
210
|
+
* Fires at most once per rung of LEVEL_MULTIPLIERS, never on every prompt above
|
|
211
|
+
* the line. A reminder costs tokens on the very prompt it appears in, so a
|
|
212
|
+
* context-efficiency tool that nagged every turn would be spending the thing it
|
|
213
|
+
* claims to save.
|
|
214
|
+
*
|
|
215
|
+
* `level` rides out with the decision because the TEXT is graded by it — see
|
|
216
|
+
* reminderText(). Crossing several rungs in one turn (a 100k tool result) fires
|
|
217
|
+
* once, at the rung actually reached: no level is owed retroactively.
|
|
172
218
|
*/
|
|
173
219
|
export function decide(prev, total, threshold) {
|
|
174
220
|
// `fired: true` is how state written before TASK-080 recorded a single
|
|
175
221
|
// firing; read it as level 1 so an in-flight session whose hook is upgraded
|
|
176
222
|
// mid-run is not re-notified from scratch.
|
|
223
|
+
//
|
|
224
|
+
// A pre-TASK-084 `levels: 2` means "crossed 2x", which on the new ladder is
|
|
225
|
+
// level 3 — so a session live across that upgrade earns exactly ONE extra
|
|
226
|
+
// notice, then goes quiet for good. Accepted rather than fixed: telling old-2
|
|
227
|
+
// from new-2 needs a ladder version in the state, a permanent cost for one
|
|
228
|
+
// notice in sessions that happen to span an upgrade.
|
|
177
229
|
let reached = typeof prev?.levels === 'number' ? prev.levels : prev?.fired === true ? 1 : 0;
|
|
178
230
|
// A large drop means the context was compacted/cleared: arm again so the
|
|
179
231
|
// NEXT time it grows past the threshold the user hears about it.
|
|
@@ -181,7 +233,7 @@ export function decide(prev, total, threshold) {
|
|
|
181
233
|
|
|
182
234
|
const level = levelFor(total, threshold);
|
|
183
235
|
const remind = level > reached;
|
|
184
|
-
return { remind,
|
|
236
|
+
return { remind, level, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
|
|
185
237
|
}
|
|
186
238
|
|
|
187
239
|
/**
|
|
@@ -207,11 +259,15 @@ export function userNotice(total) {
|
|
|
207
259
|
|
|
208
260
|
/**
|
|
209
261
|
* The channel that actually works (`additionalContext`): the model reads it and
|
|
210
|
-
* relays it. The timing judgement
|
|
211
|
-
* whether the current thread is finished — but
|
|
212
|
-
*
|
|
262
|
+
* relays it. The timing judgement is the model's on the FIRST rung, because only
|
|
263
|
+
* it can tell whether the current thread is finished — but that exemption is
|
|
264
|
+
* what LADDER spends: by the last rung the text asks it to speak regardless,
|
|
265
|
+
* since staying silent forever means the user never learns at all.
|
|
266
|
+
*
|
|
267
|
+
* `level` defaults to 1, so the level-1 text is byte-identical to every release
|
|
268
|
+
* since TASK-079 and a caller that does not care about rungs keeps working.
|
|
213
269
|
*/
|
|
214
|
-
export function reminderText(total, {
|
|
270
|
+
export function reminderText(total, { level = 1 } = {}) {
|
|
215
271
|
const k = Math.round(total / 1000);
|
|
216
272
|
return [
|
|
217
273
|
`[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
|
|
@@ -220,11 +276,10 @@ export function reminderText(total, { escalated = false } = {}) {
|
|
|
220
276
|
// heard about the cost at ~346k.
|
|
221
277
|
'The user CANNOT see this reminder — it reaches you alone, so if you never mention it they will never know.',
|
|
222
278
|
'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
|
|
223
|
-
|
|
279
|
+
// Position matters: the graded sentence stays 4th of 5, so level 1 is
|
|
280
|
+
// unchanged from what shipped.
|
|
281
|
+
(LADDER[level - 1] ?? LADDER[0]).timing,
|
|
224
282
|
'The PreCompact hook preserves branch, dirty files and recent commits across a compaction — judgment calls and open threads are NOT preserved, so write those down first.',
|
|
225
|
-
// Kept short on purpose: it rides ON TOP of the full reminder, inside the
|
|
226
|
-
// same <700 char budget.
|
|
227
|
-
...(escalated ? ['This is the second and final automatic notice — nothing more fires until a compaction.'] : []),
|
|
228
283
|
].join(' ');
|
|
229
284
|
}
|
|
230
285
|
|
|
@@ -245,7 +300,7 @@ function main() {
|
|
|
245
300
|
const session = input.session_id || 'unknown';
|
|
246
301
|
const path = stateFile();
|
|
247
302
|
const state = readState(path);
|
|
248
|
-
const { remind,
|
|
303
|
+
const { remind, level, next } = decide(state[session], total, thresholdTokens());
|
|
249
304
|
|
|
250
305
|
state[session] = next;
|
|
251
306
|
try {
|
|
@@ -264,7 +319,7 @@ function main() {
|
|
|
264
319
|
systemMessage: userNotice(total),
|
|
265
320
|
hookSpecificOutput: {
|
|
266
321
|
hookEventName: 'UserPromptSubmit',
|
|
267
|
-
additionalContext: reminderText(total, {
|
|
322
|
+
additionalContext: reminderText(total, { level }),
|
|
268
323
|
},
|
|
269
324
|
}),
|
|
270
325
|
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bglocation/tune-context",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Context-efficiency configurator for Claude Code
|
|
3
|
+
"version": "2.3.1",
|
|
4
|
+
"description": "Context-efficiency configurator for Claude Code — token-saving doctrine packaged as skills, plus a tune-context configurator that scaffolds a lean CLAUDE.md, doctrine skills, subagents, settings, hooks and MCP wiring.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Szymon Walczak",
|
|
@@ -166,10 +166,14 @@ covering the detected build/test/lint commands.
|
|
|
166
166
|
It reads the **tail** of the session transcript (`transcript_path`, present on
|
|
167
167
|
every hook payload), sums `input_tokens + cache_read + cache_creation` — the
|
|
168
168
|
context actually re-sent — and once that crosses a PO-frozen threshold
|
|
169
|
-
(120k, override `TUNE_CONTEXT_REMIND_TOKENS`) injects
|
|
170
|
-
consider `/compact`. Fires **
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
(120k, override `TUNE_CONTEXT_REMIND_TOKENS`) injects a short reminder to
|
|
170
|
+
consider `/compact`. Fires at most **three times per crossing**, on a ladder
|
|
171
|
+
of 1×/1.5×/2× the threshold (120k/180k/240k), re-arming only after a
|
|
172
|
+
compaction-sized drop. Each rung tightens the wording — wait for a natural
|
|
173
|
+
break, then raise it at the next pause, then say it this turn — because a
|
|
174
|
+
note absorbed mid-task is otherwise never repeated. Bounded at three on
|
|
175
|
+
purpose: the reminder costs tokens on the prompt it appears in, so nagging
|
|
176
|
+
every turn would spend exactly what the tool saves. It reminds on
|
|
173
177
|
SIZE, not on "phase boundary" — size is measurable from the transcript, a
|
|
174
178
|
phase boundary is a judgment a hook (no model access) cannot make, so the
|
|
175
179
|
reminder hands that timing call to the model and `phase-workflow`.
|