@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 +6 -4
- package/bin/trailhead.test.js +110 -2
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
- package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
- package/plugins/trailhead/skills/_shared/configuration-reference.md +4 -1
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -1
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
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
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.
|
package/bin/trailhead.test.js
CHANGED
|
@@ -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
|
|
216
|
-
/[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is
|
|
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.
|
|
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.
|
|
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
|
+
}
|