@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.
@@ -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.2.0",
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.2.0",
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
- Prefer the model-driven path? Install the plugin and use the skill — it does the
65
- same thing plus wiki wiring and richer MCP detection:
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).** With the plugin installed (below), run
83
- `/tune-context` in any repo. It detects the stack, MCP servers and existing config,
84
- then generates a lean project `CLAUDE.md`, wires the doctrine skills, and merges
85
- hooks + permissions — idempotently, never clobbering hand-written content. This is
86
- the full-fidelity path (it also handles wiki wiring and semantic-search detection).
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 steps of the skill have **no CLI equivalent**, because each needs judgment a
103
- deterministic tool cannot supply — run `/tune-context` if you want them:
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
- | Step | Skill | CLI |
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
- single short note suggesting `/compact`.
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 note tells Claude to stay quiet mid-task
192
- and raise it at the first natural break: the hook can measure size, but only
193
- the model can see whether you're mid-refactor.
194
- - **Twice per crossing at most.** Once at the threshold, once more at **240,000**
195
- if the context keeps growing — because a note that lands mid-task can be
196
- correctly absorbed and then forgotten. Measured on one session: it fired at
197
- 166k, Claude stayed silent, and nothing spoke again until 346k. The second
198
- notice halves that blind spot; there is deliberately no third.
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. Both levels scale with the override — the
202
- second notice is always at twice the threshold.
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 (RAG
216
- installed-but-unused vs not installed; method-level doctrine that leaves no tool call).
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
@@ -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
- export function aggregate(records) {
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
- if (r.cwd != null) cwdSet.add(r.cwd);
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
- if (r.detail === 'rag' || tool.includes('search_codebase')) search.rag++;
158
- else if (r.detail === 'glob' || tool === 'Glob') search.glob++;
159
- else if (tool === 'Grep') search.grepTool++;
160
- else search.shell++; // Bash-grep / ambiguous — reported, excluded from RAG-share
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
- if (a.search.rag === 0) {
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 tells
17
- // it to stay quiet mid-task. The phase-boundary half is the `phase-workflow`
18
- // skill's job, model-side, where judgment actually lives.
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
- // TASK-080 fallback, chosen by the PO after measurement killed the preferred
141
- // design (see userNotice()): a SECOND and final notice at twice the threshold.
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 2x and not "every +N tokens": periodic nagging would break the rule this
151
- // hook lives under — it is the only hook that ADDS tokens to the context it
152
- // exists to shrink. One extra notice per cycle is a bounded, one-off cost that
153
- // halves the worst-case blind spot; an unbounded series is the feature eating
154
- // itself. Deliberately NOT a third level.
155
- const ESCALATION_MULTIPLIER = 2;
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, 1 or 2. */
197
+ /** How many notices this session has earned at `total`: 0..LADDER.length. */
158
198
  function levelFor(total, threshold) {
159
- if (total >= threshold * ESCALATION_MULTIPLIER) return 2;
160
- if (total >= threshold) return 1;
161
- return 0;
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 TWICE per crossing (threshold, then 2x threshold), never on
169
- * every prompt above the line. A reminder costs tokens on the very prompt it
170
- * appears in, so a context-efficiency tool that nagged every turn would be
171
- * spending the thing it claims to save.
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, escalated: remind && level === 2, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
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 stays here, because only the model can tell
211
- * whether the current thread is finished — but see the second sentence: it must
212
- * relay eventually, because staying silent forever means the user never learns.
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, { escalated = false } = {}) {
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
- 'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
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, escalated, next } = decide(state[session], total, thresholdTokens());
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, { escalated }),
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.2.0",
4
- "description": "Context-efficiency configurator for Claude Code \u2014 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.",
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 one short reminder to
170
- consider `/compact`. Fires **once per crossing**, re-arming only after a
171
- compaction-sized drop: the reminder costs tokens on the prompt it appears in,
172
- so nagging every turn would spend exactly what the tool saves. It reminds on
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`.