@marcomigozzi/trailhead 0.16.0 → 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.
Files changed (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // Docs test per #323: Grilling gira su models.grill / trailhead-grill, con la sessione
3
+ // principale come orchestratore HITL. Il protocollo e' scritto una volta (grilling.md);
4
+ // i punti d'ingresso lo richiamano soltanto.
5
+ // Run: node grill-model-docs.test.js
6
+ const assert = require('assert');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const root = path.join(__dirname, '..');
11
+ const skills = path.join(root, 'plugins', 'trailhead', 'skills');
12
+ const read = (rel) => fs.readFileSync(path.join(skills, rel), 'utf8');
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ // --- Parte A: configuration-reference.md e configuration.md --------------------------
18
+ {
19
+ const ref = read('_shared/configuration-reference.md');
20
+ const cfg = read('_shared/configuration.md');
21
+ const lines = ref.split('\n');
22
+ const rowStart = (prefix) => lines.find((l) => l.startsWith(prefix)) || '';
23
+ ok('A: models.{...} key-table row lists grill', /^\| `models\.\{[^}]*\bgrill\b[^}]*\}`/.test(rowStart('| `models.{')));
24
+ ok('A: models.codex.{...} key-table row lists grill', /^\| `models\.codex\.\{[^}]*\bgrill\b[^}]*\}`/.test(rowStart('| `models.codex.{')));
25
+ ok('A: Models bullet for grill', lines.some((l) => /^- `grill`:/.test(l) && l.includes('Grilling')));
26
+ ok('A: subagent bullet maps grill to trailhead-grill and says the session keeps asking the user',
27
+ lines.some((l) => /^- `grill`/.test(l) && l.includes('trailhead-grill') && /session (keeps|still) ask/.test(l)));
28
+ const header = lines.find((l) => l.startsWith('| profile |')) || '';
29
+ ok('A: profile table has a grill column', header.includes('| grill |'));
30
+ const col = (name) => {
31
+ const idx = header.split('|').map((c) => c.trim()).indexOf('grill');
32
+ const row = lines.find((l) => l.startsWith(`| **${name}** |`)) || '';
33
+ return row.split('|').map((c) => c.trim())[idx];
34
+ };
35
+ ok('A: grill column reads High strong, Balanced strong, Low standard',
36
+ col('High') === 'strong' && col('Balanced') === 'strong' && col('Low') === 'standard');
37
+ ok('A: a profile writes no grill entry, so grill keeps following plan',
38
+ ref.includes('a profile writes **no `grill` entry**') && ref.includes('keeps following `plan`'));
39
+ ok('A: the key rows say an unset grill follows plan',
40
+ rowStart('| `models.{').includes('unset `grill` follows `plan`') && rowStart('| `models.codex.{').includes('unset `grill` follows `models.codex.plan`'));
41
+ ok('A: Models bullet says unset grill is the same model as plan', lines.some((l) => /^- `grill`:/.test(l) && l.includes('Unset, it uses the same model as `plan`')));
42
+ ok('A: the unset-default paragraph names the grill exception',
43
+ ref.includes('the one exception is `grill`: unset, it follows the resolved `models.plan`'));
44
+ ok('A: Manual path offers grill with "same as plan (Recommended)" first, leaving the key unset',
45
+ ref.includes('**same as plan (Recommended)** as the first option') && ref.includes('leaves `models.grill` unset') &&
46
+ ref.includes('leaves `models.codex.grill` unset'));
47
+ ok('A: a layer that sets plan but not grill implies grill = plan before the merge',
48
+ ref.includes('a config layer that sets `plan` but not `grill` implicitly sets `grill` to that `plan` for that layer, before the project-over-global merge'));
49
+ ok('A: existing configs without grill need no action or notice',
50
+ ref.includes('An existing config with no `grill` key grills on its `plan` model with no action and no notice'));
51
+ const advanced = lines.filter((l) => l.includes('behind an "Advanced" option') || l.includes('behind Advanced'));
52
+ ok('A: Advanced lists name grill for Claude and Codex', advanced.length === 2 && advanced.every((l) => l.includes('`grill`')));
53
+ const everything = lines.find((l) => l.startsWith('**Everything else runs in the main session')) || '';
54
+ ok('A: Everything else paragraph no longer lists grilling', everything.length > 0 && !/grilling/i.test(everything.split("That's deliberate")[0]));
55
+ ok('A: Codex sentence (unset inherits, profile or pin brings strong, equal pin runs inline)',
56
+ /unset `models\.codex\.grill` follows `models\.codex\.plan` when that is pinned/.test(ref) &&
57
+ ref.includes('else inherits the session model') && ref.includes('an equal pin (same model as the session'));
58
+ ok('A: a spawn gets the family, not the exact version', ref.includes('A dispatched grill is guaranteed the family of the resolved id, not its exact version'));
59
+ ok('A: configuration.md names the grill exception to the Balanced default', cfg.includes('except `grill`, which follows `plan`'));
60
+ ok('A: configuration.md lists grill in the subagent list', /`research`, `review`, `debug`, `verify`, `codebase-map`, `grill`/.test(cfg));
61
+ }
62
+
63
+ // --- Parte B: grilling.md "Who runs a round" e techniques.md ---------------------------
64
+ {
65
+ const grilling = read('_shared/techniques/grilling.md');
66
+ const techniques = read('_shared/techniques.md');
67
+ const at = grilling.indexOf('\n## Who runs a round');
68
+ ok('B: grilling.md has a Who runs a round section', at !== -1);
69
+ const sec = at === -1 ? '' : grilling.slice(at);
70
+ const has = (...needles) => needles.every((n) => sec.includes(n));
71
+ const gateAt = grilling.indexOf('**First, before any question: route the round.**');
72
+ const firstBullet = grilling.indexOf('\n- **Map the decision as a tree.**');
73
+ ok('B: the routing gate exists and precedes the first round bullet', gateAt !== -1 && firstBullet !== -1 && gateAt < firstBullet);
74
+ const gate = gateAt === -1 ? '' : grilling.slice(gateAt, firstBullet);
75
+ ok('B: the gate runs model-route.js grill --session with the session full model id and obeys route',
76
+ gate.includes('model-route.js grill --session <your full model id> <project root>') && gate.includes('obey its `route`'));
77
+ ok('B: the gate says where the model id comes from and when to pass unknown',
78
+ gate.includes('system prompt or environment') && gate.includes('The exact model ID is') && gate.includes('pass `unknown` only if it is truly absent'));
79
+ ok('B: the gate forbids hand decisions', gate.includes('Never decide inline versus dispatch by hand') && gate.includes('never skip the helper') && gate.includes('never read the config yourself'));
80
+ ok('B: on dispatch the session does not write the questions and relays the report',
81
+ gate.includes('you do not write the questions yourself') && gate.includes('spawn `agent` with model `alias`') && gate.includes('relay its report'));
82
+ ok('B: the gate points at Who runs a round and exempts the dispatched agent', gate.includes('**Who runs a round**') && gate.includes('If you are the `trailhead-grill` agent, skip this gate'));
83
+ ok('B: the route call, with --session-effort and unknown',
84
+ has('model-route.js grill --session', '--session-effort', 'unknown', '--no-subagents', '--host codex'));
85
+ ok('B: the helper is found with the same two-path lookup as model-defaults-review.js',
86
+ has('../../hooks/lib/', '../trailhead/hooks/lib/', 'model-defaults-review.js'));
87
+ ok('B: a context-window suffix on the session id is documented as ignored', has('a trailing `[...]` suffix such as `[1m]` is ignored'));
88
+ ok('B: the per-layer implicit grill rule', has('a config layer that sets `plan` but not `grill` implicitly sets `grill` to that `plan` for that layer, before the project-over-global merge'));
89
+ ok('B: an unset models.grill follows models.plan', has('`models.grill` unset follows `models.plan`'));
90
+ ok('B: use its agent and alias verbatim', has('use its `agent` and `alias` verbatim'));
91
+ ok('B: route inline with the suggest line', has('`route: inline`', '`suggest`', 'suggesting the user switch the session'));
92
+ ok('B: an unregistered agent runs inline', has('not registered', 'inline'));
93
+ ok('B: the report is checked with --technique grill', has('--technique grill'));
94
+ ok('B: the relay never answers on the user\'s behalf', has('relay', 'Never answer on the user\'s behalf', 'never proceed on silence'));
95
+ ok('B: re-dispatch with the full state', has('re-dispatch with the full state'));
96
+ ok('B: the orchestrator holds the review cap', has('The orchestrator caps reviews at two', 'accepted risk'));
97
+ ok('B: the apply/amend/keep menu is the orchestrator\'s', has('apply', 'amend', 'keep', 'AskUserQuestion'));
98
+ ok('B: the family-not-version note', has('the family of the resolved id, not its exact version'));
99
+ ok('B: an inline round writes no report', has('An inline round writes no report'));
100
+ ok('B: Codex semantics', has('unset `models.codex.grill` follows `models.codex.plan` when that is pinned', 'else inherits the session model', 'equal to the session model'));
101
+ ok('B: the protocol bullets above stay the agent\'s protocol', grilling.indexOf('Holistic review before the final confirmation') < at);
102
+
103
+ ok('B: grilling.md states that trailhead-grill output is always the report',
104
+ has('When it runs as `trailhead-grill`, its output is always the report', 'never asks the user live and never shows a menu'));
105
+ const agent = fs.readFileSync(path.join(root, 'plugins', 'trailhead', 'agents', 'trailhead-grill.md'), 'utf8');
106
+ ok('B: the agent follows the round bullets and its return contract, not the orchestrator section',
107
+ agent.includes('round bullets') && agent.includes('Return contract') && !agent.includes('Who runs a round'));
108
+
109
+ const dispatch = techniques.split('\n')[4] || '';
110
+ ok('B: techniques.md dispatch list maps Grilling to trailhead-grill', /Grilling → `trailhead-grill`/.test(dispatch));
111
+ ok('B: techniques.md read-only list includes trailhead-grill', /\*\*read-only\*\*[^)]*`trailhead-grill`/.test(dispatch));
112
+ const row = techniques.split('\n').find((l) => l.startsWith('| **Grilling** |')) || '';
113
+ ok('B: the Grilling index row names models.grill', row.includes('`models.grill`'));
114
+ }
115
+
116
+ // --- Parte C: i punti d'ingresso puntano a "Who runs a round" per nome -----------------
117
+ {
118
+ const POINTER = 'Who runs a round';
119
+ const lineWith = (text, needle) => text.split('\n').find((l) => l.includes(needle)) || '';
120
+ const points = (label, line) => ok(`C: ${label} points at ${POINTER} with the imperative routing wording`, line.length > 0 && line.includes(POINTER) && line.includes('grilling.md') && line.includes('first run the routing step at the top of'));
121
+ const chart = read('trailhead-chart/SKILL.md');
122
+ const standalone = chart.slice(chart.indexOf('## Standalone grill'));
123
+ points('chart SKILL.md Standalone grill', lineWith(standalone, 'Run a standalone **Grilling**'));
124
+ const charting = read('trailhead-chart/references/charting.md');
125
+ points('charting.md Mode 1 Name the destination', lineWith(charting, '1. **Name the destination.**'));
126
+ points('charting.md Mode 1 Map the frontier', lineWith(charting, '2. **Map the frontier.**'));
127
+ points('charting.md Mode 1-bis Map the frontier of the remainder', lineWith(charting, '4. **Map the frontier of the remainder**'));
128
+ points('charting.md Mode 1-bis Name the destination of the remaining stretch', lineWith(charting, '2. **Name the destination of the remaining stretch.**'));
129
+ const engines = read('trailhead-work/references/ticket-engines.md');
130
+ points('ticket-engines.md build Discuss', lineWith(engines, '1. **Discuss**: **never auto-grill.**'));
131
+ points('ticket-engines.md Plan needs_input grill option', lineWith(engines, 'needs_input` -> ask the user the `questions:`'));
132
+ ok('C: the Plan needs_input pointer is about opting to grill the planner questions',
133
+ /opts? to grill/.test(lineWith(engines, 'needs_input` -> ask the user the `questions:`')));
134
+ points('ticket-engines.md decision Converge', lineWith(engines, '2. **Converge.**'));
135
+ const work = read('trailhead-work/SKILL.md');
136
+ points('work SKILL.md quick Grill only if needed', lineWith(work, '- **Grill only if needed** (Discuss)'));
137
+ }
138
+
139
+ // --- Parte D: README e README.it.md ---------------------------------------------------
140
+ {
141
+ for (const file of ['README.md', 'README.it.md']) {
142
+ const text = fs.readFileSync(path.join(root, file), 'utf8');
143
+ const lines = text.split('\n');
144
+ const row = (prefix) => lines.find((l) => l.startsWith(prefix)) || '';
145
+ ok(`D: ${file} models.{...} row lists grill`, /^\| `models\.\{[^}]*codebase-map,grill\}`/.test(row('| `models.{')));
146
+ ok(`D: ${file} models.codex.{...} row lists grill`, /^\| `models\.codex\.\{[^}]*codebase-map,grill\}`/.test(row('| `models.codex.{')));
147
+ ok(`D: ${file} Models bullets carry a grill bullet`, lines.some((l) => /^- `grill`/.test(l) && l.includes('trailhead-grill')));
148
+ ok(`D: ${file} no longer says grilling runs on the session model`,
149
+ !/charting, grilling/.test(text));
150
+ ok(`D: ${file} says grill follows plan when unset`, lines.some((l) => /^- `grill`/.test(l) && l.includes('`plan`') && /unset|non impostat/.test(l)));
151
+ ok(`D: ${file} carries the Codex note`, text.includes('models.codex.grill') && /models\.codex\.plan/.test(lines.find((l) => l.includes('models.codex.grill') && !l.startsWith('|')) || ''));
152
+ }
153
+ }
154
+
155
+ console.log(`grill-model-docs.test.js: ${passed} checks passed`);
@@ -37,8 +37,12 @@ for (const frag of [
37
37
  'contradictions, gaps, choices that break each other or the destination',
38
38
  'by question number',
39
39
  'say so in one line and go straight to the confirmation',
40
- 'accept, amend or reject',
41
- 'reject keeps the choices as they were',
40
+ 'Every non-working combination carries its fix',
41
+ 'say why in one line and name where it goes',
42
+ 'Accepting a combination as a risk is never the proposed fix',
43
+ 'apply, amend or keep',
44
+ 'apply (the first option, recommended) applies every proposed amendment and fix',
45
+ 'keep leaves the choices as they were',
42
46
  'consciously accepted risk',
43
47
  'reopens that branch',
44
48
  'the review runs again on the new set',
@@ -60,7 +64,7 @@ const pointer = (name, text, startsWith) => {
60
64
  const l = text.split('\n').find((x) => x.startsWith(startsWith));
61
65
  ok(`${name} found`, !!l);
62
66
  ok(`${name} references the anchor`, l.includes(ANCHOR));
63
- ok(`${name} does not restate the rule`, !l.includes('accepted risk') && !l.includes('reject keeps'));
67
+ ok(`${name} does not restate the rule`, !l.includes('accepted risk') && !l.includes('keep leaves'));
64
68
  };
65
69
  pointer('chart SKILL standalone grill', read('trailhead-chart/SKILL.md'), 'Run a standalone **Grilling**');
66
70
  const engines = read('trailhead-work/references/ticket-engines.md');
@@ -69,7 +69,14 @@ function convertToCodex(md) {
69
69
  // 2. Claude's /clear -> Codex's /new.
70
70
  out = out.replace(/\/clear\b/g, '/new');
71
71
 
72
- // 3. Paths: anchored forms first, then the bare form.
72
+ // 3a. Espansione della config dir: CLAUDE_CONFIG_DIR -> CODEX_HOME per intero,
73
+ // prima dei path nudi (altrimenti resta CLAUDE_CONFIG_DIR con fallback ~/.codex).
74
+ out = out.replace(/\$\{CLAUDE_CONFIG_DIR:-(~|\$HOME)\/\.claude\}/g, '${CODEX_HOME:-$1/.codex}');
75
+ // Forme nude rimaste in prosa: $CLAUDE_CONFIG_DIR e ${CLAUDE_CONFIG_DIR} -> CODEX_HOME.
76
+ out = out.replace(/\$\{CLAUDE_CONFIG_DIR\}/g, '${CODEX_HOME}');
77
+ out = out.replace(/\$CLAUDE_CONFIG_DIR\b/g, '$CODEX_HOME');
78
+
79
+ // 3b. Paths: anchored forms first, then the bare form.
73
80
  out = out.replace(/\$HOME\/\.claude\b/g, '$HOME/.codex');
74
81
  out = out.replace(/~\/\.claude\b/g, '~/.codex');
75
82
  out = out.replace(/(?<![A-Za-z0-9_\-.\/~$])\.claude\//g, '.codex/');
@@ -101,11 +108,11 @@ Where the engine uses \`AskUserQuestion\`, pose the choice as a **plain-text num
101
108
  After presenting the numbered list, STOP and wait: the user's **next message** is the answer to this pending question, not a fresh unrelated turn. Resolve it by matching either the **number typed** (e.g. "2") or the option's **label text** back onto that option, then continue the engine exactly as it would from a native \`AskUserQuestion\` response (e.g. write the chosen config value, or proceed down the chosen branch). If the reply matches no option, ask again rather than guessing. Never silently pick a default and proceed.
102
109
 
103
110
  ## D. Subagents (native multi_agent)
104
- Codex has a subagent toolkit: the multi_agent tools (\`spawn_agent\`, \`send_input\`, \`wait_agent\`, \`resume_agent\`, \`close_agent\`), stable and on by default on supported Codex (the installer gates on the floor that guarantees them). Where the engine delegates to a subagent (research, codebase-map, review, plan, execute, debug), spawn a real Codex subagent with these tools and the technique's own protocol (each technique carries its full protocol in-prompt, so no specialised agent is needed; pass the ticket's \`Scope:\` when relevant). The Claude-only Explore-vs-general-purpose split does not apply: Codex has one subagent kind. A subagent runs as a FRESH session that receives a task prompt, not this conversation; run independent ones in parallel (e.g. the codebase-map readers) and \`wait_agent\` on them.
111
+ Codex has a subagent toolkit: the multi_agent tools (\`spawn_agent\`, \`send_input\`, \`wait_agent\`, \`resume_agent\`, \`close_agent\`), stable and on by default on supported Codex (the installer gates on the floor that guarantees them). Where the engine delegates to a subagent (research, codebase-map, review, plan, execute, debug, grill), spawn a real Codex subagent with these tools and the technique's own protocol (each technique carries its full protocol in-prompt, so no specialised agent is needed; pass the ticket's \`Scope:\` when relevant). The Claude-only Explore-vs-general-purpose split does not apply: Codex has one subagent kind. A subagent runs as a FRESH session that receives a task prompt, not this conversation; run independent ones in parallel (e.g. the codebase-map readers) and \`wait_agent\` on them.
105
112
 
106
113
  **Never surrender the session to one open-ended \`wait_agent\`; poll instead.** \`wait_agent\` blocks until the subagent returns, so a single unconditional \`wait_agent\` on a long or stalling subagent freezes the whole session with nothing to check: this is the review / plan-review hang. Collect every delegated subagent through a **bounded wait in a poll loop**, not one open-ended blocking call. Give \`wait_agent\` a short timeout (30 to 60s); on each timeout, report a one-line "still running" progress note and loop again, so the session stays responsive and can be checked or interrupted between polls. The two **review** steps, **Code review** and **Cross-AI plan review**, always run this way: backgrounding and polling them (never a blind \`wait_agent\`) is the engine's hard cross-host default, not merely the case where it matters most. They are also the longest quiet activities in the cycle (a full-diff adversarial review, or an external CLI) and the ones most often seen to hang. If your Codex build's \`wait_agent\` exposes no timeout, poll the agent's status/output at intervals instead of blocking, or run the review as a **background job** and check back on it at intervals; the rule is the same either way. When several subagents run in parallel, poll them as a set, a bounded wait cycling across the outstanding handles, reporting which are still running.
107
114
 
108
- **Per-technique agent registry (\`agent_type\` dispatch).** The installer always projects all 8 committed trailhead agents as \`~/.codex/agents/trailhead-<technique>.toml\` (one per technique) and enables \`features.multi_agent_v2\`, so Codex always exposes a per-technique agent registry. A TOML whose technique has a \`models.codex.<key>\` pin set carries that OpenAI model (plus its reasoning effort); every other TOML — an unpinned keyed technique, or the 1 keyless agent — is pin-less and its subagent inherits the session model. Detect the registry at runtime by introspecting the visible \`spawn_agent\` tool schema (GSD-style, never a config read): if its parameters expose an \`agent_type\` field, spawn each delegated activity as \`spawn_agent(agent_type="trailhead-<technique>", reasoning_effort=…)\` (\`trailhead-plan\` for the Plan step, \`trailhead-execute\` for Execute, likewise research/review/debug/verify and \`trailhead-codebase-map\` for the Codebase-map step, plus the 1 keyless \`trailhead-fix\` for the Fix step), which routes it onto that technique's pinned model when one is set, or the session model otherwise. If the \`spawn_agent\` schema exposes NO \`agent_type\` field at all (a Codex build with only base multi_agent v1), spawn with base \`spawn_agent\` and the subagents inherit the one session model. Trust the tool surface you actually see and degrade gracefully: \`multi_agent_v2\` is still under development upstream, so v1 stays a supported runtime.
115
+ **Per-technique agent registry (\`agent_type\` dispatch).** The installer always projects all 9 committed trailhead agents as \`~/.codex/agents/trailhead-<technique>.toml\` (one per technique) and enables \`features.multi_agent_v2\`, so Codex always exposes a per-technique agent registry. A TOML whose technique has a \`models.codex.<key>\` pin set carries that OpenAI model (plus its reasoning effort); every other TOML (an unpinned keyed technique, or fix with execute unset) is pin-less and its subagent inherits the session model. Detect the registry at runtime by introspecting the visible \`spawn_agent\` tool schema (GSD-style, never a config read): if its parameters expose an \`agent_type\` field, spawn each delegated activity as \`spawn_agent(agent_type="trailhead-<technique>", reasoning_effort=…)\` (\`trailhead-plan\` for the Plan step, \`trailhead-execute\` for Execute, likewise research/review/debug/verify \`trailhead-codebase-map\` for the Codebase-map step and \`trailhead-grill\` for a Grilling round, plus \`trailhead-fix\` for the Fix step, which tracks the Execute pin), which routes it onto that technique's pinned model when one is set, or the session model otherwise. An unpinned or session-equal grill runs inline in the main session (no spawn): the main session stays the HITL orchestrator and asks the user itself. If the \`spawn_agent\` schema exposes NO \`agent_type\` field at all (a Codex build with only base multi_agent v1), spawn with base \`spawn_agent\` and the subagents inherit the one session model. Trust the tool surface you actually see and degrade gracefully: \`multi_agent_v2\` is still under development upstream, so v1 stays a supported runtime.
109
116
 
110
117
  \`config.models.*\` (Claude model ids) still cannot take effect here regardless: Codex's subagent model registry is OpenAI-only, so a Claude id cannot run and those keys collapse to the session model. The honoured namespace on Codex is \`models.codex.*\` (above); the one-time models-collapse notice still applies while a stray \`models.*\` is set and \`models.codex.*\` is empty.
111
118
 
@@ -184,10 +191,11 @@ function codexTechniqueKey(name) {
184
191
  }
185
192
 
186
193
  // --- MODEL_KEYS --------------------------------------------------------------
187
- // The 7 pinnable technique keys, mirroring the models.codex.* namespace. The
188
- // other committed agent (fix) has no pin key and is always
189
- // projected pin-less (session model).
190
- const MODEL_KEYS = new Set(['plan', 'execute', 'research', 'review', 'debug', 'verify', 'codebase-map']);
194
+ // The 8 pinnable technique keys, mirroring the models.codex.* namespace. The
195
+ // other committed agent (fix) has no pin key of its own: it tracks execute.
196
+ // Riconoscimento condiviso di "inherit session" (stesso del router, hooks/lib); modulo senza dipendenze, la proiezione resta pura.
197
+ const { isInheritSession } = require('../../plugins/trailhead/hooks/lib/inherit-session.js');
198
+ const MODEL_KEYS = new Set(['plan', 'execute', 'research', 'review', 'debug', 'verify', 'codebase-map', 'grill']);
191
199
 
192
200
  // --- codexAgentToml ---------------------------------------------------------
193
201
  // Render one Codex custom-agent TOML. When `model` is empty/absent, the TOML
@@ -231,9 +239,10 @@ function codexAgentToml({ name, description, developerInstructions, model, effor
231
239
  // readAgentDefs(): every committed plugins/trailhead/agents/trailhead-*.md,
232
240
  // each { name, description, tools, body }), return the set of
233
241
  // trailhead-<technique>.toml writes the installer should make: ONE per agent,
234
- // uniformly (all 8 project, always). A keyed agent (plan/execute/research/
235
- // review/debug/verify/codebase-map) carries its models.codex.<key> pin when set, else is
236
- // pin-less; the 1 keyless agent (fix) is always pin-less.
242
+ // uniformly (all 9 project, always). A keyed agent (plan/execute/research/
243
+ // review/debug/verify/codebase-map/grill) carries its models.codex.<key> pin when set, else is
244
+ // pin-less; fix has no key and carries the models.codex.execute pin (same
245
+ // string/object/effort/inherit-session handling), so no agent is keyless.
237
246
  // Iterates agentDefs in the given order (readAgentDefs sorts by name) so the
238
247
  // write order is deterministic.
239
248
  function codexAgentTomlPlan(codexHome, codexModels, agentDefs) {
@@ -249,13 +258,19 @@ function codexAgentTomlPlan(codexHome, codexModels, agentDefs) {
249
258
 
250
259
  let model = null;
251
260
  let effort = null;
252
- if (MODEL_KEYS.has(key) && models[key] != null) {
253
- const raw = models[key];
261
+ // grill non impostato segue models.codex.plan (come model-route.js).
262
+ // fix non ha chiave propria: segue models.codex.execute (come model-route.js).
263
+ let pinKey = key === 'grill' && (models.grill == null || (typeof models.grill === 'string' && models.grill.trim() === '')) ? 'plan' : key;
264
+ if (key === 'fix') pinKey = 'execute';
265
+ if ((MODEL_KEYS.has(key) || key === 'fix') && models[pinKey] != null) {
266
+ const raw = models[pinKey];
267
+ // "inherit session" e' il default Codex documentato: nessun pin.
268
+ const isInherit = isInheritSession;
254
269
  if (typeof raw === 'string') {
255
- if (raw.trim() !== '') model = raw;
270
+ if (raw.trim() !== '' && !isInherit(raw)) model = raw;
256
271
  } else if (typeof raw === 'object') {
257
272
  const m = typeof raw.model === 'string' ? raw.model : '';
258
- if (m.trim() !== '') {
273
+ if (m.trim() !== '' && !isInherit(m)) {
259
274
  model = m;
260
275
  effort = typeof raw.effort === 'string' ? raw.effort : null;
261
276
  }
@@ -450,6 +465,7 @@ module.exports = {
450
465
  codexClusterAgentsYaml,
451
466
  codexAgentToml,
452
467
  codexAgentTomlPlan,
468
+ MODEL_KEYS,
453
469
  codexVerbSkillContent,
454
470
  codexVerbSkillAgentsYaml,
455
471
  codexVerbSkillPlan,
@@ -96,6 +96,8 @@ ok('codexSkillAdapterHeader §D pins the exact agent_type dispatch call shape',
96
96
  header.includes('spawn_agent(agent_type="trailhead-<technique>"'));
97
97
  ok('codexSkillAdapterHeader §D keeps the base spawn_agent v1 fallback path',
98
98
  header.includes('base multi_agent v1') && header.includes('inherit the one session model'));
99
+ ok('codexSkillAdapterHeader §D says all 9 committed agents, names trailhead-grill, and runs an unpinned grill inline',
100
+ header.includes('all 9 committed') && header.includes('trailhead-grill') && /unpinned or session-equal grill runs inline/.test(header));
99
101
  ok('codexSkillAdapterHeader §D warns against a single blocking wait_agent with a poll loop',
100
102
  header.includes('bounded wait') && header.includes('poll'));
101
103
  ok('codexSkillAdapterHeader §D offers a background-job fallback for a stalling review',
@@ -261,10 +263,10 @@ ok('codexAgentToml escapes a " and \\ in name/description so the TOML stays vali
261
263
  })());
262
264
 
263
265
  // --- codexAgentTomlPlan ---
264
- // 7-entry stub agentDefs mirroring the real plugins/trailhead/agents/*.md
265
- // sources: 6 keyed techniques (plan/execute/research/review/debug/codebase-map,
266
+ // 8-entry stub agentDefs mirroring the real plugins/trailhead/agents/*.md
267
+ // sources: 7 keyed techniques (plan/execute/research/review/debug/codebase-map/grill,
266
268
  // whose filenames derive from trailhead-executor -> execute and
267
- // trailhead-code-review -> review) + 1 always-keyless agent (fix).
269
+ // trailhead-code-review -> review) + fix, which tracks the execute pin.
268
270
  const STUB_AGENT_DEFS = [
269
271
  { name: 'trailhead-plan', description: 'plan desc', tools: 'Read', body: 'Plan body.' },
270
272
  { name: 'trailhead-executor', description: 'execute desc', tools: 'Read, Write', body: 'Execute body.' },
@@ -273,37 +275,76 @@ const STUB_AGENT_DEFS = [
273
275
  { name: 'trailhead-debug', description: 'debug desc', tools: 'Read', body: 'Debug body.' },
274
276
  { name: 'trailhead-fix', description: 'fix desc', tools: 'Read, Write', body: 'Fix body.' },
275
277
  { name: 'trailhead-codebase-map', description: 'map desc', tools: 'Read', body: 'Map body.' },
278
+ { name: 'trailhead-grill', description: 'grill desc', tools: 'Read', body: 'Grill body.' },
276
279
  ];
277
280
 
278
- ok('codexAgentTomlPlan: 7 agentDefs -> 7 writes with only the pinned key carrying a model', (() => {
281
+ ok('codexAgentTomlPlan: 8 agentDefs -> 8 writes with only execute (and fix, which tracks it) carrying a model', (() => {
279
282
  const plan = codexAgentTomlPlan('/c', { execute: 'gpt-5.6-terra' }, STUB_AGENT_DEFS);
280
- if (plan.writes.length !== 7) return false;
283
+ if (plan.writes.length !== 8) return false;
281
284
  const byName = Object.fromEntries(plan.writes.map((w) => [w.name, w]));
282
285
  const execute = byName['trailhead-execute'];
283
286
  if (!execute || execute.path !== '/c/agents/trailhead-execute.toml') return false;
284
287
  if (!execute.content.includes('model = "gpt-5.6-terra"')) return false;
285
- const others = plan.writes.filter((w) => w.name !== 'trailhead-execute');
288
+ const fix = byName['trailhead-fix'];
289
+ if (!fix || !fix.content.includes('model = "gpt-5.6-terra"')) return false;
290
+ const others = plan.writes.filter((w) => w.name !== 'trailhead-execute' && w.name !== 'trailhead-fix');
286
291
  return others.length === 6 && others.every((w) => !/^model = /m.test(w.content));
287
292
  })());
288
293
 
289
- ok('codexAgentTomlPlan: keyless trailhead-fix stays pin-less; trailhead-codebase-map now pins on codebase-map', (() => {
290
- const plan = codexAgentTomlPlan('/c', { fix: 'gpt-5.6-terra', 'codebase-map': 'gpt-5.6-terra' }, STUB_AGENT_DEFS);
294
+ ok('codexAgentTomlPlan: models.codex.grill pins trailhead-grill.toml; unset leaves it pin-less', (() => {
295
+ const pinned = codexAgentTomlPlan('/c', { grill: 'gpt-5.6-sol' }, STUB_AGENT_DEFS).writes.find((w) => w.name === 'trailhead-grill');
296
+ const unset = codexAgentTomlPlan('/c', {}, STUB_AGENT_DEFS).writes.find((w) => w.name === 'trailhead-grill');
297
+ return pinned && pinned.path === '/c/agents/trailhead-grill.toml' && pinned.content.includes('model = "gpt-5.6-sol"') &&
298
+ unset && !/^model = /m.test(unset.content);
299
+ })());
300
+
301
+ ok('codexAgentTomlPlan: an unset models.codex.grill follows models.codex.plan; a set grill wins', (() => {
302
+ const find = (m) => codexAgentTomlPlan('/c', m, STUB_AGENT_DEFS).writes.find((w) => w.name === 'trailhead-grill');
303
+ const follow = find({ plan: { model: 'gpt-5.6-sol', effort: 'high' } });
304
+ const own = find({ plan: 'gpt-5.6-sol', grill: 'gpt-5.6-terra' });
305
+ const none = find({ execute: 'gpt-5.6-sol' });
306
+ const plan = codexAgentTomlPlan('/c', { plan: 'gpt-5.6-sol' }, STUB_AGENT_DEFS).writes.find((w) => w.name === 'trailhead-plan');
307
+ return follow.content.includes('model = "gpt-5.6-sol"') && follow.content.includes('model_reasoning_effort = "high"') &&
308
+ own.content.includes('model = "gpt-5.6-terra"') && !/^model = /m.test(none.content) && plan.content.includes('model = "gpt-5.6-sol"');
309
+ })());
310
+
311
+ ok('codexAgentTomlPlan: a literal inherit session is pin-less for every key, and grill follows it', (() => {
312
+ const find = (m, n) => codexAgentTomlPlan('/c', m, STUB_AGENT_DEFS).writes.find((w) => w.name === n);
313
+ const pinned = (w) => /^model = /m.test(w.content);
314
+ const a = { plan: 'inherit session' };
315
+ const b = { plan: 'gpt-5.6-sol', grill: 'inherit session' };
316
+ const c = { execute: ' Inherit Session ', research: { model: 'inherit session', effort: 'high' } };
317
+ return !pinned(find(a, 'trailhead-plan')) && !pinned(find(a, 'trailhead-grill')) &&
318
+ !pinned(find(b, 'trailhead-grill')) && find(b, 'trailhead-plan').content.includes('model = "gpt-5.6-sol"') &&
319
+ !pinned(find(c, 'trailhead-execute')) && !pinned(find(c, 'trailhead-research')) &&
320
+ !find(c, 'trailhead-research').content.includes('model_reasoning_effort');
321
+ })());
322
+
323
+ ok('codexAgentTomlPlan: trailhead-fix carries the execute pin (not a fix key); trailhead-codebase-map pins on codebase-map', (() => {
324
+ const plan = codexAgentTomlPlan('/c', { execute: { model: 'gpt-5.6-sol', effort: 'high' }, fix: 'gpt-5.6-luna', 'codebase-map': 'gpt-5.6-terra' }, STUB_AGENT_DEFS);
291
325
  const byName = Object.fromEntries(plan.writes.map((w) => [w.name, w]));
292
326
  const fix = byName['trailhead-fix'];
293
327
  const map = byName['trailhead-codebase-map'];
294
328
  return fix && map &&
295
- fix.path === '/c/agents/trailhead-fix.toml' && !/^model = /m.test(fix.content) &&
329
+ fix.path === '/c/agents/trailhead-fix.toml' && fix.content.includes('model = "gpt-5.6-sol"') &&
330
+ fix.content.includes('model_reasoning_effort = "high"') && !fix.content.includes('gpt-5.6-luna') &&
296
331
  map.path === '/c/agents/trailhead-codebase-map.toml' && map.content.includes('model = "gpt-5.6-terra"');
297
332
  })());
298
333
 
299
- ok('codexAgentTomlPlan: empty models object -> 7 pin-less writes (not zero)', (() => {
334
+ ok('codexAgentTomlPlan: trailhead-fix is pin-less when execute is unset or inherit session', (() => {
335
+ const find = (m) => codexAgentTomlPlan('/c', m, STUB_AGENT_DEFS).writes.find((w) => w.name === 'trailhead-fix');
336
+ return ![{}, { plan: 'gpt-5.6-sol' }, { execute: 'inherit session' }, { execute: { model: ' Inherit Session ', effort: 'high' } }]
337
+ .some((m) => /^model = /m.test(find(m).content) || find(m).content.includes('model_reasoning_effort'));
338
+ })());
339
+
340
+ ok('codexAgentTomlPlan: empty models object -> 8 pin-less writes (not zero)', (() => {
300
341
  const plan = codexAgentTomlPlan('/c', {}, STUB_AGENT_DEFS);
301
- return plan.writes.length === 7 && plan.writes.every((w) => !/^model = /m.test(w.content));
342
+ return plan.writes.length === 8 && plan.writes.every((w) => !/^model = /m.test(w.content));
302
343
  })());
303
344
 
304
- ok('codexAgentTomlPlan: null models -> 7 pin-less writes (not zero)', (() => {
345
+ ok('codexAgentTomlPlan: null models -> 8 pin-less writes (not zero)', (() => {
305
346
  const plan = codexAgentTomlPlan('/c', null, STUB_AGENT_DEFS);
306
- return plan.writes.length === 7 && plan.writes.every((w) => !/^model = /m.test(w.content));
347
+ return plan.writes.length === 8 && plan.writes.every((w) => !/^model = /m.test(w.content));
307
348
  })());
308
349
 
309
350
  ok('codexAgentTomlPlan: object model value on the execute key carries model + model_reasoning_effort', (() => {
@@ -315,7 +356,7 @@ ok('codexAgentTomlPlan: object model value on the execute key carries model + mo
315
356
  ok('codexAgentTomlPlan: a keyed key whose object value has no model stays pin-less', (() => {
316
357
  const plan = codexAgentTomlPlan('/c', { execute: { effort: 'high' } }, STUB_AGENT_DEFS);
317
358
  const execute = plan.writes.find((w) => w.name === 'trailhead-execute');
318
- return plan.writes.length === 7 && !/^model = /m.test(execute.content) && !execute.content.includes('model_reasoning_effort');
359
+ return plan.writes.length === 8 && !/^model = /m.test(execute.content) && !execute.content.includes('model_reasoning_effort');
319
360
  })());
320
361
 
321
362
  ok('codexAgentTomlPlan: description is routed through convertToCodex (host paths rewritten)', (() => {
@@ -441,22 +482,45 @@ ok('convertToCodex keeps CLAUDE_CODE_SESSION_ID intact',
441
482
  autoRef.includes('--auto true --auto-done <n>'));
442
483
  }
443
484
 
485
+ // #319: l'espansione CLAUDE_CONFIG_DIR diventa CODEX_HOME, mai un ibrido che punta alla config di Claude.
486
+ {
487
+ const a = convertToCodex('${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/x');
488
+ const b = convertToCodex('${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/y');
489
+ ok('convertToCodex maps the ~ config-dir expansion to CODEX_HOME', a === '${CODEX_HOME:-~/.codex}/trailhead/templates/x');
490
+ ok('convertToCodex maps the $HOME config-dir expansion to CODEX_HOME', b === '${CODEX_HOME:-$HOME/.codex}/hooks/y');
491
+ ok('convertToCodex leaves no CLAUDE_CONFIG_DIR behind', !/CLAUDE_CONFIG_DIR/.test(a + b));
492
+ const c = convertToCodex('config dir = `$CLAUDE_CONFIG_DIR` or `~/.claude`; `${CLAUDE_CONFIG_DIR}/x`');
493
+ ok('convertToCodex maps the bare config-dir forms to CODEX_HOME',
494
+ c === 'config dir = `$CODEX_HOME` or `~/.codex`; `${CODEX_HOME}/x`');
495
+ ok('convertToCodex leaves a longer variable name untouched',
496
+ convertToCodex('$CLAUDE_CONFIG_DIRX') === '$CLAUDE_CONFIG_DIRX');
497
+
498
+ // Ogni .md reale proiettato non deve contenere CLAUDE_CONFIG_DIR.
499
+ const root = path.join(__dirname, '../../plugins/trailhead');
500
+ const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((e) =>
501
+ e.isDirectory() ? walk(path.join(d, e.name)) : e.name.endsWith('.md') ? [path.join(d, e.name)] : []);
502
+ const files = ['skills', 'agents', 'commands'].flatMap((n) => walk(path.join(root, n)));
503
+ const leaks = files.filter((f) => /CLAUDE_CONFIG_DIR/.test(convertToCodex(fs.readFileSync(f, 'utf8'))));
504
+ ok('real plugin .md files were found', files.length > 0);
505
+ ok('no projected plugin .md keeps CLAUDE_CONFIG_DIR: ' + leaks.join(', '), leaks.length === 0);
506
+ }
507
+
444
508
  // #248: the shared boundary blocks survive the Codex projection of every real agent.
445
509
  {
446
510
  const { parseSource } = require('../../scripts/sync-agent-blocks.js');
447
511
  const shared = parseSource(fs.readFileSync(path.join(__dirname, '../../scripts/agent-blocks.md'), 'utf8'));
448
512
  const agentsDir = path.join(__dirname, '../../plugins/trailhead/agents');
449
513
  const files = fs.readdirSync(agentsDir).filter((f) => /^trailhead-.*\.md$/.test(f));
450
- ok('projection boundaries: found the eight real agents', files.length === 8);
514
+ ok('projection boundaries: found the nine real agents', files.length === 9);
451
515
  ok('projection boundaries: source has common and implementing blocks', !!shared.common && !!shared.implementing);
452
516
  // #249: every agent's projected TOML carries exactly its family block, whole.
453
517
  const familyOf = {
454
518
  'trailhead-verify': 'reviewing', 'trailhead-code-review': 'reviewing',
455
519
  'trailhead-executor': 'implementing', 'trailhead-fix': 'implementing',
456
520
  'trailhead-debug': 'investigating', 'trailhead-research': 'investigating', 'trailhead-codebase-map': 'investigating',
457
- 'trailhead-plan': 'plan',
521
+ 'trailhead-plan': 'plan', 'trailhead-grill': 'grilling',
458
522
  };
459
- const families = ['reviewing', 'implementing', 'investigating', 'plan'];
523
+ const families = ['reviewing', 'implementing', 'investigating', 'plan', 'grilling'];
460
524
  ok('projection boundaries: source has every family block', families.every((f) => !!shared[f]));
461
525
  for (const f of files) {
462
526
  const name = f.slice(0, -3);
@@ -65,6 +65,8 @@ const HOSTS = Object.freeze({
65
65
  label: 'Claude Code',
66
66
  configDirEnv: 'CLAUDE_CONFIG_DIR',
67
67
  configDirDefault: '.claude',
68
+ // Prefisso con cui l'host invoca un verbo trailhead (vedi commandForm).
69
+ commandPrefix: '/trailhead:',
68
70
  axes: Object.freeze({
69
71
  commandSurface: 'slash-file',
70
72
  subagentToolkit: 'full',
@@ -93,6 +95,7 @@ const HOSTS = Object.freeze({
93
95
  label: 'Codex',
94
96
  configDirEnv: 'CODEX_HOME',
95
97
  configDirDefault: '.codex',
98
+ commandPrefix: '$trailhead ',
96
99
  axes: Object.freeze({
97
100
  commandSurface: 'skill',
98
101
  subagentToolkit: 'full',
@@ -168,6 +171,15 @@ function hyphenateCommand(name) {
168
171
  return String(name).trim().replace(/^\//, '').replace(/:/g, '-');
169
172
  }
170
173
 
174
+ // --- Helper: commandForm ------------------------------------------------------
175
+ // Forma di invocazione di un verbo per l'host: la scrittura da usare in ogni
176
+ // testo runtime, mai un literal `/trailhead:<verb>` hardcoded.
177
+ // commandForm('claude', 'update') -> '/trailhead:update'
178
+ // commandForm('codex', 'update') -> '$trailhead update'
179
+ function commandForm(host, verb) {
180
+ return `${resolveHost(host).commandPrefix}${verb}`;
181
+ }
182
+
171
183
  // --- Helper: emitsAgentToml ---------------------------------------------------
172
184
  function emitsAgentToml(host) {
173
185
  return resolveHost(host).emitsAgentToml;
@@ -184,7 +196,7 @@ function degradations(host) {
184
196
 
185
197
  if (descriptor.axes.subagentToolkit === 'none') {
186
198
  entries.push({
187
- from: 'subagent fan-out (research, codebase-map, review, plan, execute)',
199
+ from: 'subagent fan-out (research, codebase-map, review, plan, execute, grill)',
188
200
  to: 'inline sequential execution in the main session',
189
201
  });
190
202
  }
@@ -275,7 +287,7 @@ function updateNotice(host, cache) {
275
287
  const installed = String(cache.installed || '').trim();
276
288
  const ver = latest ? ` ${latest}` : '';
277
289
  const from = installed ? ` (you have ${installed})` : '';
278
- return `Heads up: trailhead${ver} is available${from}. Run /trailhead:update to install it.`;
290
+ return `Heads up: trailhead${ver} is available${from}. Run ${commandForm(host, 'update')} to install it.`;
279
291
  }
280
292
 
281
293
  // --- Codex version floor gate (decision #27) --------------------------------
@@ -390,6 +402,7 @@ module.exports = {
390
402
  getHost,
391
403
  configDirFor,
392
404
  hyphenateCommand,
405
+ commandForm,
393
406
  emitsAgentToml,
394
407
  degradations,
395
408
  modelsCollapseNotice,
@@ -8,6 +8,7 @@ const {
8
8
  getHost,
9
9
  configDirFor,
10
10
  hyphenateCommand,
11
+ commandForm,
11
12
  emitsAgentToml,
12
13
  degradations,
13
14
  modelsCollapseNotice,
@@ -104,6 +105,12 @@ ok('modelsCollapseNotice(codex, models set) names the set keys', (() => {
104
105
  const msg = modelsCollapseNotice('codex', { models: { plan: 'x', execute: 'y' } });
105
106
  return typeof msg === 'string' && msg.includes('plan') && msg.includes('execute') && msg.includes('Codex');
106
107
  })());
108
+ ok('modelsCollapseNotice(codex, models.grill set) names grill', (() => {
109
+ const msg = modelsCollapseNotice('codex', { models: { grill: 'claude-opus-5-5' } });
110
+ return typeof msg === 'string' && msg.includes('(grill)') && msg.includes('models.codex');
111
+ })());
112
+ ok('modelsCollapseNotice is null once models.codex.grill is set',
113
+ modelsCollapseNotice('codex', { models: { grill: 'claude-opus-5-5', codex: { grill: 'gpt-5.6-sol' } } }) === null);
107
114
  ok('modelsCollapseNotice(codex, undefined config) is null',
108
115
  modelsCollapseNotice('codex') === null);
109
116
  ok('modelsCollapseNotice(claude, models set) is null (Claude honours the keys)',
@@ -127,6 +134,23 @@ ok('modelsCollapseNotice: codex namespace with only empty/nullish values does no
127
134
  return typeof msg === 'string' && msg.includes('plan') && !msg.includes('codex)');
128
135
  })());
129
136
 
137
+ // --- commandForm ---
138
+ ok('commandForm(claude, update) is the slash form', commandForm('claude', 'update') === '/trailhead:update');
139
+ ok('commandForm(codex, update) is the dollar form', commandForm('codex', 'update') === '$trailhead update');
140
+ ok('commandForm throws on an unknown host', (() => { try { commandForm('nope', 'x'); return false; } catch { return true; } })());
141
+
142
+ // Drift guard: la lib degli hook ha la propria copia di commandForm (non puo'
143
+ // richiedere bin/ una volta installata); le due devono restare identiche.
144
+ {
145
+ const lib = require('../../plugins/trailhead/hooks/lib/model-defaults-review.js');
146
+ for (const host of ['claude', 'codex']) {
147
+ for (const verb of ['config', 'update']) {
148
+ ok(`commandForm drift: hook lib matches host-descriptor (${host}, ${verb})`,
149
+ lib.commandForm(host, verb) === commandForm(host, verb));
150
+ }
151
+ }
152
+ }
153
+
130
154
  // --- updateNotice ---
131
155
  // Both hosts now have a hook bus (hookBus !== 'none'), so updateNotice never
132
156
  // fires for either: the SessionStart hook + (on Codex) its stdout