@marcomigozzi/trailhead 0.11.0 → 0.11.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.
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ // Tests for model-defaults-review.js. Run: node model-defaults-review.test.js
3
+ // No framework: plain asserts, mirrors the style of gh-subcommand.test.js.
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFileSync } = require('child_process');
9
+
10
+ // Isola ogni caso dal vero CLAUDE_CONFIG_DIR dell'utente: senza questo, una
11
+ // review "global" potrebbe leggere davvero ~/.claude/trailhead/config.json.
12
+ // Ripristinato in fondo al file; i casi che puntano altrove salvano e
13
+ // ripristinano il valore corrente attorno a se stessi.
14
+ const PRIOR_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR;
15
+ const BASE_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-globalcfg-'));
16
+ process.env.CLAUDE_CONFIG_DIR = BASE_CONFIG_DIR;
17
+
18
+ const {
19
+ loadData,
20
+ currentEntry,
21
+ stalePins,
22
+ applyUpdate,
23
+ semverLt,
24
+ reviewOffer,
25
+ reviewNotice,
26
+ findProjectRoot,
27
+ readAck,
28
+ writeAck,
29
+ check,
30
+ ack,
31
+ effectiveModels,
32
+ globalConfigDir,
33
+ globalConfigFile,
34
+ globalAckFile,
35
+ readAckFile,
36
+ writeAckFile,
37
+ } = require('./model-defaults-review.js');
38
+
39
+ const LIB_PATH = path.join(__dirname, 'model-defaults-review.js');
40
+
41
+ let passed = 0;
42
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
43
+
44
+ const data = loadData();
45
+ const cur = currentEntry(data);
46
+ ok('loadData reads the shipped JSON', data.schema === 1 && Array.isArray(data.history));
47
+ ok('currentEntry is the last history entry', cur.since === data.history[data.history.length - 1].since);
48
+
49
+ // --- stalePins -------------------------------------------------------------
50
+ ok('no models -> stalePins empty', stalePins({}, data).length === 0);
51
+ ok('pins already equal to the new defaults -> stalePins empty',
52
+ stalePins({ models: { plan: 'claude-opus-5-5', execute: 'claude-sonnet-5-5' } }, data).length === 0);
53
+
54
+ {
55
+ const stale = stalePins({ models: { plan: 'claude-opus-4-8' } }, data);
56
+ ok('stale strong pin detected', stale.length === 1 &&
57
+ stale[0].key === 'models.plan' && stale[0].from === 'claude-opus-4-8' && stale[0].to === 'claude-opus-5-5');
58
+ }
59
+
60
+ {
61
+ const stale = stalePins({ models: { execute: 'claude-sonnet-5' } }, data);
62
+ ok('stale standard pin detected', stale.length === 1 &&
63
+ stale[0].key === 'models.execute' && stale[0].from === 'claude-sonnet-5' && stale[0].to === 'claude-sonnet-5-5');
64
+ }
65
+
66
+ {
67
+ const stale = stalePins({ models: { plan: 'claude-opus-5' } }, data);
68
+ ok('a superseded id (not a former plain default) is flagged too', stale.length === 1 &&
69
+ stale[0].key === 'models.plan' && stale[0].from === 'claude-opus-5' && stale[0].to === 'claude-opus-5-5');
70
+ }
71
+
72
+ {
73
+ const stale = stalePins({ models: { plan: 'claude-fable-5', review: 'inherit session' } }, data);
74
+ ok('unknown ids and "inherit session" are never flagged', stale.length === 0);
75
+ }
76
+
77
+ {
78
+ const stale = stalePins({ models: { codex: { plan: 'gpt-5.6-sol' } } }, data);
79
+ ok('codex pins already on current ids are not stale', stale.length === 0);
80
+ }
81
+
82
+ {
83
+ // Fixture sintetico: un tier codex il cui default e' cambiato, per esercitare
84
+ // il ramo {model, effort} che il dataset reale (codex invariato) non copre.
85
+ const synthetic = {
86
+ schema: 1,
87
+ history: [
88
+ { since: '0.1.0', claude: { strong: 'a', standard: 'b', fast: 'c' }, codex: { strong: 'old-strong', standard: 's', fast: 'f' } },
89
+ { since: '0.2.0', claude: { strong: 'a', standard: 'b', fast: 'c' }, codex: { strong: 'new-strong', standard: 's', fast: 'f' } },
90
+ ],
91
+ };
92
+ const stale = stalePins({ models: { codex: { plan: { model: 'old-strong', effort: 'high' } } } }, synthetic);
93
+ ok('a stale codex {model, effort} pin is detected on the model field', stale.length === 1 &&
94
+ stale[0].key === 'models.codex.plan' && stale[0].from === 'old-strong' && stale[0].to === 'new-strong');
95
+ }
96
+
97
+ // --- applyUpdate -------------------------------------------------------------
98
+ {
99
+ const config = { other: 1, models: { plan: 'claude-opus-4-8', codex: { plan: { model: 'gpt-old', effort: 'high' } } } };
100
+ const stale = [
101
+ { key: 'models.plan', from: 'claude-opus-4-8', to: 'claude-opus-5-5' },
102
+ { key: 'models.codex.plan', from: 'gpt-old', to: 'gpt-new' },
103
+ ];
104
+ const next = applyUpdate(config, stale);
105
+ ok('applyUpdate replaces a claude string pin', next.models.plan === 'claude-opus-5-5');
106
+ ok('applyUpdate replaces only model in a codex {model, effort} pin, keeping effort',
107
+ next.models.codex.plan.model === 'gpt-new' && next.models.codex.plan.effort === 'high');
108
+ ok('applyUpdate does not mutate the input config',
109
+ config.models.plan === 'claude-opus-4-8' && config.models.codex.plan.model === 'gpt-old');
110
+ }
111
+
112
+ // --- reviewOffer / reviewNotice ----------------------------------------------
113
+ ok('reviewOffer: ack equal to current since -> null',
114
+ reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: cur.since }) === null);
115
+ ok('reviewOffer: older ack -> offered',
116
+ reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: '0.10.0' }) !== null);
117
+ ok('reviewOffer: no ack, no stale pins -> null',
118
+ reviewOffer({ config: {}, data, ack: null }) === null);
119
+
120
+ {
121
+ const offer = reviewOffer({ config: { models: { plan: 'claude-opus-4-8' } }, data, ack: null });
122
+ ok('reviewOffer notice mentions the since version', offer.notice.includes(cur.since));
123
+ ok('reviewOffer notice lists the stale key with -> arrow', offer.notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
124
+ ok('reviewNotice never contains an em-dash', !reviewNotice(offer.stale, cur.since).includes('—'));
125
+ ok('reviewNotice no longer claims the offer will not reappear once acknowledged',
126
+ !reviewNotice(offer.stale, cur.since).includes('This offer will not reappear once acknowledged.'));
127
+ ok('reviewNotice says the offer reappears until acknowledged',
128
+ reviewNotice(offer.stale, cur.since).includes('reappears') || reviewNotice(offer.stale, cur.since).toLowerCase().includes('until'));
129
+ }
130
+
131
+ // --- fs round trip: check() is read-only --------------------------------------
132
+ {
133
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-'));
134
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
135
+ fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
136
+
137
+ ok('findProjectRoot finds the nearest .trailhead/config.json', findProjectRoot(tmp) === tmp);
138
+ ok('findProjectRoot walking from a subdir still finds it', (() => {
139
+ const sub = path.join(tmp, 'a', 'b');
140
+ fs.mkdirSync(sub, { recursive: true });
141
+ return findProjectRoot(sub) === tmp;
142
+ })());
143
+ ok('findProjectRoot returns null with no project above', findProjectRoot(fs.mkdtempSync(path.join(os.tmpdir(), 'th-noproj-'))) === null);
144
+
145
+ ok('readAck is null before any ack is written', readAck(tmp) === null);
146
+
147
+ const first = check(tmp);
148
+ ok('check() returns a notice on first run', typeof first === 'string' && first.length > 0);
149
+ ok('check() is read-only: writes no ack file', readAck(tmp) === null);
150
+ ok('check() is read-only: writes no ack file on disk either', !fs.existsSync(path.join(tmp, '.trailhead', 'model-defaults-ack')));
151
+
152
+ const second = check(tmp);
153
+ ok('check() returns the same notice on a second run (still not acknowledged)', second === first);
154
+
155
+ ok('the notice carries a ready-to-run ack instruction naming this lib and the project root',
156
+ first.includes(`node "${LIB_PATH}" ack "${tmp}"`));
157
+ }
158
+
159
+ // --- ack(cwd) ------------------------------------------------------------------
160
+ {
161
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-ack-'));
162
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
163
+ fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
164
+
165
+ ok('check() returns a notice before ack()', typeof check(tmp) === 'string');
166
+
167
+ const result = ack(tmp);
168
+ ok('ack() returns true when it finds a project root', result === true);
169
+ ok('ack() writes the current since as the ack', readAck(tmp) === cur.since);
170
+ ok('check() returns null after ack()', check(tmp) === null);
171
+ }
172
+
173
+ {
174
+ const noProj = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-ack-noproj-'));
175
+ ok('ack() returns false with no project above cwd', ack(noProj) === false);
176
+ ok('ack() never throws and writes nothing with no project above cwd',
177
+ !fs.existsSync(path.join(noProj, '.trailhead')));
178
+ }
179
+
180
+ {
181
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-nopins-'));
182
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
183
+ fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({}));
184
+ const res = check(tmp);
185
+ ok('check() with no pins returns null', res === null);
186
+ ok('check() with no pins writes no ack file', readAck(tmp) === null);
187
+ }
188
+
189
+ {
190
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-'));
191
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
192
+ fs.writeFileSync(path.join(tmp, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
193
+
194
+ const out = execFileSync('node', [LIB_PATH, 'check', tmp], { encoding: 'utf8' });
195
+ ok('CLI check prints the notice to stdout', out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
196
+ ok('CLI check writes no ack file', !fs.existsSync(path.join(tmp, '.trailhead', 'model-defaults-ack')));
197
+
198
+ const ackOut = execFileSync('node', [LIB_PATH, 'ack', tmp], { encoding: 'utf8' });
199
+ ok('CLI ack prints nothing', ackOut === '');
200
+ ok('CLI ack writes the ack file', readAck(tmp) === cur.since);
201
+
202
+ const outAfterAck = execFileSync('node', [LIB_PATH, 'check', tmp], { encoding: 'utf8' });
203
+ ok('CLI check prints nothing after CLI ack', outAfterAck === '');
204
+ }
205
+
206
+ {
207
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-unknown-'));
208
+ let status = null;
209
+ try {
210
+ execFileSync('node', [LIB_PATH, 'bogus', tmp], { encoding: 'utf8' });
211
+ status = 0;
212
+ } catch (e) {
213
+ status = e.status;
214
+ }
215
+ ok('CLI with an unknown subcommand still exits 0', status === 0);
216
+ }
217
+
218
+ // --- shipped data invariants ---------------------------------------------
219
+ {
220
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', 'package.json'), 'utf8'));
221
+ ok('shipped data: schema is 1', data.schema === 1);
222
+ let ascending = true;
223
+ for (let i = 1; i < data.history.length; i++) {
224
+ if (!semverLt(data.history[i - 1].since, data.history[i].since)) ascending = false;
225
+ }
226
+ ok('shipped data: history is ascending by since', ascending);
227
+ const complete = data.history.every((e) => ['claude', 'codex'].every((h) =>
228
+ ['strong', 'standard', 'fast'].every((t) => typeof e[h][t] === 'string' && e[h][t].length > 0)));
229
+ ok('shipped data: every entry is a complete snapshot (3 tiers x 2 hosts)', complete);
230
+ ok('shipped data: last since <= package.json version',
231
+ cur.since === pkg.version || semverLt(cur.since, pkg.version));
232
+ const plugin = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '.claude-plugin', 'plugin.json'), 'utf8'));
233
+ ok('shipped data: last since <= plugin.json version',
234
+ cur.since === plugin.version || semverLt(cur.since, plugin.version));
235
+ }
236
+
237
+ {
238
+ const refDoc = fs.readFileSync(
239
+ path.resolve(__dirname, '..', '..', 'skills', '_shared', 'configuration-reference.md'), 'utf8');
240
+ ok('drift guard: current claude strong id appears in the Tier-class -> id section',
241
+ refDoc.includes(cur.claude.strong));
242
+ ok('drift guard: current claude standard id appears in the Tier-class -> id section',
243
+ refDoc.includes(cur.claude.standard));
244
+ }
245
+
246
+ // --- effectiveModels (pure merge, no mutation) --------------------------------
247
+ {
248
+ const globalModels = { plan: 'g-plan', execute: 'g-exec', codex: { plan: { model: 'g-gpt', effort: 'high' } } };
249
+ const projectModels = { plan: 'p-plan', codex: { plan: { model: 'p-gpt' } } };
250
+ const layers = [
251
+ { layer: 'global', file: '/g/trailhead/config.json', config: { models: globalModels } },
252
+ { layer: 'project', file: '/p/.trailhead/config.json', config: { models: projectModels } },
253
+ ];
254
+ const { config, sources } = effectiveModels(layers);
255
+ ok('effectiveModels: a project key wins over the global one', config.models.plan === 'p-plan');
256
+ ok('effectiveModels: a global-only key survives when the project does not set it', config.models.execute === 'g-exec');
257
+ ok('effectiveModels: a project codex key replaces the global one atomically (no inherited effort)',
258
+ config.models.codex.plan.model === 'p-gpt' && config.models.codex.plan.effort === undefined);
259
+ ok('effectiveModels: sources tags models.plan as the project layer',
260
+ sources['models.plan'].layer === 'project' && sources['models.plan'].file === '/p/.trailhead/config.json');
261
+ ok('effectiveModels: sources tags models.execute as the global layer',
262
+ sources['models.execute'].layer === 'global' && sources['models.execute'].file === '/g/trailhead/config.json');
263
+ ok('effectiveModels: sources tags models.codex.plan as the project layer',
264
+ sources['models.codex.plan'].layer === 'project' && sources['models.codex.plan'].file === '/p/.trailhead/config.json');
265
+ ok('effectiveModels does not mutate the input layer configs',
266
+ globalModels.plan === 'g-plan' && projectModels.codex.plan.model === 'p-gpt' && !('effort' in projectModels.codex.plan));
267
+ }
268
+
269
+ {
270
+ const layers = [
271
+ { layer: 'global', file: '/g/config.json', config: { models: { plan: 'claude-opus-4-8' } } },
272
+ { layer: 'project', file: '/p/config.json', config: { models: { plan: null } } },
273
+ ];
274
+ const { config, sources } = effectiveModels(layers);
275
+ ok('effectiveModels: a project null value wins over global by own-key presence, not truthiness', config.models.plan === null);
276
+ ok('effectiveModels: source for plan is the project even though its value is null', sources['models.plan'].layer === 'project');
277
+ }
278
+
279
+ {
280
+ const layers = [
281
+ { layer: 'global', file: '/g/config.json', config: { models: { plan: 'claude-opus-4-8' } } },
282
+ { layer: 'project', file: '/p/config.json', config: { models: { plan: '' } } },
283
+ ];
284
+ const { config } = effectiveModels(layers);
285
+ ok('effectiveModels: a project empty-string value wins over global by own-key presence', config.models.plan === '');
286
+ }
287
+
288
+ {
289
+ const layers = [
290
+ { layer: 'global', file: '/g/config.json', config: { models: { codex: { plan: { model: 'g-gpt' } } } } },
291
+ { layer: 'project', file: '/p/config.json', config: { models: { codex: 'not-an-object' } } },
292
+ ];
293
+ const { config, sources } = effectiveModels(layers);
294
+ ok('effectiveModels: a malformed project codex wins wholesale, yielding no codex pins', config.models.codex === 'not-an-object');
295
+ ok('effectiveModels: a malformed project codex clears prior codex sources', !sources['models.codex.plan']);
296
+ }
297
+
298
+ {
299
+ const layers = [{ layer: 'global', file: '/g/config.json', config: { models: null } }];
300
+ const { config } = effectiveModels(layers);
301
+ ok('effectiveModels: a layer whose models is not a plain object contributes nothing', Object.keys(config.models).length === 0);
302
+ }
303
+
304
+ // --- stalePins with a malformed codex value -------------------------------
305
+ ok('stalePins: a non-object models.codex yields no codex pins (not an exception)',
306
+ stalePins({ models: { codex: 'oops' } }, data).length === 0);
307
+
308
+ // --- check()/ack(): global config merged under the project ---------------
309
+ {
310
+ const savedEnv = process.env.CLAUDE_CONFIG_DIR;
311
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-'));
312
+ process.env.CLAUDE_CONFIG_DIR = globalDir;
313
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
314
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
315
+
316
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-empty-'));
317
+ fs.mkdirSync(path.join(projectDir, '.trailhead'));
318
+ fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({}));
319
+
320
+ const notice = check(projectDir);
321
+ ok('check(): a project with an empty config still flags a stale global pin',
322
+ typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
323
+ ok('check(): the stale global pin line is tagged with the global config path',
324
+ notice.includes(path.join(globalDir, 'trailhead', 'config.json')));
325
+
326
+ process.env.CLAUDE_CONFIG_DIR = savedEnv;
327
+ }
328
+
329
+ {
330
+ const savedEnv = process.env.CLAUDE_CONFIG_DIR;
331
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global2-'));
332
+ process.env.CLAUDE_CONFIG_DIR = globalDir;
333
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
334
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
335
+
336
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-override-'));
337
+ fs.mkdirSync(path.join(projectDir, '.trailhead'));
338
+ fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-5-5' } }));
339
+
340
+ ok('check(): a project pin overriding a stale global pin is not flagged', check(projectDir) === null);
341
+
342
+ process.env.CLAUDE_CONFIG_DIR = savedEnv;
343
+ }
344
+
345
+ {
346
+ const savedEnv = process.env.CLAUDE_CONFIG_DIR;
347
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-only-'));
348
+ process.env.CLAUDE_CONFIG_DIR = globalDir;
349
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
350
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
351
+
352
+ const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-noproj2-'));
353
+
354
+ const notice = check(noProjDir);
355
+ ok('check(): no project above cwd but a global config exists -> a notice',
356
+ typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
357
+ ok('check(): the global-only case writes nothing', !fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
358
+
359
+ const ackResult = ack(noProjDir);
360
+ ok('ack(): the global-only case writes the global ack and returns true',
361
+ ackResult === true && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
362
+
363
+ ok('check(): the global-only case is silenced after the global ack', check(noProjDir) === null);
364
+
365
+ process.env.CLAUDE_CONFIG_DIR = savedEnv;
366
+ }
367
+
368
+ {
369
+ const savedEnv = process.env.CLAUDE_CONFIG_DIR;
370
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-bad-'));
371
+ process.env.CLAUDE_CONFIG_DIR = globalDir;
372
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
373
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), '{ not valid json');
374
+
375
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-malformed-global-'));
376
+ fs.mkdirSync(path.join(projectDir, '.trailhead'));
377
+ fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
378
+
379
+ const notice = check(projectDir);
380
+ ok('check(): a malformed global config does not block the project review',
381
+ typeof notice === 'string' && notice.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
382
+
383
+ process.env.CLAUDE_CONFIG_DIR = savedEnv;
384
+ }
385
+
386
+ {
387
+ const savedEnv = process.env.CLAUDE_CONFIG_DIR;
388
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-global-mixed-'));
389
+ process.env.CLAUDE_CONFIG_DIR = globalDir;
390
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
391
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
392
+
393
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-proj-mixed-'));
394
+ fs.mkdirSync(path.join(projectDir, '.trailhead'));
395
+ fs.writeFileSync(path.join(projectDir, '.trailhead', 'config.json'), JSON.stringify({ models: { execute: 'claude-sonnet-5' } }));
396
+
397
+ const notice = check(projectDir);
398
+ const globalConfigPath = path.join(globalDir, 'trailhead', 'config.json');
399
+ const projectConfigPath = path.join(projectDir, '.trailhead', 'config.json');
400
+ ok('check(): mixed sources - the global stale key is tagged with the global config path',
401
+ notice.includes(`models.plan: claude-opus-4-8 -> claude-opus-5-5 (new default; from the global config, ${globalConfigPath})`));
402
+ ok('check(): mixed sources - the project stale key is tagged with the project config path',
403
+ notice.includes(`models.execute: claude-sonnet-5 -> claude-sonnet-5-5 (new default; from the project config, ${projectConfigPath})`));
404
+
405
+ process.env.CLAUDE_CONFIG_DIR = savedEnv;
406
+ }
407
+
408
+ // --- CLI check/ack honouring CLAUDE_CONFIG_DIR ----------------------------
409
+ {
410
+ const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-global-'));
411
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
412
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
413
+
414
+ const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-cli-noproj-'));
415
+
416
+ const out = execFileSync('node', [LIB_PATH, 'check', noProjDir], {
417
+ encoding: 'utf8',
418
+ env: { ...process.env, CLAUDE_CONFIG_DIR: globalDir },
419
+ });
420
+ ok('CLI check with CLAUDE_CONFIG_DIR set flags a global-only stale pin',
421
+ out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
422
+
423
+ const ackOut = execFileSync('node', [LIB_PATH, 'ack', noProjDir], {
424
+ encoding: 'utf8',
425
+ env: { ...process.env, CLAUDE_CONFIG_DIR: globalDir },
426
+ });
427
+ ok('CLI ack with CLAUDE_CONFIG_DIR set writes the global ack',
428
+ ackOut === '' && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
429
+ }
430
+
431
+ console.log(`model-defaults-review.test.js: ${passed} assertions passed`);
432
+
433
+ if (PRIOR_CLAUDE_CONFIG_DIR === undefined) {
434
+ delete process.env.CLAUDE_CONFIG_DIR;
435
+ } else {
436
+ process.env.CLAUDE_CONFIG_DIR = PRIOR_CLAUDE_CONFIG_DIR;
437
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "schema": 1,
3
+ "history": [
4
+ { "since": "0.5.1",
5
+ "claude": { "strong": "claude-opus-4-8", "standard": "claude-sonnet-5", "fast": "claude-haiku-4-5-20251001" },
6
+ "codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" } },
7
+ { "since": "0.11.0",
8
+ "claude": { "strong": "claude-opus-5-5", "standard": "claude-sonnet-5-5", "fast": "claude-haiku-4-5-20251001" },
9
+ "codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" },
10
+ "supersedes": { "claude": { "strong": ["claude-opus-5"], "standard": ["claude-sonnet-4-8"] } } }
11
+ ]
12
+ }
@@ -105,9 +105,11 @@ function latestGitHub() {
105
105
  return '';
106
106
  }
107
107
 
108
- function main() {
108
+ // Calcola la notice dell'update (solo Codex, comportamento invariato).
109
+ // Ritorna null se non c'è nulla da segnalare.
110
+ function updateNotice() {
109
111
  const info = detect();
110
- if (!SEMVER.test(clean(info.version))) return; // versione installata sconosciuta: non azzardare
112
+ if (!SEMVER.test(clean(info.version))) return null; // versione installata sconosciuta: non azzardare
111
113
  const out = cacheFile(info.host); // cache per-host: Codex non eredita il verdetto di Claude
112
114
 
113
115
  // throttle: se la cache è fresca (<6h) riusa quel verdetto senza richiamare la rete
@@ -119,7 +121,7 @@ function main() {
119
121
  verdict = prev;
120
122
  } else {
121
123
  const latest = (info.channel === 'npm' || info.channel === 'codex') ? latestNpm() : latestGitHub();
122
- if (!SEMVER.test(clean(latest))) return; // rete/fonte non disponibile: lascia la cache com'è
124
+ if (!SEMVER.test(clean(latest))) return null; // rete/fonte non disponibile: lascia la cache com'è
123
125
 
124
126
  verdict = {
125
127
  channel: info.channel,
@@ -138,18 +140,71 @@ function main() {
138
140
 
139
141
  // Codex non ha una statusline: l'hook stesso deve segnalare l'update
140
142
  // disponibile via additionalContext, su OGNI sessione (non solo quando la
141
- // finestra di throttle si riapre). Su Claude (info.host è undefined) non
142
- // scrive mai su stdout: comportamento invariato.
143
+ // finestra di throttle si riapre). Su Claude (info.host è undefined) resta
144
+ // sempre null: comportamento invariato.
143
145
  if (info.host === 'codex' && verdict && verdict.updateAvailable === true && SEMVER.test(clean(verdict.latest))) {
144
- process.stdout.write(JSON.stringify({
145
- hookSpecificOutput: {
146
- hookEventName: 'SessionStart',
147
- additionalContext: `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`,
148
- },
149
- }));
146
+ return `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`;
150
147
  }
148
+ return null;
151
149
  }
152
150
 
153
- // esegui in background: non blocca l'avvio della sessione.
154
- // L'hook stesso ritorna subito; il lavoro (rete) è dentro main() con timeout brevi.
155
- try { main(); } catch { /* mai far fallire SessionStart */ }
151
+ // Notice di revisione dei pin dei modelli (#186): calcolata PRIMA e a
152
+ // prescindere dall'update check, cosi' un early-return di quest'ultimo
153
+ // (versione ignota, rete assente) non la fa mai cadere. Il require è dentro
154
+ // un try: una lib mancante non deve mai rompere SessionStart. check() è di
155
+ // sola lettura (#187): non scrive l'ack, quindi ripropone la notice finche'
156
+ // l'agente non chiama la subcommand "ack" della stessa lib, dopo che
157
+ // l'utente ha risposto a una delle tre scelte della notice.
158
+ function reviewNotice(cwd) {
159
+ try {
160
+ return require('./lib/model-defaults-review.js').check(cwd) || null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ // Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
167
+ // gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
168
+ // un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
169
+ const STDIN_TIMEOUT_MS = 2000;
170
+ function readInput(done) {
171
+ if (process.stdin.isTTY) return done({});
172
+ let data = '';
173
+ let finished = false;
174
+ const finish = () => {
175
+ if (finished) return;
176
+ finished = true;
177
+ clearTimeout(timer);
178
+ try { process.stdin.destroy(); } catch { /* ignore */ }
179
+ let input = {};
180
+ try { input = JSON.parse(data || '{}'); } catch { input = {}; }
181
+ done(input && typeof input === 'object' ? input : {});
182
+ };
183
+ const timer = setTimeout(finish, STDIN_TIMEOUT_MS);
184
+ process.stdin.on('data', (c) => (data += c));
185
+ process.stdin.on('end', finish);
186
+ process.stdin.on('error', finish);
187
+ }
188
+
189
+ function main(input) {
190
+ const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
191
+
192
+ const review = reviewNotice(cwd);
193
+ const update = updateNotice();
194
+
195
+ const parts = [review, update].filter(Boolean);
196
+ if (!parts.length) return;
197
+
198
+ process.stdout.write(JSON.stringify({
199
+ hookSpecificOutput: {
200
+ hookEventName: 'SessionStart',
201
+ additionalContext: parts.join('\n\n'),
202
+ },
203
+ }));
204
+ }
205
+
206
+ // L'hook non deve MAI far fallire SessionStart: ogni errore degrada in silenzio.
207
+ // Il lavoro (rete) è dentro main() con timeout brevi.
208
+ try {
209
+ readInput((input) => { try { main(input); } catch { /* mai far fallire SessionStart */ } });
210
+ } catch { /* mai far fallire SessionStart */ }
@@ -37,6 +37,8 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
37
37
 
38
38
  **Inline update check (hookless hosts).** On a host without a hook bus (the descriptor's `hookBus == 'none'`) the `trailhead-check-update.js` SessionStart hook and the statusline flag are both absent, so the engine does the check **inline at session start**, co-located with the models-collapse notice above. It is lightweight and degrades silently: read the shared cache (`$XDG_CACHE_HOME/trailhead/update-check.json`, the same file the hook writes on Claude Code) and, if it reports an update, surface a one-line notice pointing at `/trailhead:update` (the pure `updateNotice(host, cache)` helper in `bin/lib/host-descriptor.js` is the canonical wording, and returns null on a host that already notifies via the hook, so it never double-fires); if the cache is absent or stale (older than a few hours), do a minimal throttled compare of the installed `VERSION` against the latest (npm registry or the GitHub release, per install channel) and rewrite the cache **atomically** (write a temp file in the same directory as the target, then rename it over the target, exactly as the `trailhead-check-update.js` SessionStart hook does with `out + '.tmp'` then `renameSync`), so a crash mid-write cannot leave a truncated or corrupt cache. Never block or fail the session on a network or parse error. `/trailhead:update` stays the on-demand installer.
39
39
 
40
+ **Pinned model review.** After an update that changed the tier defaults, the engine offers to review pinned `models.*` / `models.codex.*` ids that are older than their tier's new default (listed `key: old -> new`), with three choices: update (write the new ids, codex effort unchanged), keep (nothing to do, not asked again for these defaults), or open `/trailhead:config` to pick per key. The review evaluates the **effective** pins: the global `~/.claude/trailhead/config.json` merged under the project's `.trailhead/config.json`, project winning key by key (a project's own key, even `null`, `''`, or `inherit session`, always wins over the global value; `models.codex` follows the same per-key rule, so a stale pin inherited from the global config is flagged too, even for a project that pins nothing itself). Each stale line names the config file it actually came from, so the update choice edits the right one. The check (`model-defaults-review.js`'s `check(cwd)`, CLI subcommand `check`) is **read-only**: it never writes an ack by itself, so it keeps reoffering until the user actually answers. The ack (CLI subcommand `ack`, writing the acknowledged defaults version) is written only after the user picks one of the three choices, never just for the notice having been shown; it lives in the gitignored `.trailhead/model-defaults-ack` file when a project is found above `cwd`, whether the stale pin itself is project or global. With no project found above `cwd`, the review runs on the global config alone (if one exists) and its ack lives next to it, at `<config dir>/trailhead/model-defaults-ack` (config dir = `$CLAUDE_CONFIG_DIR` or `~/.claude`). The check runs both in the `trailhead-check-update.js` SessionStart hook and at skill config load (`../_shared/load-first.md`), once per session either way. Unset keys already follow the new defaults, so they are never flagged; unknown ids (custom or unrecognised) are never flagged either. The tier history lives in `hooks/lib/model-defaults.json`. Hookless hosts run the same `node <hooks>/lib/model-defaults-review.js check <dir>` / `... ack <dir>` pair inline at session start.
41
+
40
42
  If a value is unset, it resolves to its **Balanced**-profile default for that activity (the default profile: a project with no config still gets the Balanced tier per key, resolved to a concrete id at load time, strong → `claude-opus-5-5`, standard → `claude-sonnet-5-5`, fast → Haiku-class). To use the session model instead of the Balanced default, set the key explicitly to `inherit session`. Never hard-fail on an unknown id: warn and inherit the session model.
41
43
 
42
44
  **Everything else runs in the main session on its own model, there is no key for it:** charting, grilling, naming the destination, framing and **writing the tickets**, graduating fog, and map/decision operations. That's deliberate: it's HITL/orchestration work (you reason *with* the user), so the model that writes a ticket is the one grilling you; it can't fan out to a subagent. Practical upshot: **run a charting session (`/trailhead:new`/`:adopt`/`:ticket`) on your strong model**. The `models.*` keys above govern only the fan-out activities, not the quality of the tickets themselves. **Store a full model id** (e.g. `claude-opus-5-5`, `claude-fable-5`), never a bare family alias like `opus`; a family now spans several live versions, so an alias can't say which one. (On Claude Code the `Agent` tool takes only a family alias, so at subagent spawn the stored id is mapped to its alias, see the dispatch rule in `techniques.md`; the full id is still what gets stored, so the exact version stays on record and drives the inline-vs-subagent decision, even though the alias-only spawn cannot convey that version to the subagent.) **`models.plan` and `models.execute` are always distinct choices**: never collapse them into one shared model pick, even when the user would pick the same id for both.
@@ -52,6 +54,7 @@ If a value is unset, it resolves to its **Balanced**-profile default for that ac
52
54
  **Tier-class -> id**, resolved from the lineup actually reachable at setup time (verify, don't trust these verbatim):
53
55
  - **Claude** (`models.*`, ids only, no effort): strong = Opus-class, resolved to **`claude-opus-5-5`** by default (`claude-opus-5` stays offered in the per-key menus) · standard = Sonnet-class, resolved to **`claude-sonnet-5-5`** by default (so Balanced's `execute` runs on Sonnet 5.5; `claude-sonnet-5` stays offered in the per-key menus) · fast = Haiku-class.
54
56
  - **Codex** (`models.codex.*`, `{model, effort}`): strong = `gpt-5.6-sol` @ `high` (debug on **High** may be `xhigh`) · standard = `gpt-5.6-terra` @ `medium` · fast = `gpt-5.6-luna` @ `low`.
57
+ - Changing a tier default must append a complete new entry to `hooks/lib/model-defaults.json` in the same commit.
55
58
 
56
59
  **Ticket language.** `ticket.language` (ISO 639-1, default `en`) sets the language trailhead **writes** in on the Issues: ticket titles and bodies, engine comments (DISCUSS/PLAN/VERIFY/OPTIONS/REPRO/DIAGNOSIS/PAUSED/resolution), the map body sections, and commit message **descriptions**. It's fully decoupled from the language the agent *converses* in (its session/global setting); setting `ticket.language` never changes how the agent talks to the user. **Untouched by it:** code and identifiers, the fixed `trailhead:*` label names, config keys/values, and the conventional-commit type prefix (`feat:`/`fix:` stay English). The skill's own source docs (SKILL.md, `references/`) are not project prose and stay English.
57
60
 
@@ -65,7 +68,7 @@ If a value is unset, it resolves to its **Balanced**-profile default for that ac
65
68
 
66
69
  **plan_review.** `off` (default) = build PLANs are not externally reviewed; `on` = review with every external AI CLI detected on the PATH; a comma list (`gemini,codex`) = only those. Needs the named CLIs installed; if none are available it's skipped, never failed. The `build` engine's Plan step runs **Cross-AI plan review** when this is on.
67
70
 
68
- **code_review.rounds.** Integer, default 2. The max fix-and-re-review rounds the **Code review** technique spends chasing **Critical** (blocking) findings during the `build`/`bug` Verify step. When the budget is exhausted with Critical findings still open, the technique never resolves or continues silently: it surfaces an explicit HITL checkpoint (run another 2 rounds vs proceed with the blockers recorded). Mirrors `plan_review.rounds`.
71
+ **code_review.rounds.** Integer, default 2. The max fix-and-re-review rounds the **Code review** technique spends chasing **Critical** (blocking) findings during the `build`/`bug` Verify step. When the budget is exhausted with Critical findings still open, the technique never resolves or continues silently: it surfaces an explicit HITL checkpoint (run another 2 rounds, proceed with the blockers recorded, or run until it converges). Mirrors `plan_review.rounds`.
69
72
 
70
73
  **effort (cost-aware gating).** `standard` (default) = the full flow for the map cycle: every `build`/`bug` step runs at full cost, no triage (today's behaviour, backward-compatible). `lean` = **cost-aware**, defined **canonically here**; the `build`/`bug` engine references this section under `lean` and adds only its own timing. **`quick` is the exception: it runs the size triage and the offer below on *every* ticket, independent of `effort`** (reaching for `quick` is itself the small-piece signal; `effort` governs the map cycle, not `quick`, see `../trailhead-work/SKILL.md`). Under `lean` (and always for `quick`) the engine runs a **size triage** in the orchestrator (main session, inline, no subagent, so the triage itself costs almost nothing), classifying the change predictively from the ticket scope and a quick glance at the target files (the real diff is unknown before Execute):
71
74
  - **trivial**: a single file, a handful of lines, no new seam, no new dependency, no architectural change (a copy/CSS/doc/constant edit).
@@ -5,4 +5,4 @@ Three layers, **nearest wins**, key by key; a key unset at one layer inherits th
5
5
  - **Global**: `~/.claude/trailhead/config.json` (honour `$CLAUDE_CONFIG_DIR`): your standing defaults across every project.
6
6
  - **Defaults**: the built-in values documented in `configuration-reference.md`.
7
7
 
8
- Load the effective config at the start of a work session, **from the map's project root** (`.trailhead/config.json` at the superproject repo root) plus the global file, **before** any isolation workspace is set up. **Config is a property of the project/map, not the working directory**: an `isolation: worktree`/`clone` workspace **inherits** the config loaded here and must **never re-resolve it from the isolated copy's own root**. A per-ticket clone is always a clone of the **superproject** (never of a submodule alone), so its `.trailhead/config.json` is present when the project commits one and absent when it doesn't; either way re-resolving there is wrong, because the workspace must reuse the config already resolved from the original checkout, not load it afresh (an absent file would silently fall back to defaults, which is exactly how a `design: claude.ai/design` project ends up wrongly using `disk` in a clone). Config **writes** too (e.g. caching `design.project`) target the project's `.trailhead/config.json` in the original checkout, never the clone. **The main session orchestrates and holds the HITL moments** (the `build`/`bug` Discuss's "stop and ask", Verify's acceptance/UAT); **every model key runs as a subagent** (`plan`, `execute` (an *executor subagent* implementing with atomic commits on `models.execute`), `research`, `review`, `debug`, `verify`, `codebase-map`) so the whole per-activity model split applies in one session whatever model it runs on. Run Execute inline only when `models.execute` is unset or equals the session model. **On a subagent-less host** (`subagentToolkit == none`) there is nothing to fan out into: every activity runs inline. **Separately, on a host that can't honour the model keys** (`honorsModelKeys == false`, e.g. Codex, whose subagents run on its own OpenAI models) every `models.*` key collapses to the single session model even though the activities still fan out; when any `models.*` is set there the engine surfaces a **one-time** session-start notice that the split is not taking effect (the keys stay in `.trailhead/config.json` for when the project next runs on Claude Code). On Codex the per-activity split is available through a parallel `models.codex.*` namespace (OpenAI model ids), the honoured namespace there (projected into per-technique `~/.codex/agents/*.toml` and dispatched at runtime by `agent_type`), while `models.*` (Claude ids) still collapse to the single session model; the collapse notice points at `models.codex.*` and goes silent once it is set. A host without a hook bus (`hookBus == none`) also runs a lightweight inline update check at session start (no SessionStart hook, no statusline). See `configuration-reference.md`. The **keys, their values, the model/tdd/acceptance semantics, and the guided menu setup live in `configuration-reference.md`**: read it when running `/trailhead:config` or when you need a key's exact meaning. `/trailhead:config` with no args runs the guided setup; `config get` prints the merged config; `config set <key> <value>` writes one key.
8
+ Load the effective config at the start of a work session, **from the map's project root** (`.trailhead/config.json` at the superproject repo root) plus the global file, **before** any isolation workspace is set up. **Config is a property of the project/map, not the working directory**: an `isolation: worktree`/`clone` workspace **inherits** the config loaded here and must **never re-resolve it from the isolated copy's own root**. A per-ticket clone is always a clone of the **superproject** (never of a submodule alone), so its `.trailhead/config.json` is present when the project commits one and absent when it doesn't; either way re-resolving there is wrong, because the workspace must reuse the config already resolved from the original checkout, not load it afresh (an absent file would silently fall back to defaults, which is exactly how a `design: claude.ai/design` project ends up wrongly using `disk` in a clone). Config **writes** too (e.g. caching `design.project`) target the project's `.trailhead/config.json` in the original checkout, never the clone. **The main session orchestrates and holds the HITL moments** (the `build`/`bug` Discuss's "stop and ask", Verify's acceptance/UAT); **every model key runs as a subagent** (`plan`, `execute` (an *executor subagent* implementing with atomic commits on `models.execute`), `research`, `review`, `debug`, `verify`, `codebase-map`) so the whole per-activity model split applies in one session whatever model it runs on. Run Execute inline only when `models.execute` is unset or equals the session model. **On a subagent-less host** (`subagentToolkit == none`) there is nothing to fan out into: every activity runs inline. **Separately, on a host that can't honour the model keys** (`honorsModelKeys == false`, e.g. Codex, whose subagents run on its own OpenAI models) every `models.*` key collapses to the single session model even though the activities still fan out; when any `models.*` is set there the engine surfaces a **one-time** session-start notice that the split is not taking effect (the keys stay in `.trailhead/config.json` for when the project next runs on Claude Code). On Codex the per-activity split is available through a parallel `models.codex.*` namespace (OpenAI model ids), the honoured namespace there (projected into per-technique `~/.codex/agents/*.toml` and dispatched at runtime by `agent_type`), while `models.*` (Claude ids) still collapse to the single session model; the collapse notice points at `models.codex.*` and goes silent once it is set. A host without a hook bus (`hookBus == none`) also runs a lightweight inline update check at session start (no SessionStart hook, no statusline). After an update changes the tier defaults, a pinned-model review offer surfaces too, once per session and until the user answers it (see `../_shared/load-first.md` for the check/ack sequence and `configuration-reference.md`'s Pinned model review). See `configuration-reference.md`. The **keys, their values, the model/tdd/acceptance semantics, and the guided menu setup live in `configuration-reference.md`**: read it when running `/trailhead:config` or when you need a key's exact meaning. `/trailhead:config` with no args runs the guided setup; `config get` prints the merged config; `config set <key> <value>` writes one key.
@@ -16,3 +16,5 @@ Before doing anything, read the shared core, in order:
16
16
  **Host command forms (standing render rule).** Every trailhead command you write in prose you generate at runtime, the session handoff, a capture confirmation, any next-step suggestion, and not only where a doc example already shows one, uses **this host's command forms**: `/clear` to clear context, and the `/trailhead:<verb>` family (`/trailhead:work <n>`, `/trailhead:quick <n>`, `/trailhead:map`, `/trailhead:dashboard`). These literals are this host's surface. **Never emit a command form that belongs to a different host** (one you may have read elsewhere in this repository, such as in the projection code and its tests, or earlier in the session): the forms stated in this contract are the only ones correct for the host you are running on.
17
17
 
18
18
  Load the **effective config** (per `../_shared/configuration.md`) at session start, from the map's project root (`.trailhead/config.json` plus the global file), before any isolation workspace is set up.
19
+
20
+ **Pinned model review, once per session.** After loading the effective config, run the read-only check: `node <lib> check <project root>`, where `<lib>` is the first of these two paths (relative to this `_shared/` dir) that exists: `../../hooks/lib/model-defaults-review.js` (Claude plugin, Claude npm copy, dev-symlink install), else `../trailhead/hooks/lib/model-defaults-review.js` (Codex). If neither exists, skip silently. If it prints a notice, offer it to the user without blocking the current request; only after the user answers any of its three choices, run `node <lib> ack <project root>`. Do this at most once per session: skip it if the SessionStart hook (or an earlier config load this session) already surfaced the same offer. Repeats across sessions are expected until the user answers.