@marcomigozzi/trailhead 0.16.1 → 0.17.0
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/README.it.md +57 -10
- package/README.md +57 -10
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/lib/codex-projection.js +22 -13
- package/bin/lib/codex-projection.test.js +58 -17
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/trailhead.js +7 -5
- package/bin/trailhead.test.js +53 -11
- package/bin/unblock-docs.test.js +56 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/mod/README.md +16 -7
- package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
- package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques.md +6 -4
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
- package/plugins/trailhead/templates/trailhead-capture.js +418 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -126,6 +126,87 @@ const EMPTY_DECISIONS = ['Intro line for the map, long enough.', '', '## Decisio
|
|
|
126
126
|
ok('remove: missing section is an error', typeof miss.error === 'string');
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// --- applyOp: remove on numbered lists ---------------------------------------
|
|
130
|
+
{
|
|
131
|
+
const rm = (body, match) => applyOp(body, { kind: 'remove', section: 'Not yet specified', match });
|
|
132
|
+
const sec = (...l) => ['Intro line for the map, long enough.', '', '## Not yet specified', ...l, ''].join('\n');
|
|
133
|
+
const repro = rm('## Not yet specified\n1. **Repo to checkout resolution**: x\n2. **Other**: y\n', 'Repo to checkout resolution');
|
|
134
|
+
ok('remove numbered: ticket repro removes the entry and renumbers', repro.changed === true && repro.body === '## Not yet specified\n1. **Other**: y\n');
|
|
135
|
+
ok('remove numbered: summary unchanged', repro.summary === 'removed 1 entry from "Not yet specified"');
|
|
136
|
+
const mid = rm(sec('1. a', '2. b', '3. c'), 'b');
|
|
137
|
+
ok('remove numbered: following items renumbered', mid.body === sec('1. a', '2. c'));
|
|
138
|
+
const last = rm(sec('1. a', '2. b'), 'b');
|
|
139
|
+
ok('remove numbered: removing the last entry renumbers nothing', last.body === sec('1. a'));
|
|
140
|
+
const start = rm(sec('3. a', '4. b', '5. c'), 'a');
|
|
141
|
+
ok('remove numbered: start number preserved', start.body === sec('3. b', '4. c'));
|
|
142
|
+
const paren = rm(sec('1) a', '2) b', '3) c'), 'a');
|
|
143
|
+
ok('remove numbered: ) delimiter handled and kept', paren.body === sec('1) b', '2) c'));
|
|
144
|
+
const cont = rm(sec('1. a', ' detail of a', '2. b', ' detail of b', '3. c'), 'detail of a');
|
|
145
|
+
ok('remove numbered: 3-space continuation removed with its entry', cont.body === sec('1. b', ' detail of b', '2. c'));
|
|
146
|
+
const nested = rm(sec('1. a', ' 1. nested', '2. b'), 'a');
|
|
147
|
+
ok('remove numbered: an indented number is a continuation, not an entry', nested.body === sec('1. b'));
|
|
148
|
+
const amb = rm(sec('1. a x', '2. b x'), 'x');
|
|
149
|
+
ok('remove numbered: more than one match is ambiguous', typeof amb.error === 'string' && /ambiguous/.test(amb.error));
|
|
150
|
+
const body = sec('1. a', '2. b');
|
|
151
|
+
const none = rm(body, 'zzz');
|
|
152
|
+
ok('remove numbered: zero matches is a no-op', !none.error && none.changed === false && none.body === body);
|
|
153
|
+
const mixed = rm(sec('- bullet', '1. a', '2. b', '3. c'), 'a');
|
|
154
|
+
ok('remove numbered: bullet before the hit untouched, run after renumbered', mixed.body === sec('- bullet', '1. b', '2. c'));
|
|
155
|
+
const stop = rm(sec('1. a', '2. b', '- bullet', '3. c'), 'a');
|
|
156
|
+
ok('remove numbered: renumbering stops at a bullet', stop.body === sec('1. b', '- bullet', '3. c'));
|
|
157
|
+
const delim = rm(sec('1. a', '2. b', '3) c', '4) d'), 'a');
|
|
158
|
+
ok('remove numbered: renumbering stops at a different delimiter', delim.body === sec('1. b', '3) c', '4) d'));
|
|
159
|
+
const dec = rm(sec('1.5 x', '2. y'), 'y');
|
|
160
|
+
ok('remove numbered: 1.5 is not an item', dec.changed === true && dec.body === sec('1.5 x'));
|
|
161
|
+
const bullet = rm(sec('- a', '- b'), 'a');
|
|
162
|
+
ok('remove bullet: unchanged behaviour in a bullet section', bullet.body === sec('- b'));
|
|
163
|
+
|
|
164
|
+
// loose lists (blank lines between items)
|
|
165
|
+
const loose = sec('1. a', '', '2. b', '', '3. c', '', '4. d');
|
|
166
|
+
ok('remove loose: first item, numbering continues and no blank under the heading', rm(loose, 'a').body === sec('1. b', '', '2. c', '', '3. d'));
|
|
167
|
+
ok('remove loose: middle item, no double blank', rm(loose, 'b').body === sec('1. a', '', '2. c', '', '3. d'));
|
|
168
|
+
ok('remove loose: last item, no double blank', rm(loose, 'd').body === sec('1. a', '', '2. b', '', '3. c'));
|
|
169
|
+
const spaced = sec('', '1. a', '', '2. b', '', '3. c');
|
|
170
|
+
ok('remove loose: first item keeps the blank after the heading', rm(spaced, 'a').body === sec('', '1. b', '', '2. c'));
|
|
171
|
+
const half = rm(sec('1. a', '2. b', '', '3. c', '4. d'), 'a');
|
|
172
|
+
ok('remove loose: a run split by a blank line is renumbered across it', half.body === sec('1. b', '', '2. c', '3. d'));
|
|
173
|
+
ok('remove loose: tight neighbour before a blank keeps its spacing', rm(sec('1. a', '2. b', '', '3. c'), 'b').body === sec('1. a', '', '2. c'));
|
|
174
|
+
|
|
175
|
+
// loose items with an indented continuation paragraph
|
|
176
|
+
const para3 = '## Not yet specified\n\n1. a\n\n cont para\n\n2. b\n\n3. c';
|
|
177
|
+
ok('remove loose: item with a continuation paragraph removed whole, rest renumbered', rm(para3, 'a').body === '## Not yet specified\n\n1. b\n\n2. c');
|
|
178
|
+
ok('remove loose: matching the continuation paragraph removes the whole item', rm(para3, 'cont para').body === '## Not yet specified\n\n1. b\n\n2. c');
|
|
179
|
+
ok('remove loose: the item after a continuation paragraph is removed cleanly', rm(para3, 'b').body === '## Not yet specified\n\n1. a\n\n cont para\n\n2. c');
|
|
180
|
+
const bpara = '## Not yet specified\n\n- a\n\n cont para\n\n- b\n';
|
|
181
|
+
ok('remove loose: bullet with a continuation paragraph removed whole', rm(bpara, 'cont para').body === '## Not yet specified\n\n- b\n');
|
|
182
|
+
|
|
183
|
+
// the only item of a spaced section before the next heading
|
|
184
|
+
ok('remove loose: sole numbered item leaves a single blank before the next heading', rm('## Not yet specified\n\n1. a\n\n## Next', 'a').body === '## Not yet specified\n\n## Next');
|
|
185
|
+
ok('remove loose: sole bullet leaves a single blank before the next heading', rm('## Not yet specified\n\n- a\n\n## Next', 'a').body === '## Not yet specified\n\n## Next');
|
|
186
|
+
|
|
187
|
+
// CRLF
|
|
188
|
+
const crlf = rm(sec('1. a', '2. b', '3. c').replace(/\n/g, '\r\n'), 'a');
|
|
189
|
+
ok('remove numbered: CRLF preserved and renumbered', crlf.body === sec('1. b', '2. c').replace(/\n/g, '\r\n'));
|
|
190
|
+
|
|
191
|
+
// width changes
|
|
192
|
+
const wide = rm(sec('9. a', '10. b', '11. c'), 'a');
|
|
193
|
+
ok('remove numbered: renumbering across a width change', wide.body === sec('9. b', '10. c'));
|
|
194
|
+
const ten = rm(sec(...Array.from({ length: 10 }, (_, k) => `${k + 1}. item${k + 1}`)), '1. item1');
|
|
195
|
+
ok('remove numbered: a list reaching 10 items renumbered', ten.body === sec(...Array.from({ length: 9 }, (_, k) => `${k + 1}. item${k + 2}`)));
|
|
196
|
+
|
|
197
|
+
// run boundaries
|
|
198
|
+
const para = rm(sec('1. a', '2. b', '', 'A paragraph.', '3. c'), 'a');
|
|
199
|
+
ok('remove numbered: a paragraph ends the run, later numbering untouched', para.body === sec('1. b', '', 'A paragraph.', '3. c'));
|
|
200
|
+
const para2 = rm(sec('1. a', '', '2. b', '', 'A paragraph.', '', '3. c'), 'a');
|
|
201
|
+
ok('remove numbered: a paragraph after blank lines ends the run', para2.body === sec('1. b', '', 'A paragraph.', '', '3. c'));
|
|
202
|
+
|
|
203
|
+
// numbering style
|
|
204
|
+
const padded = rm(sec('09. a', '10. b', '11. c'), 'a');
|
|
205
|
+
ok('remove numbered: zero padding width kept', padded.body === sec('09. b', '10. c'));
|
|
206
|
+
const lazy = rm(sec('1. a', '1. b', '1. c'), 'a');
|
|
207
|
+
ok('remove numbered: an all-same-number list is not renumbered', lazy.body === sec('1. b', '1. c'));
|
|
208
|
+
}
|
|
209
|
+
|
|
129
210
|
// --- fake tracker ------------------------------------------------------------
|
|
130
211
|
function makeTracker(init = {}) {
|
|
131
212
|
const t = {
|
|
@@ -396,6 +477,9 @@ function writeTmp(text) {
|
|
|
396
477
|
const t3 = makeTracker();
|
|
397
478
|
const r3 = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', '#'], t3);
|
|
398
479
|
ok('remove ambiguous: exit 1, nothing written', r3.code === 1 && /ambiguous/.test(r3.out) && t3.writes.length === 0);
|
|
480
|
+
const tn = makeTracker({ body: '## Not yet specified\n1. **Repo to checkout resolution**: x\n2. **Other**: y\n' });
|
|
481
|
+
const rn = runMain(['remove', '--issue', '7', '--section', 'Not yet specified', '--match', 'Repo to checkout resolution'], tn);
|
|
482
|
+
ok('remove numbered via main: entry removed and rest renumbered', rn.code === 0 && /removed 1 entry/.test(rn.out) && tn.writes.length === 1 && tn.writes[0] === '## Not yet specified\n1. **Other**: y\n');
|
|
399
483
|
const t4 = makeTracker();
|
|
400
484
|
const r4 = runMain(['replace', '--issue', '7', '--section', 'Not yet specified', '--file', writeTmp('- only this\n')], t4);
|
|
401
485
|
ok('replace via main: summary and write', r4.code === 0 && /replaced "Not yet specified"/.test(r4.out) && t4.writes[0].includes('- only this'));
|
|
@@ -16,6 +16,7 @@ const path = require('path');
|
|
|
16
16
|
const DATA_FILE = path.join(__dirname, 'model-defaults.json');
|
|
17
17
|
const HOSTS = ['claude', 'codex'];
|
|
18
18
|
const TIERS = ['strong', 'standard', 'fast'];
|
|
19
|
+
const { isInheritSession } = require('./inherit-session.js');
|
|
19
20
|
|
|
20
21
|
// Host: la lib viene copiata byte-per-byte su Codex (sotto
|
|
21
22
|
// <codexHome>/skills/trailhead/hooks/lib), quindi l'host si deduce dal path
|
|
@@ -94,7 +95,7 @@ function findTier(data, host, id, current) {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
function isSkippable(id) {
|
|
97
|
-
return !id || typeof id !== 'string' || id.trim() === '' || id
|
|
98
|
+
return !id || typeof id !== 'string' || id.trim() === '' || isInheritSession(id);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
// Valuta un singolo pin (id) per un host e ritorna { from, to } se stale,
|
|
@@ -206,6 +207,23 @@ function effectiveModels(layers) {
|
|
|
206
207
|
return { config: { models: outModels }, sources };
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
// Un valore "impostato" per il fallback di grill: non null/undefined/stringa vuota.
|
|
211
|
+
const isSetValue = (v) => v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '');
|
|
212
|
+
|
|
213
|
+
// #323: una layer di config che imposta plan ma non grill imposta implicitamente
|
|
214
|
+
// grill = plan per quella layer, PRIMA del merge progetto-su-globale (cosi' un
|
|
215
|
+
// progetto che ha scelto i suoi modelli fa grilling sul suo plan, anche se il
|
|
216
|
+
// globale fissa un grill). Vale per models.* e per models.codex.*. Funzione
|
|
217
|
+
// pura: non muta l'input; usata solo dal routing e dalla proiezione Codex,
|
|
218
|
+
// mai dalla review dei pin (un grill implicito non e' un pin dell'utente).
|
|
219
|
+
function withImplicitGrill(models) {
|
|
220
|
+
if (!isPlainObject(models)) return models;
|
|
221
|
+
const imply = (obj) => (isSetValue(obj.plan) && !isSetValue(obj.grill) ? { ...obj, grill: obj.plan } : obj);
|
|
222
|
+
const out = imply(models);
|
|
223
|
+
if (isPlainObject(models.codex)) return { ...out, codex: imply(models.codex) };
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
209
227
|
function reviewOffer({ config, data, ack, sources, host }) {
|
|
210
228
|
const current = currentEntry(data);
|
|
211
229
|
if (!current) return null;
|
|
@@ -395,6 +413,8 @@ module.exports = {
|
|
|
395
413
|
applyUpdate,
|
|
396
414
|
semverLt,
|
|
397
415
|
effectiveModels,
|
|
416
|
+
withImplicitGrill,
|
|
417
|
+
isInheritSession,
|
|
398
418
|
reviewOffer,
|
|
399
419
|
reviewNotice,
|
|
400
420
|
detectHost,
|
|
@@ -403,6 +423,7 @@ module.exports = {
|
|
|
403
423
|
globalConfigDir,
|
|
404
424
|
globalConfigFile,
|
|
405
425
|
globalAckFile,
|
|
426
|
+
loadGlobalConfig,
|
|
406
427
|
readAckFile,
|
|
407
428
|
writeAckFile,
|
|
408
429
|
readAck,
|
|
@@ -34,6 +34,8 @@ const {
|
|
|
34
34
|
globalAckFile,
|
|
35
35
|
readAckFile,
|
|
36
36
|
writeAckFile,
|
|
37
|
+
withImplicitGrill,
|
|
38
|
+
isInheritSession,
|
|
37
39
|
} = require('./model-defaults-review.js');
|
|
38
40
|
|
|
39
41
|
const LIB_PATH = path.join(__dirname, 'model-defaults-review.js');
|
|
@@ -295,6 +297,67 @@ ok('claude-haiku-5-5 itself is not stale',
|
|
|
295
297
|
`models.codebase-map: claude-haiku-4-5-20251001 -> claude-haiku-5-5 (new default; from the project config, ${cfgPath})`));
|
|
296
298
|
}
|
|
297
299
|
|
|
300
|
+
// --- #323: i pin grill sono valutati come ogni altra chiave (caratterizzazione) ---
|
|
301
|
+
{
|
|
302
|
+
const config = { models: { grill: 'claude-opus-5', codex: { grill: { model: 'gpt-5.5-sol', effort: 'high' } } } };
|
|
303
|
+
const stale = stalePins(config, data);
|
|
304
|
+
ok('stale models.grill pin is flagged and updated to the strong default',
|
|
305
|
+
stale.some((s) => s.key === 'models.grill' && s.from === 'claude-opus-5' && s.to === 'claude-opus-5-5'));
|
|
306
|
+
const next = applyUpdate({ models: { grill: 'claude-opus-5' } }, stalePins({ models: { grill: 'claude-opus-5' } }, data));
|
|
307
|
+
ok('applyUpdate rewrites models.grill', next.models.grill === 'claude-opus-5-5');
|
|
308
|
+
const codexStale = stalePins({ models: { codex: { grill: { model: 'gpt-5.6-terra', effort: 'high' } } } }, data);
|
|
309
|
+
ok('models.codex.grill at the current default is not flagged', codexStale.length === 0);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- #323: una config senza grill (chiave nata dopo) non genera nulla per grill ---
|
|
313
|
+
{
|
|
314
|
+
const sevenOld = { plan: 'claude-opus-5-5', execute: 'claude-sonnet-5-5', research: 'claude-sonnet-5-5', review: 'claude-sonnet-5-5',
|
|
315
|
+
debug: 'claude-sonnet-5-5', verify: 'claude-sonnet-5-5', 'codebase-map': 'claude-haiku-5-5',
|
|
316
|
+
codex: { plan: { model: 'gpt-5.6-sol', effort: 'high' }, execute: 'gpt-5.6-terra' } };
|
|
317
|
+
const cfg = { models: sevenOld };
|
|
318
|
+
ok('seven old keys and no grill: no stale pin names grill', stalePins(cfg, data).every((s) => !/grill/.test(s.key)));
|
|
319
|
+
ok('seven old keys at current defaults: the review offers nothing', reviewOffer({ config: cfg, data, ack: null }) === null);
|
|
320
|
+
const stale = { models: { ...sevenOld, plan: 'claude-opus-5' } };
|
|
321
|
+
const offer = reviewOffer({ config: stale, data, ack: null });
|
|
322
|
+
ok('a stale plan pin is flagged but the notice never mentions grill', offer !== null && !offer.notice.includes('grill'));
|
|
323
|
+
ok('applyUpdate does not invent a grill key', !('grill' in applyUpdate(stale, stalePins(stale, data)).models));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
{
|
|
327
|
+
const mod = require('./inherit-session.js');
|
|
328
|
+
const src = fs.readFileSync(path.join(__dirname, 'inherit-session.js'), 'utf8');
|
|
329
|
+
ok('inherit-session.js is dependency-free (no require)', !/require\(/.test(src));
|
|
330
|
+
ok('model-defaults-review re-exports the same isInheritSession', mod.isInheritSession === isInheritSession);
|
|
331
|
+
const projSrc = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'bin', 'lib', 'codex-projection.js'), 'utf8');
|
|
332
|
+
ok('codex-projection requires the dependency-free module, not model-defaults-review',
|
|
333
|
+
projSrc.includes("hooks/lib/inherit-session.js") && !projSrc.includes('model-defaults-review'));
|
|
334
|
+
ok('isSkippable semantics: an inherit session pin is never flagged stale',
|
|
335
|
+
stalePins({ models: { plan: ' Inherit Session ' } }, data).length === 0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- #323: isInheritSession e' l'unico riconoscimento di "inherit session" ---
|
|
339
|
+
ok('isInheritSession: bare string, trimmed, any case',
|
|
340
|
+
isInheritSession('inherit session') && isInheritSession(' Inherit Session ') && isInheritSession('INHERIT SESSION'));
|
|
341
|
+
ok('isInheritSession: the model field of an object pin', isInheritSession({ model: 'inherit session', effort: 'high' }) && isInheritSession({ model: ' Inherit Session' }));
|
|
342
|
+
ok('isInheritSession: real ids and non-strings are not inherit',
|
|
343
|
+
!isInheritSession('gpt-5.6-sol') && !isInheritSession({ model: 'gpt-5.6-sol' }) && !isInheritSession(null) && !isInheritSession(undefined) && !isInheritSession({}) && !isInheritSession(3));
|
|
344
|
+
|
|
345
|
+
// --- #323: una layer che imposta plan ma non grill imposta grill = plan ---
|
|
346
|
+
{
|
|
347
|
+
const src = { plan: 'p', codex: { plan: 'cp' }, execute: 'e' };
|
|
348
|
+
const out = withImplicitGrill(src);
|
|
349
|
+
ok('withImplicitGrill: plan without grill implies grill (claude and codex)', out.grill === 'p' && out.codex.grill === 'cp');
|
|
350
|
+
ok('withImplicitGrill: does not mutate the input', !('grill' in src) && !('grill' in src.codex));
|
|
351
|
+
ok('withImplicitGrill: an explicit grill is kept', withImplicitGrill({ plan: 'p', grill: 'g' }).grill === 'g');
|
|
352
|
+
ok('withImplicitGrill: null or blank grill counts as unset', withImplicitGrill({ plan: 'p', grill: null }).grill === 'p' && withImplicitGrill({ plan: 'p', grill: ' ' }).grill === 'p');
|
|
353
|
+
ok('withImplicitGrill: no plan, no implied grill', !('grill' in withImplicitGrill({ execute: 'e' })));
|
|
354
|
+
ok('withImplicitGrill: a blank plan implies nothing', !('grill' in withImplicitGrill({ plan: '' })));
|
|
355
|
+
ok('withImplicitGrill: inherit session plan implies an inherit grill', withImplicitGrill({ plan: 'inherit session' }).grill === 'inherit session');
|
|
356
|
+
ok('withImplicitGrill: non-objects pass through', withImplicitGrill(null) === null && withImplicitGrill('x') === 'x');
|
|
357
|
+
const stale = { models: { plan: 'claude-opus-5' } };
|
|
358
|
+
ok('the pinned model review does not use the implicit grill', stalePins(stale, data).every((s) => s.key !== 'models.grill'));
|
|
359
|
+
}
|
|
360
|
+
|
|
298
361
|
// --- effectiveModels (pure merge, no mutation) --------------------------------
|
|
299
362
|
{
|
|
300
363
|
const globalModels = { plan: 'g-plan', execute: 'g-exec', codex: { plan: { model: 'g-gpt', effort: 'high' } } };
|
|
@@ -498,7 +561,7 @@ ok('stalePins: a non-object models.codex yields no codex pins (not an exception)
|
|
|
498
561
|
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codexhome-'));
|
|
499
562
|
const libDir = path.join(codexHome, 'skills', 'trailhead', 'hooks', 'lib');
|
|
500
563
|
fs.mkdirSync(libDir, { recursive: true });
|
|
501
|
-
for (const f of ['model-defaults-review.js', 'model-defaults.json']) {
|
|
564
|
+
for (const f of ['model-defaults-review.js', 'model-defaults.json', 'inherit-session.js']) {
|
|
502
565
|
fs.copyFileSync(path.join(__dirname, f), path.join(libDir, f));
|
|
503
566
|
}
|
|
504
567
|
fs.mkdirSync(path.join(codexHome, 'trailhead'), { recursive: true });
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// model-route.js: decide dove gira un'attivita' legata a una chiave models.*
|
|
3
|
+
// (inline nella sessione oppure dispatch a un subagent), per host. #323.
|
|
4
|
+
// Funzione pura route() piu' una CLI che carica la config effettiva (globale
|
|
5
|
+
// poi progetto, uniti da effectiveModels di model-defaults-review.js).
|
|
6
|
+
// Per la chiave grill, un valore non impostato segue models.plan (e su Codex
|
|
7
|
+
// models.codex.plan): non ha un default Balanced proprio. #323.
|
|
8
|
+
// E' il router di ogni step del motore (plan, execute, fix via execute, review,
|
|
9
|
+
// verify, research, debug, codebase-map, grill): il paragrafo canonico e'
|
|
10
|
+
// "Route every step through model-route.js" in skills/_shared/techniques.md. #325.
|
|
11
|
+
//
|
|
12
|
+
// CLI:
|
|
13
|
+
// node model-route.js <key> --session <id completo|unknown> [--session-effort <e>]
|
|
14
|
+
// [--host claude|codex] [--no-subagents] [<dir>]
|
|
15
|
+
// Stampa una riga JSON {"route","agent","model","alias","suggest","warning"}.
|
|
16
|
+
// Exit 2 (errore JSON su stderr, niente su stdout) se manca --session o e'
|
|
17
|
+
// vuoto, con un flag sconosciuto, un host o una chiave sconosciuti.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const mdr = require('./model-defaults-review.js');
|
|
22
|
+
const { isInheritSession } = require('./inherit-session.js');
|
|
23
|
+
|
|
24
|
+
const UNKNOWN_SESSION = 'unknown'; // valore riservato: non e' mai uguale a un id
|
|
25
|
+
const HOSTS = ['claude', 'codex'];
|
|
26
|
+
const ALIASES = ['opus', 'sonnet', 'haiku', 'fable'];
|
|
27
|
+
|
|
28
|
+
// Chiave -> nome dell'agent, per host. Su Codex i nomi sono quelli che la
|
|
29
|
+
// proiezione TOML emette (codexAgentTomlPlan); su Claude quelli sotto agents/.
|
|
30
|
+
const AGENT_OF = Object.freeze({
|
|
31
|
+
claude: Object.freeze({
|
|
32
|
+
plan: 'trailhead-plan',
|
|
33
|
+
execute: 'trailhead-executor',
|
|
34
|
+
research: 'trailhead-research',
|
|
35
|
+
review: 'trailhead-code-review',
|
|
36
|
+
debug: 'trailhead-debug',
|
|
37
|
+
verify: 'trailhead-verify',
|
|
38
|
+
'codebase-map': 'trailhead-codebase-map',
|
|
39
|
+
grill: 'trailhead-grill',
|
|
40
|
+
}),
|
|
41
|
+
codex: Object.freeze({
|
|
42
|
+
plan: 'trailhead-plan',
|
|
43
|
+
execute: 'trailhead-execute',
|
|
44
|
+
research: 'trailhead-research',
|
|
45
|
+
review: 'trailhead-review',
|
|
46
|
+
debug: 'trailhead-debug',
|
|
47
|
+
verify: 'trailhead-verify',
|
|
48
|
+
'codebase-map': 'trailhead-codebase-map',
|
|
49
|
+
grill: 'trailhead-grill',
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Riga Balanced della tabella dei profili (configuration-reference.md), senza
|
|
54
|
+
// grill (un grill non impostato segue plan); il test la tiene allineata.
|
|
55
|
+
const BALANCED = Object.freeze({
|
|
56
|
+
plan: 'strong',
|
|
57
|
+
execute: 'standard',
|
|
58
|
+
research: 'standard',
|
|
59
|
+
review: 'standard',
|
|
60
|
+
debug: 'standard',
|
|
61
|
+
verify: 'standard',
|
|
62
|
+
'codebase-map': 'fast',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Famiglia (alias accettato da Agent su Claude Code) dell'id, o null.
|
|
66
|
+
function familyAlias(id) {
|
|
67
|
+
if (typeof id !== 'string') return null;
|
|
68
|
+
return ALIASES.find((a) => id.toLowerCase().includes(a)) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Id dei tier dell'host dall'entry corrente di model-defaults.json.
|
|
72
|
+
function loadTierIds(host) {
|
|
73
|
+
const current = mdr.currentEntry(mdr.loadData());
|
|
74
|
+
return (current && current[host]) || {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Toglie un suffisso finale `[...]` (es. la finestra di contesto `[1m]`): lo
|
|
78
|
+
// stesso modello con o senza suffisso resta lo stesso modello.
|
|
79
|
+
const stripSuffix = (id) => (typeof id === 'string' ? id.replace(/\[[^\]]*\]\s*$/, '').trim() : id);
|
|
80
|
+
|
|
81
|
+
const isUnset = (v) => v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
|
|
82
|
+
const result = (fields) => ({ route: 'inline', agent: null, model: null, alias: null, suggest: null, warning: null, ...fields });
|
|
83
|
+
|
|
84
|
+
// Grill non impostato segue plan (valore risolto di models.plan, o il default
|
|
85
|
+
// Balanced di plan): ritorna { raw, tierKey } per la chiave data.
|
|
86
|
+
function resolveRaw(source, key) {
|
|
87
|
+
const own = source ? source[key] : undefined;
|
|
88
|
+
if (key === 'grill' && isUnset(own)) return { raw: source ? source.plan : undefined, tierKey: 'plan' };
|
|
89
|
+
return { raw: own, tierKey: key };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function routeClaude({ key, models, sessionModel, subagents, tierIds }) {
|
|
93
|
+
const { raw, tierKey } = resolveRaw(models, key);
|
|
94
|
+
if (isInheritSession(raw)) return result({});
|
|
95
|
+
const id = stripSuffix(isUnset(raw) ? (tierIds || {})[BALANCED[tierKey]] : raw);
|
|
96
|
+
if (typeof id !== 'string' || id.trim() === '') return result({ warning: 'unknown-id' });
|
|
97
|
+
const sameAsSession = sessionModel !== UNKNOWN_SESSION && id === sessionModel;
|
|
98
|
+
|
|
99
|
+
if (subagents === false) {
|
|
100
|
+
return result({ model: id, suggest: sameAsSession ? null : id });
|
|
101
|
+
}
|
|
102
|
+
if (sameAsSession) return result({ model: id });
|
|
103
|
+
const alias = familyAlias(id);
|
|
104
|
+
if (!alias) return result({ model: id, warning: 'unknown-id' });
|
|
105
|
+
return result({ route: 'dispatch', agent: AGENT_OF.claude[key], model: id, alias });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function routeCodex({ key, models, sessionModel, sessionEffort, subagents }) {
|
|
109
|
+
const codexModels = models && models.codex && typeof models.codex === 'object' && !Array.isArray(models.codex) ? models.codex : {};
|
|
110
|
+
const { raw } = resolveRaw(codexModels, key);
|
|
111
|
+
if (isUnset(raw) || isInheritSession(raw)) return result({});
|
|
112
|
+
let pin = typeof raw === 'object' ? raw : { model: raw };
|
|
113
|
+
if (typeof pin.model !== 'string' || pin.model.trim() === '') return result({});
|
|
114
|
+
pin = { ...pin, model: stripSuffix(pin.model) };
|
|
115
|
+
|
|
116
|
+
const sameModel = sessionModel !== UNKNOWN_SESSION && pin.model === sessionModel;
|
|
117
|
+
if (subagents === false) return result({ model: pin.model, suggest: sameModel ? null : pin.model });
|
|
118
|
+
// Solo il TOML proiettato onora model_reasoning_effort: a parita' di modello
|
|
119
|
+
// si resta inline solo se non c'e' un effort pinnato, o se coincide con quello di sessione.
|
|
120
|
+
if (sameModel) {
|
|
121
|
+
if (!pin.effort) return result({ model: pin.model });
|
|
122
|
+
if (sessionEffort && sessionEffort === pin.effort) return result({ model: pin.model });
|
|
123
|
+
}
|
|
124
|
+
return result({ route: 'dispatch', agent: AGENT_OF.codex[key], model: pin.model });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function route({ key, host, models, sessionModel, sessionEffort, subagents, tierIds } = {}) {
|
|
128
|
+
if (!HOSTS.includes(host)) throw new Error(`unknown host: ${host}`);
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_OF[host], key)) throw new Error(`unknown key: ${key}`);
|
|
130
|
+
const args = { key, models, sessionModel: stripSuffix(sessionModel), sessionEffort, subagents, tierIds };
|
|
131
|
+
return host === 'codex' ? routeCodex(args) : routeClaude(args);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- CLI -------------------------------------------------------------------
|
|
135
|
+
function parseArgs(argv) {
|
|
136
|
+
const opts = { key: null, session: null, sessionEffort: null, host: null, subagents: true, dir: null };
|
|
137
|
+
for (let i = 0; i < argv.length; i++) {
|
|
138
|
+
const a = argv[i];
|
|
139
|
+
if (a === '--no-subagents') {
|
|
140
|
+
opts.subagents = false;
|
|
141
|
+
} else if (a === '--session' || a === '--session-effort' || a === '--host') {
|
|
142
|
+
const v = argv[i + 1];
|
|
143
|
+
if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
|
|
144
|
+
opts[a === '--session' ? 'session' : a === '--host' ? 'host' : 'sessionEffort'] = v;
|
|
145
|
+
i++;
|
|
146
|
+
} else if (a.startsWith('--')) {
|
|
147
|
+
throw new Error(`unknown flag: ${a}`);
|
|
148
|
+
} else if (opts.key === null) {
|
|
149
|
+
opts.key = a;
|
|
150
|
+
} else if (opts.dir === null) {
|
|
151
|
+
opts.dir = a;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`unexpected argument: ${a}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!opts.key) throw new Error('a key is required');
|
|
157
|
+
if (opts.session === null || opts.session.trim() === '') throw new Error('--session <full model id|unknown> is required');
|
|
158
|
+
if (opts.host !== null && !HOSTS.includes(opts.host)) throw new Error(`unknown host: ${opts.host}`);
|
|
159
|
+
return opts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Config effettiva: globale poi progetto (il progetto vince chiave per chiave).
|
|
163
|
+
function effectiveConfig(host, dir) {
|
|
164
|
+
// Ogni layer: plan senza grill implica grill = plan, prima del merge (#323).
|
|
165
|
+
const implied = (config) => (config && config.models ? { ...config, models: mdr.withImplicitGrill(config.models) } : config);
|
|
166
|
+
const layers = [{ layer: 'global', file: mdr.globalConfigFile(host), config: implied(mdr.loadGlobalConfig(host)) }];
|
|
167
|
+
const projectRoot = mdr.findProjectRoot(dir);
|
|
168
|
+
if (projectRoot) {
|
|
169
|
+
const file = path.join(projectRoot, '.trailhead', 'config.json');
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
172
|
+
layers.push({ layer: 'project', file, config: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? implied(parsed) : {} });
|
|
173
|
+
} catch {
|
|
174
|
+
/* config di progetto illeggibile: vale come assente */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return mdr.effectiveModels(layers).config;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function main(argv, io = {}) {
|
|
181
|
+
const stdout = io.stdout || process.stdout;
|
|
182
|
+
const stderr = io.stderr || process.stderr;
|
|
183
|
+
let out;
|
|
184
|
+
try {
|
|
185
|
+
const opts = parseArgs(argv);
|
|
186
|
+
const host = opts.host || mdr.detectHost();
|
|
187
|
+
const models = effectiveConfig(host, opts.dir || process.cwd()).models;
|
|
188
|
+
out = route({
|
|
189
|
+
key: opts.key,
|
|
190
|
+
host,
|
|
191
|
+
models,
|
|
192
|
+
sessionModel: opts.session,
|
|
193
|
+
sessionEffort: opts.sessionEffort,
|
|
194
|
+
subagents: opts.subagents,
|
|
195
|
+
tierIds: loadTierIds('claude'),
|
|
196
|
+
});
|
|
197
|
+
} catch (e) {
|
|
198
|
+
stderr.write(`${JSON.stringify({ error: e.message })}\n`);
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
|
201
|
+
stdout.write(`${JSON.stringify({ route: out.route, agent: out.agent, model: out.model, alias: out.alias, suggest: out.suggest, warning: out.warning })}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
206
|
+
|
|
207
|
+
module.exports = { route, parseArgs, main, effectiveConfig, familyAlias, loadTierIds, AGENT_OF, BALANCED };
|