@marcomigozzi/trailhead 0.10.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.
Files changed (36) hide show
  1. package/README.it.md +2 -2
  2. package/README.md +2 -2
  3. package/bin/lib/install-verify.js +257 -0
  4. package/bin/lib/install-verify.test.js +379 -0
  5. package/bin/trailhead.js +86 -5
  6. package/bin/trailhead.test.js +500 -4
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  10. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  11. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  12. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  13. package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
  14. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
  15. package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
  16. package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  18. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +9 -6
  20. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  21. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  22. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  23. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
  24. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  25. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  31. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  34. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  35. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  36. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
@@ -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 */ }
@@ -44,6 +44,11 @@
44
44
  // are shared with trailhead-search-guard.js via lib/shell-scan.js, so the two
45
45
  // guards can never drift into two divergent tokenizers again.
46
46
  //
47
+ // #180 fix: `docker`/`podman` `--env-file .env` (or `--env-file=.env`) was a
48
+ // false positive: the container RUNTIME reads that file, not the agent. Only
49
+ // that flag's value is exempted, and only for docker/podman verbs; see
50
+ // ENV_FILE_EXEMPT_VERBS below.
51
+ //
47
52
  // Deliberately best-effort on the Bash leg, like the sibling guards: not a
48
53
  // full shell parser. It does not care about `cd` shape at all (that is
49
54
  // trailhead-search-guard.js's job) — it only asks "does any token here
@@ -94,6 +99,18 @@ function isSecretPathArg(t) {
94
99
  // every tool's flag surface, just the common file-value shapes.
95
100
  const VALUE_FLAGS = new Set(['-f', '--file']);
96
101
 
102
+ // #180: `docker run --env-file .env img` / `docker compose --env-file
103
+ // .env.prod up` / `podman run --env-file=.env img` are false positives. The
104
+ // CONTAINER RUNTIME reads that file directly to populate the container's
105
+ // environment; its contents never pass through the agent, so it is not a
106
+ // secret READ by the agent (unlike a bare `cat .env`, which is). Scoped
107
+ // narrowly: only the value of `--env-file` is exempt, and only for these
108
+ // verbs; every other flag/positional in the same statement (image name,
109
+ // `-v`, trailing `cat .env`, etc.) is still scanned exactly as before.
110
+ const ENV_FILE_EXEMPT_VERBS = new Set(['docker', 'podman', 'docker-compose', 'podman-compose']);
111
+ const ENV_FILE_EXEMPT_FLAGS = new Set(['--env-file']);
112
+ const NO_EXEMPT_FLAGS = new Set();
113
+
97
114
  // The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
98
115
  // trailhead-search-guard.js. Their first positional is a PATTERN, not a file
99
116
  // operand, so it must be skipped before scanning for a secret FILE argument
@@ -119,8 +136,11 @@ function stripTrailingPunct(t) {
119
136
  // Scan one already-tokenized statement's argument tokens (verb NOT stripped)
120
137
  // for a secret path used as a plain positional, an option value
121
138
  // (`--file=.env`, `-f .env`, glued `-f.env`), or a `<` (or glued `<file`)
122
- // redirect target. Returns the offending token or null.
123
- function scanTokensForSecretPath(tokens) {
139
+ // redirect target. Returns the offending token or null. `exemptFlags` (#180)
140
+ // names flags whose value must be skipped entirely rather than scanned, in
141
+ // both the `--flag=value` and spaced `--flag value` forms.
142
+ function scanTokensForSecretPath(tokens, exemptFlags) {
143
+ const exempt = exemptFlags || NO_EXEMPT_FLAGS;
124
144
  for (let i = 0; i < tokens.length; i++) {
125
145
  const t = stripTrailingPunct(tokens[i]);
126
146
  if (!t) continue;
@@ -143,10 +163,15 @@ function scanTokensForSecretPath(tokens) {
143
163
  // `--flag=value` form.
144
164
  const eq = t.indexOf('=');
145
165
  if (eq > 0) {
166
+ const flagName = t.slice(0, eq);
167
+ if (exempt.has(flagName)) continue; // #180: value never scanned
146
168
  const val = t.slice(eq + 1);
147
169
  if (isSecretPathArg(val)) return val;
148
170
  continue;
149
171
  }
172
+ // #180: exempt flag's separate-token value is skipped entirely, not
173
+ // scanned as a plain positional on the next loop iteration.
174
+ if (exempt.has(t)) { i++; continue; }
150
175
  // `-f value` / `--file value` (separate-token value).
151
176
  if (VALUE_FLAGS.has(t)) {
152
177
  const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
@@ -214,7 +239,11 @@ function scanStatementForSecretPath(stmt) {
214
239
  if (SEARCH_VERBS.has(bin)) {
215
240
  return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
216
241
  }
217
- return scanTokensForSecretPath(tokens);
242
+ // #180: docker/podman (incl. `docker compose` as a subcommand, and the
243
+ // standalone docker-compose/podman-compose binaries) get --env-file's value
244
+ // exempted; every other verb keeps today's behaviour unchanged.
245
+ const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
246
+ return scanTokensForSecretPath(tokens, exempt);
218
247
  }
219
248
 
220
249
  // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,