@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.
package/bin/trailhead.js CHANGED
@@ -248,10 +248,12 @@ const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scan
248
248
  // require('./lib/commit-message-check.js'); trailhead-search-guard.js and
249
249
  // trailhead-secret-read-guard.js both do require('./lib/shell-scan.js'), #135
250
250
  // follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
251
- // require('./lib/gh-subcommand.js'), #153 follow-up). Copied by name, never a
252
- // recursive sweep: the Claude hooks/lib dir is shared with other plugins, so
253
- // we must not clobber a co-tenant's lib nor ship our own *.test.js files.
254
- const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js'];
251
+ // require('./lib/gh-subcommand.js'), #153 follow-up; trailhead-check-update.js
252
+ // does require('./lib/model-defaults-review.js'), which in turn reads the
253
+ // sibling model-defaults.json, #186). Copied by name, never a recursive
254
+ // sweep: the Claude hooks/lib dir is shared with other plugins, so we must
255
+ // not clobber a co-tenant's lib nor ship our own *.test.js files.
256
+ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js', 'model-defaults-review.js', 'model-defaults.json'];
255
257
  // search-guard is Claude-Code-specific (#169): excluded from both the Codex
256
258
  // hook copy and the Codex verify spec's expected hookFiles, so a healthy
257
259
  // Codex install is never flagged for a script it never ships.
@@ -52,6 +52,39 @@ function fakeBinDir(names) {
52
52
  return dir;
53
53
  }
54
54
 
55
+ // #187: the pinned-model review's documented lib path (see load-first.md) is
56
+ // relative to a skills/_shared/ dir; assert it actually resolves on every
57
+ // install layout, survives realpathSync, and its check/ack CLI round-trips.
58
+ const MODEL_DEFAULTS_LIB_CANDIDATES = [
59
+ '../../hooks/lib/model-defaults-review.js',
60
+ '../trailhead/hooks/lib/model-defaults-review.js',
61
+ ];
62
+ function assertModelDefaultsLibLayout(label, sharedDir) {
63
+ const resolved = MODEL_DEFAULTS_LIB_CANDIDATES
64
+ .map((c) => path.join(sharedDir, c))
65
+ .find((p) => fs.existsSync(p));
66
+ ok(`${label}: documented model-defaults-review.js lib path resolves`, !!resolved);
67
+ if (!resolved) return;
68
+ ok(`${label}: resolved lib path still resolves after realpathSync`,
69
+ fs.existsSync(fs.realpathSync(resolved)));
70
+
71
+ const tmpProject = mktmp();
72
+ fs.mkdirSync(path.join(tmpProject, '.trailhead'), { recursive: true });
73
+ fs.writeFileSync(path.join(tmpProject, '.trailhead', 'config.json'),
74
+ JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
75
+
76
+ const noticeOut = String(execFileSync(process.execPath, [resolved, 'check', tmpProject], { encoding: 'utf8' }));
77
+ ok(`${label}: CLI check prints a notice`, noticeOut.trim().length > 0 && noticeOut.includes('models.plan'));
78
+
79
+ execFileSync(process.execPath, [resolved, 'ack', tmpProject], { encoding: 'utf8' });
80
+ const afterAckOut = String(execFileSync(process.execPath, [resolved, 'check', tmpProject], { encoding: 'utf8' }));
81
+ ok(`${label}: CLI check prints nothing after CLI ack`, afterAckOut === '');
82
+ }
83
+
84
+ // Source/plugin layout itself (not an installed copy), so drift is caught
85
+ // even before an install is exercised.
86
+ assertModelDefaultsLibLayout('source', path.join(repoRoot, 'plugins', 'trailhead', 'skills', '_shared'));
87
+
55
88
  // --- codex install -----------------------------------------------------------
56
89
  const codexDir = mktmp();
57
90
  const codexInstallOut = String(runInstaller([`--codex`, `--dir=${codexDir}`]));
@@ -212,8 +245,8 @@ ok('source: trailhead-chart SKILL.md config-offer points at the next-step block'
212
245
  // (session-handoff.md legitimately offers /trailhead:quick <n> for the next loose ticket).
213
246
  const captureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
214
247
  ok('source: capture.md confirmation anchors the next step to /trailhead:work', /[Aa]nchor the next step to `\/trailhead:work <n>`, always/.test(captureSource));
215
- ok('source: capture.md demotes /trailhead:quick to only the no-split alternative, after the work anchor',
216
- /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is \*\*only\*\* the no-split alternative/.test(captureSource));
248
+ ok('source: capture.md offers /trailhead:quick as the lighter alternative, after the work anchor',
249
+ /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is the \*\*lighter alternative\*\*/.test(captureSource));
217
250
  ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
218
251
  ok('source: capture.md keeps /trailhead:work the anchor even for a whiteboard capture', /whiteboard[\s\S]{0,200}the anchor stays `\/trailhead:work <n>`/.test(captureSource));
219
252
 
@@ -244,6 +277,23 @@ ok('source: capture.md says a map ticket stays on its map', /a map ticket stays
244
277
  const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
245
278
  ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
246
279
 
280
+ // --- quick is the lighter path, never "just without splitting" --------------
281
+ // Source-level invariant: wherever a next-step suggestion characterizes quick,
282
+ // it names what quick is for (the lighter path: always size-triaged, offers to
283
+ // skip the heavy steps on a small change, no map book-keeping), with no-split
284
+ // as a side note. The old "only the no-split alternative" / "same work as
285
+ // work, without split" framing made agents tell users quick merely avoids
286
+ // splitting.
287
+ const dispatcherSourceForQuick = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead', 'SKILL.md'), 'utf8');
288
+ ok('source: capture.md drops the "only the no-split alternative" framing', !/only\*\* the no-split alternative/.test(captureSource));
289
+ ok('source: session-handoff.md drops the "as the no-split alternative" framing', !sessionHandoffSource.includes('as the no-split alternative'));
290
+ ok('source: dispatcher SKILL.md drops the "as the no-split alternative" framing', !dispatcherSourceForQuick.includes('as the no-split alternative'));
291
+ ok('source: session-handoff.md example drops "lo stesso lavoro di `work`, senza split"', !sessionHandoffSource.includes('lo stesso lavoro di `work`, senza split'));
292
+ ok('source: capture.md says quick offers to skip the heavy steps on a small change', /quick <n>`[\s\S]{0,400}skip the heavy steps/.test(captureSource));
293
+ ok('source: capture.md forbids describing quick as merely avoiding a split', /never as merely "without splitting"/.test(captureSource));
294
+ ok('source: session-handoff.md forbids describing quick as merely avoiding a split', /never as merely "without splitting"/.test(sessionHandoffSource));
295
+ ok('source: session-handoff.md example renders quick as the lighter path', /> - oppure `\/trailhead:quick[^\n]*percorso leggero/.test(sessionHandoffSource));
296
+
247
297
  // --- #181: scope code review and verify to the ticket's own commits ---------
248
298
  // Source-level invariant: Code review and Goal-backward verification scope to
249
299
  // the commits carrying this ticket's `Refs: #<n>` trailer (the "Ticket diff"
@@ -622,6 +672,10 @@ ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-gua
622
672
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
623
673
  ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
624
674
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
675
+ ok('codex: skills/trailhead/hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
676
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults-review.js')));
677
+ ok('codex: skills/trailhead/hooks/lib/model-defaults.json exists (#186)',
678
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults.json')));
625
679
  // #169: search-guard is Claude-Code-specific (it only prevents arming
626
680
  // Claude's Read() deny rule under bypass permissions, a mechanism Codex
627
681
  // lacks), so it must neither be registered nor copied on a Codex install.
@@ -648,6 +702,8 @@ ok('codex: trailhead-codebase-map.toml is projected with no models.codex.* set',
648
702
  ok('codex: trailhead-fix.toml is pin-less (no model = line) with no models.codex.* set',
649
703
  fs.existsSync(noPinFixTomlPath) && !/^model = /m.test(fs.readFileSync(noPinFixTomlPath, 'utf8')));
650
704
 
705
+ assertModelDefaultsLibLayout('codex copy', path.join(codexDir, 'skills', '_shared'));
706
+
651
707
  // --- codex --symlink: link verbatim artifacts (hooks + templates), keep skills projected ---
652
708
  const codexSymDir = mktmp();
653
709
  runInstaller([`--codex`, `--symlink`, `--dir=${codexSymDir}`]);
@@ -672,6 +728,8 @@ ok('codex copy: hooks are regular files (not symlinks)',
672
728
  ok('codex copy: templates is a regular dir (not a symlink)',
673
729
  !fs.lstatSync(path.join(codexDir, 'skills', 'trailhead', 'templates')).isSymbolicLink());
674
730
 
731
+ assertModelDefaultsLibLayout('codex symlink', path.join(codexSymDir, 'skills', '_shared'));
732
+
675
733
  // --- codex agent TOML projection (#38, #88) -------------------------------------
676
734
  // With NO models.codex.* set, a codex install still projects all 8 agents
677
735
  // uniformly, pin-less (uniform projection, #88), never zero. Install from an
@@ -765,6 +823,10 @@ ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require targe
765
823
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
766
824
  ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
767
825
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
826
+ ok('claude: hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
827
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults-review.js')));
828
+ ok('claude: hooks/lib/model-defaults.json exists (#186)',
829
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults.json')));
768
830
 
769
831
  // #185: the commit-msg hook sync script ships wholesale with templates/ too.
770
832
  const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
@@ -800,6 +862,8 @@ ok('claude: agent file registers its subagent name in frontmatter',
800
862
  ok('claude: agents are real files on a copy install (not symlinks)',
801
863
  !fs.lstatSync(path.join(claudeAgentsDir, 'trailhead-plan.md')).isSymbolicLink());
802
864
 
865
+ assertModelDefaultsLibLayout('claude copy', path.join(claudeDir, 'skills', '_shared'));
866
+
803
867
  // --- claude --symlink: dev install links hooks live too (not just skills/commands) ---
804
868
  const symDir = mktmp();
805
869
  runInstaller([`--claude`, `--symlink`, `--dir=${symDir}`]);
@@ -833,6 +897,8 @@ ok('claude symlink: agent symlink resolves into the package source',
833
897
  ok('claude copy: hooks/trailhead-secret-guard.js is a regular file (not a symlink)',
834
898
  !fs.lstatSync(path.join(claudeDir, 'hooks', 'trailhead-secret-guard.js')).isSymbolicLink());
835
899
 
900
+ assertModelDefaultsLibLayout('claude symlink', path.join(symDir, 'skills', '_shared'));
901
+
836
902
  // --- claude migration: reinstall over an old layout sweeps stale skill dirs ---
837
903
  // An old install may carry a pre-split monolith (skills/trailhead-monolith) or a
838
904
  // removed cluster (skills/trailhead-legacy) the current install no longer ships.
@@ -1043,6 +1109,28 @@ for (const [label, src] of [
1043
1109
  ok(`source: ${label} has no em-dash`, !src.includes('—'));
1044
1110
  }
1045
1111
 
1112
+ // --- #189: script mancante / errore node -> fallback che ispeziona la hooks dir
1113
+ // invece di dichiarare alla cieca "git non applica le regole" ------------------
1114
+ ok('source: teamwork.md fallback resolves the hooks dir via rev-parse --git-path hooks',
1115
+ teamworkSource.includes('git -C <working-root> rev-parse --git-path hooks'));
1116
+ ok('source: teamwork.md fallback classifies by the trailhead header line',
1117
+ teamworkSource.includes('// trailhead commit-msg hook (git).'));
1118
+ ok('source: teamwork.md fallback checks the executable bit (test -x)',
1119
+ teamworkSource.includes('test -x'));
1120
+ ok('source: teamwork.md fallback reads only the first 5 lines',
1121
+ teamworkSource.includes('first 5 lines'));
1122
+ ok('source: teamwork.md fallback treats a symlinked commit-msg as foreign',
1123
+ teamworkSource.includes('symlinked `commit-msg`'));
1124
+ for (const [label, src] of [
1125
+ ['trailhead-work/SKILL.md', workSkillSource],
1126
+ ['substrate-commands.md', substrateCommandsSource],
1127
+ ]) {
1128
+ ok(`source: ${label} mentions the missing-script hooks-dir fallback`,
1129
+ src.includes('rev-parse --git-path hooks') && /missing script or a node error|script is missing or node errors/.test(src));
1130
+ ok(`source: ${label} has no em-dash (#189)`, !src.includes('—'));
1131
+ }
1132
+ ok('source: teamwork.md has no em-dash (#189)', !teamworkSource.includes('—'));
1133
+
1046
1134
  // --- #182 ---------------------------------------------------------------------
1047
1135
  // A broken install fails loudly (verifyInstall names the gap and the run exits
1048
1136
  // non-zero, printing no "✓ trailhead installed"); a clean one still prints ✓.
@@ -1247,6 +1335,26 @@ for (const [label, src] of [
1247
1335
  ok(`${label} has no em-dash (#183)`, !src.includes('\u2014'));
1248
1336
  }
1249
1337
 
1338
+ // --- plan-review: "run until it converges" option at the rounds-exhausted ask ---
1339
+ const planReviewSourceConv = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques', 'plan-review.md'), 'utf8');
1340
+ const planReviewStep4 = planReviewSourceConv.split('\n').find((l) => l.startsWith('4. **Record and decide.**')) || '';
1341
+ ok('plan-review.md step 4 still offers another bounded review cycle', planReviewStep4.includes('run another review cycle'));
1342
+ ok('plan-review.md step 4 offers running until the plan converges', /\(d\) \*\*run until it converges\*\*/.test(planReviewStep4));
1343
+ ok('plan-review.md converge option stops when no blocking concern remains', /run until it converges[\s\S]{0,400}no blocking concern remains/.test(planReviewStep4));
1344
+ ok('plan-review.md converge option has a no-progress guard that returns to this checkpoint',
1345
+ /run until it converges[\s\S]{0,600}no progress[\s\S]{0,300}back to this checkpoint/.test(planReviewStep4));
1346
+ ok('plan-review.md step 4 has no em-dash', !planReviewStep4.includes('\u2014'));
1347
+
1348
+ // --- code-review: "run until it converges" option at the rounds-exhausted ask ---
1349
+ const codeReviewSourceConv = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md'), 'utf8');
1350
+ const codeReviewBoundary = codeReviewSourceConv.split('\n').find((l) => l.startsWith('**Converge on the blockers')) || '';
1351
+ ok('code-review.md boundary ask still offers another 2 review rounds', codeReviewBoundary.includes('run another 2 review rounds'));
1352
+ ok('code-review.md boundary ask offers running until it converges', /\(c\) \*\*run until it converges\*\*/.test(codeReviewBoundary));
1353
+ ok('code-review.md converge option stops when no Critical remains', /run until it converges[\s\S]{0,400}no Critical remains/.test(codeReviewBoundary));
1354
+ ok('code-review.md converge option has a no-progress guard that returns to this checkpoint',
1355
+ /run until it converges[\s\S]{0,600}no progress[\s\S]{0,300}back to this checkpoint/.test(codeReviewBoundary));
1356
+ ok('code-review.md boundary ask has no em-dash', !codeReviewBoundary.includes('\u2014'));
1357
+
1250
1358
  // --- cleanup -------------------------------------------------------------------
1251
1359
  for (const d of tmpDirs) {
1252
1360
  fs.rmSync(d, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
5
5
  "bin": {
6
6
  "trailhead": "bin/trailhead.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trailhead",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+ // model-defaults-review.js: quando un aggiornamento sposta i default dei
3
+ // tier (plugins/trailhead/hooks/lib/model-defaults.json), offre la revisione
4
+ // dei models.* / models.codex.* pinnati che sono rimasti ai vecchi default.
5
+ // check() e' di sola lettura: non scrive mai l'ack, quindi ripropone la
6
+ // notice finche' qualcuno non chiama ack(cwd) (dopo che l'utente ha risposto
7
+ // a una delle tre scelte). Non lancia mai eccezioni dagli entry point
8
+ // pubblici: ogni funzione degrada a null/[]/false in caso di dato mancante
9
+ // o malformato. Entry point condiviso anche dagli host senza hook bus (CLI
10
+ // "check"/"ack" in fondo al file).
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const DATA_FILE = path.join(__dirname, 'model-defaults.json');
17
+ const HOSTS = ['claude', 'codex'];
18
+ const TIERS = ['strong', 'standard', 'fast'];
19
+
20
+ function isPlainObject(v) {
21
+ return !!v && typeof v === 'object' && !Array.isArray(v);
22
+ }
23
+
24
+ function loadData() {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
27
+ } catch {
28
+ return { schema: 1, history: [] };
29
+ }
30
+ }
31
+
32
+ function currentEntry(data) {
33
+ const history = (data && data.history) || [];
34
+ return history[history.length - 1] || null;
35
+ }
36
+
37
+ // Confronto semver minimale: true se a < b. Ignora pre-release, tollera "v".
38
+ function semverLt(a, b) {
39
+ const clean = (v) => String(v || '').replace(/^v/, '').split('-')[0];
40
+ const pa = clean(a).split('.').map(Number);
41
+ const pb = clean(b).split('.').map(Number);
42
+ for (let i = 0; i < 3; i++) {
43
+ const x = pa[i] || 0;
44
+ const y = pb[i] || 0;
45
+ if (x !== y) return x < y;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ // Trova il tier T a cui un id e' storicamente appartenuto per un host dato:
51
+ // T's default in una entry PIU' VECCHIA della corrente, oppure elencato nei
52
+ // supersedes[host][T] di una qualsiasi entry (corrente inclusa). Se piu'
53
+ // entry corrispondono, vince quella con indice piu' alto (piu' recente).
54
+ function findTier(data, host, id, current) {
55
+ const history = data.history || [];
56
+ let best = null; // { tier, index }
57
+ history.forEach((entry, index) => {
58
+ if (entry !== current && entry[host]) {
59
+ for (const tier of TIERS) {
60
+ if (entry[host][tier] === id) {
61
+ if (!best || index > best.index) best = { tier, index };
62
+ }
63
+ }
64
+ }
65
+ if (entry.supersedes && entry.supersedes[host]) {
66
+ for (const tier of TIERS) {
67
+ const list = entry.supersedes[host][tier] || [];
68
+ if (list.includes(id)) {
69
+ if (!best || index > best.index) best = { tier, index };
70
+ }
71
+ }
72
+ }
73
+ });
74
+ return best ? best.tier : null;
75
+ }
76
+
77
+ function isSkippable(id) {
78
+ return !id || typeof id !== 'string' || id.trim() === '' || id === 'inherit session';
79
+ }
80
+
81
+ // Valuta un singolo pin (id) per un host e ritorna { from, to } se stale,
82
+ // null altrimenti (id sconosciuto o gia' allineato al default corrente).
83
+ function evaluatePin(data, host, id, current) {
84
+ if (isSkippable(id)) return null;
85
+ const tier = findTier(data, host, id, current);
86
+ if (!tier) return null;
87
+ const to = current[host][tier];
88
+ if (to === id) return null;
89
+ return { from: id, to };
90
+ }
91
+
92
+ function stalePins(config, data) {
93
+ const out = [];
94
+ const current = currentEntry(data);
95
+ if (!current) return out;
96
+ const models = (config && config.models) || {};
97
+
98
+ for (const key of Object.keys(models)) {
99
+ if (key === 'codex') continue;
100
+ const result = evaluatePin(data, 'claude', models[key], current);
101
+ if (result) out.push({ key: `models.${key}`, from: result.from, to: result.to });
102
+ }
103
+
104
+ // Un models.codex malformato (non un oggetto piano) non produce pin: come
105
+ // se fosse assente, invece di iterare i suoi indici/caratteri.
106
+ const codex = isPlainObject(models.codex) ? models.codex : {};
107
+ for (const key of Object.keys(codex)) {
108
+ const raw = codex[key];
109
+ const id = raw && typeof raw === 'object' ? raw.model : raw;
110
+ const result = evaluatePin(data, 'codex', id, current);
111
+ if (result) out.push({ key: `models.codex.${key}`, from: result.from, to: result.to });
112
+ }
113
+
114
+ return out;
115
+ }
116
+
117
+ // Ritorna una NUOVA config con i pin stale aggiornati; non muta l'input.
118
+ function applyUpdate(config, stale) {
119
+ const next = { ...config, models: { ...((config && config.models) || {}) } };
120
+ if (next.models.codex) next.models.codex = { ...next.models.codex };
121
+
122
+ for (const s of stale) {
123
+ if (s.key.startsWith('models.codex.')) {
124
+ const k = s.key.slice('models.codex.'.length);
125
+ const cur = next.models.codex[k];
126
+ next.models.codex[k] = cur && typeof cur === 'object' ? { ...cur, model: s.to } : s.to;
127
+ } else {
128
+ const k = s.key.slice('models.'.length);
129
+ next.models[k] = s.to;
130
+ }
131
+ }
132
+ return next;
133
+ }
134
+
135
+ // Unisce piu' layer di config (dal piu' generico al piu' specifico, es.
136
+ // [global, project]) in una config effettiva pura: nessun input viene
137
+ // mutato. La precedenza e' per PRESENZA della chiave, mai per verita' del
138
+ // valore: un layer piu' specifico che imposta una chiave (anche a null, ''
139
+ // o "inherit session") vince sempre, e il valore del layer sotto non
140
+ // riaffiora mai. models.codex e' unito chiave per chiave solo quando sia
141
+ // l'accumulo corrente sia il valore in arrivo sono oggetti piani; altrimenti
142
+ // il valore del layer piu' specifico rimpiazza l'intero models.codex (un
143
+ // models.codex malformato nel progetto produce quindi zero pin codex, non
144
+ // un fallback al globale). Ogni layer il cui `models` non e' un oggetto
145
+ // piano (null, array, stringa) non contribuisce, come se fosse assente.
146
+ // Ritorna { config, sources }, dove sources mappa ogni chiave vincente
147
+ // (es. "models.plan", "models.codex.plan") a { layer, file } di chi l'ha
148
+ // decisa, cosi' la notice puo' stampare il path reale.
149
+ function effectiveModels(layers) {
150
+ let models = {};
151
+ let codex; // undefined finche' nessun layer tocca models.codex
152
+ const sources = {};
153
+
154
+ for (const layer of layers || []) {
155
+ const cfgModels = layer && layer.config && isPlainObject(layer.config.models) ? layer.config.models : null;
156
+ if (!cfgModels) continue;
157
+
158
+ for (const key of Object.keys(cfgModels)) {
159
+ if (key === 'codex') continue;
160
+ models = { ...models, [key]: cfgModels[key] };
161
+ sources[`models.${key}`] = { layer: layer.layer, file: layer.file };
162
+ }
163
+
164
+ if (Object.prototype.hasOwnProperty.call(cfgModels, 'codex')) {
165
+ const incoming = cfgModels.codex;
166
+ if (isPlainObject(incoming) && isPlainObject(codex)) {
167
+ for (const k of Object.keys(incoming)) {
168
+ codex = { ...codex, [k]: incoming[k] };
169
+ sources[`models.codex.${k}`] = { layer: layer.layer, file: layer.file };
170
+ }
171
+ } else {
172
+ for (const k of Object.keys(sources)) {
173
+ if (k.startsWith('models.codex.')) delete sources[k];
174
+ }
175
+ codex = isPlainObject(incoming) ? { ...incoming } : incoming;
176
+ if (isPlainObject(codex)) {
177
+ for (const k of Object.keys(codex)) {
178
+ sources[`models.codex.${k}`] = { layer: layer.layer, file: layer.file };
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ const outModels = { ...models };
186
+ if (codex !== undefined) outModels.codex = codex;
187
+ return { config: { models: outModels }, sources };
188
+ }
189
+
190
+ function reviewOffer({ config, data, ack, sources }) {
191
+ const current = currentEntry(data);
192
+ if (!current) return null;
193
+ const stale = stalePins(config, data).map((s) => {
194
+ const source = sources && sources[s.key];
195
+ return source ? { ...s, source } : s;
196
+ });
197
+ if (!stale.length) return null;
198
+ if (ack && !semverLt(ack, current.since)) return null;
199
+ return { since: current.since, stale, notice: reviewNotice(stale, current.since) };
200
+ }
201
+
202
+ function sourceSuffix(source) {
203
+ if (!source) return ' (new default)';
204
+ const layerName = source.layer === 'global' ? 'the global config' : 'the project config';
205
+ return ` (new default; from ${layerName}, ${source.file})`;
206
+ }
207
+
208
+ function reviewNotice(stale, since) {
209
+ const lines = stale.map((s) => `- ${s.key}: ${s.from} -> ${s.to}${sourceSuffix(s.source)}`);
210
+ return [
211
+ `trailhead's default models changed in ${since}. The following pinned model(s) are older than the new default:`,
212
+ ...lines,
213
+ '',
214
+ 'Offer the user, once, without blocking their current request, three choices:',
215
+ '1) update those keys to the new defaults (edit each key in the config file it comes from, with exactly the values above; keep any codex effort unchanged),',
216
+ '2) keep them as they are (nothing to do; trailhead will not ask again for these defaults once acknowledged),',
217
+ '3) open /trailhead:config to pick per key.',
218
+ 'This offer reappears every time it is checked until acknowledged: run the ack instruction below only after the user has picked one of the three choices above.',
219
+ ].join('\n');
220
+ }
221
+
222
+ // Paragrafo con l'istruzione pronta all'uso per acquisire l'ack, appesa alla
223
+ // notice da check(). Nomina il path assoluto di questo lib e la root (la
224
+ // root del progetto trovata, oppure il cwd nel caso solo-globale), cosi'
225
+ // l'agente puo' eseguirla cosi' com'e'.
226
+ function ackInstruction(root) {
227
+ return `Once the user has answered, run: node "${__filename}" ack "${root}"`;
228
+ }
229
+
230
+ // --- I/O ---------------------------------------------------------------
231
+ const ACK_FILE = path.join('.trailhead', 'model-defaults-ack');
232
+ const CONFIG_FILE = path.join('.trailhead', 'config.json');
233
+
234
+ // Dir base della config globale: stessa risoluzione di trailhead-check-update.js.
235
+ function globalConfigDir() {
236
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
237
+ }
238
+
239
+ function globalConfigFile() {
240
+ return path.join(globalConfigDir(), 'trailhead', 'config.json');
241
+ }
242
+
243
+ // L'ack globale vive accanto al config.json globale, senza nidificare
244
+ // ".trailhead" (che e' una convenzione solo di progetto).
245
+ function globalAckFile() {
246
+ return path.join(globalConfigDir(), 'trailhead', 'model-defaults-ack');
247
+ }
248
+
249
+ // Mancante o malformato -> {}: non deve mai bloccare la review di progetto.
250
+ function loadGlobalConfig() {
251
+ try {
252
+ const raw = JSON.parse(fs.readFileSync(globalConfigFile(), 'utf8'));
253
+ return isPlainObject(raw) ? raw : {};
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+
259
+ function findProjectRoot(cwd) {
260
+ let dir = path.resolve(cwd || '.');
261
+ for (;;) {
262
+ if (fs.existsSync(path.join(dir, CONFIG_FILE))) return dir;
263
+ const parent = path.dirname(dir);
264
+ if (parent === dir) return null;
265
+ dir = parent;
266
+ }
267
+ }
268
+
269
+ function readAckFile(file) {
270
+ try {
271
+ return fs.readFileSync(file, 'utf8').trim() || null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ function writeAckFile(file, since) {
278
+ try {
279
+ fs.mkdirSync(path.dirname(file), { recursive: true });
280
+ fs.writeFileSync(`${file}.tmp`, since);
281
+ fs.renameSync(`${file}.tmp`, file);
282
+ } catch {
283
+ /* mai far fallire il chiamante per un ack non scrivibile */
284
+ }
285
+ }
286
+
287
+ function readAck(root) {
288
+ return readAckFile(path.join(root, ACK_FILE));
289
+ }
290
+
291
+ function writeAck(root, since) {
292
+ writeAckFile(path.join(root, ACK_FILE), since);
293
+ }
294
+
295
+ // Di sola lettura: non scrive mai l'ack. Ripropone la stessa notice finche'
296
+ // non arriva un ack esplicito via ack(cwd). Valuta i pin EFFETTIVI: la
297
+ // config globale unita sotto quella di progetto (progetto vince chiave per
298
+ // chiave); senza un progetto sopra cwd, valuta la sola config globale.
299
+ function check(cwd) {
300
+ try {
301
+ const resolvedCwd = path.resolve(cwd || '.');
302
+ const root = findProjectRoot(cwd);
303
+ const data = loadData();
304
+ const globalFile = globalConfigFile();
305
+ const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig() };
306
+
307
+ if (root) {
308
+ let projectConfig;
309
+ try {
310
+ projectConfig = JSON.parse(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
311
+ } catch {
312
+ return null;
313
+ }
314
+ const projectLayer = {
315
+ layer: 'project',
316
+ file: path.join(root, CONFIG_FILE),
317
+ config: isPlainObject(projectConfig) ? projectConfig : {},
318
+ };
319
+ const { config, sources } = effectiveModels([globalLayer, projectLayer]);
320
+ const ack = readAck(root);
321
+ const offer = reviewOffer({ config, data, ack, sources });
322
+ if (!offer) return null;
323
+ return `${offer.notice}\n${ackInstruction(root)}`;
324
+ }
325
+
326
+ // Nessun progetto sopra cwd: valuta la sola config globale, se esiste.
327
+ if (!fs.existsSync(globalFile)) return null;
328
+ const { config, sources } = effectiveModels([globalLayer]);
329
+ const ack = readAckFile(globalAckFile());
330
+ const offer = reviewOffer({ config, data, ack, sources });
331
+ if (!offer) return null;
332
+ return `${offer.notice}\n${ackInstruction(resolvedCwd)}`;
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ // Scrive l'ack, con il "since" corrente dei default. Va chiamata SOLO dopo
339
+ // che l'utente ha risposto a una delle tre scelte della notice. Progetto
340
+ // trovato sopra cwd -> ack di progetto; altrimenti, se esiste una config
341
+ // globale -> ack globale. True se un target e' stato trovato e la scrittura
342
+ // tentata (un fallimento silenzioso non e' riportato, comportamento
343
+ // invariato); false se nessun target esiste. Non lancia mai eccezioni.
344
+ function ack(cwd) {
345
+ try {
346
+ const root = findProjectRoot(cwd);
347
+ const data = loadData();
348
+ const current = currentEntry(data);
349
+ if (!current) return false;
350
+ if (root) {
351
+ writeAck(root, current.since);
352
+ return true;
353
+ }
354
+ if (fs.existsSync(globalConfigFile())) {
355
+ writeAckFile(globalAckFile(), current.since);
356
+ return true;
357
+ }
358
+ return false;
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ module.exports = {
365
+ loadData,
366
+ currentEntry,
367
+ stalePins,
368
+ applyUpdate,
369
+ semverLt,
370
+ effectiveModels,
371
+ reviewOffer,
372
+ reviewNotice,
373
+ findProjectRoot,
374
+ globalConfigDir,
375
+ globalConfigFile,
376
+ globalAckFile,
377
+ readAckFile,
378
+ writeAckFile,
379
+ readAck,
380
+ writeAck,
381
+ check,
382
+ ack,
383
+ };
384
+
385
+ // CLI: entry point condiviso per gli host senza hook bus.
386
+ if (require.main === module) {
387
+ const [cmd, cwdArg] = process.argv.slice(2);
388
+ try {
389
+ if (cmd === 'check') {
390
+ const notice = check(cwdArg || process.cwd());
391
+ if (notice) process.stdout.write(notice + '\n');
392
+ } else if (cmd === 'ack') {
393
+ ack(cwdArg || process.cwd());
394
+ }
395
+ } catch {
396
+ /* mai fallire da CLI */
397
+ }
398
+ process.exit(0);
399
+ }