@marcomigozzi/trailhead 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.it.md +22 -15
  2. package/README.md +23 -16
  3. package/bin/lib/codex-projection.js +102 -60
  4. package/bin/lib/codex-projection.test.js +119 -17
  5. package/bin/trailhead.js +205 -51
  6. package/bin/trailhead.test.js +195 -15
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +8 -3
  9. package/plugins/trailhead/agents/trailhead-code-review.md +9 -0
  10. package/plugins/trailhead/agents/trailhead-codebase-map.md +9 -0
  11. package/plugins/trailhead/agents/trailhead-debug.md +9 -0
  12. package/plugins/trailhead/agents/trailhead-executor.md +9 -0
  13. package/plugins/trailhead/agents/trailhead-fix.md +9 -0
  14. package/plugins/trailhead/agents/trailhead-plan.md +9 -0
  15. package/plugins/trailhead/agents/trailhead-research.md +9 -0
  16. package/plugins/trailhead/commands/adopt.md +1 -1
  17. package/plugins/trailhead/commands/bug.md +1 -1
  18. package/plugins/trailhead/commands/config.md +1 -1
  19. package/plugins/trailhead/commands/dashboard.md +1 -1
  20. package/plugins/trailhead/commands/grill.md +1 -1
  21. package/plugins/trailhead/commands/idea.md +1 -1
  22. package/plugins/trailhead/commands/inbox.md +1 -1
  23. package/plugins/trailhead/commands/map.md +1 -1
  24. package/plugins/trailhead/commands/new.md +1 -1
  25. package/plugins/trailhead/commands/note.md +1 -1
  26. package/plugins/trailhead/commands/pause.md +1 -1
  27. package/plugins/trailhead/commands/quick.md +3 -1
  28. package/plugins/trailhead/commands/resume.md +1 -1
  29. package/plugins/trailhead/commands/seed.md +1 -1
  30. package/plugins/trailhead/commands/split.md +1 -1
  31. package/plugins/trailhead/commands/ticket.md +1 -1
  32. package/plugins/trailhead/commands/todo.md +1 -1
  33. package/plugins/trailhead/commands/update.md +1 -1
  34. package/plugins/trailhead/commands/whiteboard.md +1 -1
  35. package/plugins/trailhead/commands/work.md +1 -1
  36. package/plugins/trailhead/hooks/hooks.json +4 -0
  37. package/plugins/trailhead/hooks/trailhead-commit-guard.js +1 -1
  38. package/plugins/trailhead/hooks/trailhead-install-guard.js +176 -0
  39. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +118 -0
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +19 -4
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +41 -1
  42. package/plugins/trailhead/skills/{trailhead/references/configuration.md → _shared/configuration-reference.md} +29 -9
  43. package/plugins/trailhead/skills/_shared/configuration.md +8 -0
  44. package/plugins/trailhead/skills/_shared/multi-map.md +27 -0
  45. package/plugins/trailhead/skills/_shared/out-of-scope.md +11 -0
  46. package/plugins/trailhead/skills/_shared/principles.md +8 -0
  47. package/plugins/trailhead/skills/_shared/session-handoff.md +22 -0
  48. package/plugins/trailhead/skills/_shared/substrate-commands.md +159 -0
  49. package/plugins/trailhead/skills/_shared/substrate.md +35 -0
  50. package/plugins/trailhead/skills/{trailhead/references → _shared}/teamwork.md +5 -4
  51. package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/acceptance-testing.md +3 -2
  52. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -0
  53. package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/codebase-map.md +1 -1
  54. package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/debug.md +4 -1
  55. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +21 -0
  56. package/plugins/trailhead/skills/_shared/techniques/prototype.md +39 -0
  57. package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/research.md +2 -1
  58. package/plugins/trailhead/skills/_shared/techniques.md +20 -0
  59. package/plugins/trailhead/skills/_shared/ticket-anatomy.md +32 -0
  60. package/plugins/trailhead/skills/_shared/ticket-language.md +3 -0
  61. package/plugins/trailhead/skills/trailhead/SKILL.md +26 -453
  62. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +29 -0
  63. package/plugins/trailhead/skills/{trailhead → trailhead-capture}/references/capture.md +4 -4
  64. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +52 -0
  65. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +24 -0
  66. package/plugins/trailhead/skills/{trailhead → trailhead-chart}/references/inbox.md +6 -6
  67. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +30 -0
  68. package/plugins/trailhead/skills/{trailhead → trailhead-manage}/references/updating.md +12 -0
  69. package/plugins/trailhead/skills/trailhead-view/SKILL.md +113 -0
  70. package/plugins/trailhead/skills/trailhead-work/SKILL.md +66 -0
  71. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +52 -0
  72. package/plugins/trailhead/skills/trailhead/references/techniques/code-review.md +0 -14
  73. package/plugins/trailhead/skills/trailhead/references/techniques/plan-review.md +0 -24
  74. package/plugins/trailhead/skills/trailhead/references/techniques/prototype.md +0 -18
  75. /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/domain-vocabulary.md +0 -0
  76. /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/grilling.md +0 -0
  77. /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/tdd.md +0 -0
@@ -81,8 +81,22 @@ ok('codexSkillAdapterHeader §D pins the exact agent_type dispatch call shape',
81
81
  header.includes('spawn_agent(agent_type="trailhead-<technique>"'));
82
82
  ok('codexSkillAdapterHeader §D keeps the base spawn_agent v1 fallback path',
83
83
  header.includes('base multi_agent v1') && header.includes('inherit the one session model'));
84
+ ok('codexSkillAdapterHeader §D warns against a single blocking wait_agent with a poll loop',
85
+ header.includes('bounded wait') && header.includes('poll'));
86
+ ok('codexSkillAdapterHeader §D offers a background-job fallback for a stalling review',
87
+ header.includes('background job'));
88
+ ok('codexSkillAdapterHeader §D makes background+poll the hard cross-host default for reviews',
89
+ header.includes('always run this way') && header.includes('hard cross-host default'));
84
90
  ok('codexSkillAdapterHeader §F mentions real Codex hooks', header.includes('real Codex hooks'));
85
91
  ok('codexSkillAdapterHeader §F no longer says Codex has no hook bus', !header.includes('Codex has no hook bus'));
92
+ ok('codexSkillAdapterHeader §H maps plan-review external-CLI Bash timeout/background onto Codex shell',
93
+ header.includes('## H.') && header.includes('Cross-AI plan review') && header.includes('run_in_background') && header.includes('shell/exec'));
94
+ ok('codexSkillAdapterHeader §H distinguishes the external-CLI shell path from the §D subagent path',
95
+ header.includes('external-CLI') && header.includes('not a spawned Codex subagent'));
96
+ ok('codexSkillAdapterHeader §H makes backgrounding reviewers the default',
97
+ header.includes('the default is to background the reviewers'));
98
+ ok('codexSkillAdapterHeader §H closes reviewer stdin to avoid the EOF hang',
99
+ header.includes('/dev/null') && header.toLowerCase().includes('stdin'));
86
100
 
87
101
  // --- codexVerbSkillContent (#46) ---
88
102
  ok('codexVerbSkillContent: SKILL.md frontmatter is at byte 0 with name+description', (() => {
@@ -129,11 +143,13 @@ ok('codexVerbSkillPlan: null verbs yields nothing', codexVerbSkillPlan('/c', nul
129
143
 
130
144
  // --- codexHookEntries ---
131
145
  const entries = codexHookEntries('/h');
132
- ok('codexHookEntries returns 4 entries', Array.isArray(entries) && entries.length === 4);
146
+ ok('codexHookEntries returns 5 entries', Array.isArray(entries) && entries.length === 5);
133
147
  ok('codexHookEntries: commit-guard is PreToolUse/Bash', entries.some((e) =>
134
148
  e.event === 'PreToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-commit-guard.js') && e.command.includes('/h')));
135
149
  ok('codexHookEntries: secret-guard is PreToolUse/Bash', entries.some((e) =>
136
150
  e.event === 'PreToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-secret-guard.js')));
151
+ ok('codexHookEntries: install-guard is PreToolUse/Bash', entries.some((e) =>
152
+ e.event === 'PreToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-install-guard.js')));
137
153
  ok('codexHookEntries: injection-scanner is PostToolUse/Bash', entries.some((e) =>
138
154
  e.event === 'PostToolUse' && e.matcher === 'Bash' && e.command.includes('trailhead-issue-injection-scanner.js')));
139
155
  ok('codexHookEntries: check-update is SessionStart', entries.some((e) =>
@@ -184,24 +200,110 @@ ok('codexAgentToml without effort omits model_reasoning_effort', (() => {
184
200
  });
185
201
  return out.includes('model = "gpt-5.6-terra"') && !out.includes('model_reasoning_effort');
186
202
  })());
203
+ ok('codexAgentToml with no model emits a pin-less TOML (no model line, no effort line, keeps the rest)', (() => {
204
+ const out = codexAgentToml({
205
+ name: 'trailhead-fix',
206
+ description: 'desc',
207
+ developerInstructions: 'fix the thing',
208
+ model: null,
209
+ effort: null,
210
+ });
211
+ return out.includes('name = "trailhead-fix"') &&
212
+ out.includes('description = "desc"') &&
213
+ !/^model = /m.test(out) &&
214
+ !out.includes('model_reasoning_effort') &&
215
+ out.includes("developer_instructions = '''") &&
216
+ out.includes('fix the thing');
217
+ })());
218
+
219
+ ok('codexAgentToml escapes a " and \\ in name/description so the TOML stays valid', (() => {
220
+ const out = codexAgentToml({
221
+ name: 'trailhead-plan',
222
+ description: 'has a "quote" and a back\\slash',
223
+ developerInstructions: 'body',
224
+ model: null,
225
+ effort: null,
226
+ });
227
+ // The emitted basic string must carry escaped forms, never a raw " that
228
+ // would prematurely close the TOML string.
229
+ return out.includes('description = "has a \\"quote\\" and a back\\\\slash"');
230
+ })());
187
231
 
188
232
  // --- codexAgentTomlPlan ---
189
- ok('codexAgentTomlPlan: string value produces one write with the right path/name/model', (() => {
190
- const plan = codexAgentTomlPlan('/c', { execute: 'gpt-5.6-terra' });
191
- return plan.writes.length === 1 &&
192
- plan.writes[0].path === '/c/agents/trailhead-execute.toml' &&
193
- plan.writes[0].name === 'trailhead-execute' &&
194
- plan.writes[0].content.includes('model = "gpt-5.6-terra"');
195
- })());
196
- ok('codexAgentTomlPlan: object value includes model + effort', (() => {
197
- const plan = codexAgentTomlPlan('/c', { execute: { model: 'gpt-5.6-sol', effort: 'high' } });
198
- const c = plan.writes[0].content;
199
- return c.includes('model = "gpt-5.6-sol"') && c.includes('model_reasoning_effort = "high"');
200
- })());
201
- ok('codexAgentTomlPlan: empty object -> no writes', codexAgentTomlPlan('/c', {}).writes.length === 0);
202
- ok('codexAgentTomlPlan: null -> no writes', codexAgentTomlPlan('/c', null).writes.length === 0);
203
- ok('codexAgentTomlPlan: unknown key skipped', codexAgentTomlPlan('/c', { bogus: 'x' }).writes.length === 0);
204
- ok('codexAgentTomlPlan: object value with no model is skipped', codexAgentTomlPlan('/c', { execute: { effort: 'high' } }).writes.length === 0);
233
+ // 7-entry stub agentDefs mirroring the real plugins/trailhead/agents/*.md
234
+ // sources: 5 keyed techniques (plan/execute/research/review/debug, whose
235
+ // filenames derive from trailhead-executor -> execute and
236
+ // trailhead-code-review -> review) + 2 always-keyless agents.
237
+ const STUB_AGENT_DEFS = [
238
+ { name: 'trailhead-plan', description: 'plan desc', tools: 'Read', body: 'Plan body.' },
239
+ { name: 'trailhead-executor', description: 'execute desc', tools: 'Read, Write', body: 'Execute body.' },
240
+ { name: 'trailhead-research', description: 'research desc', tools: 'Read', body: 'Research body.' },
241
+ { name: 'trailhead-code-review', description: 'review desc', tools: 'Read', body: 'Review body.' },
242
+ { name: 'trailhead-debug', description: 'debug desc', tools: 'Read', body: 'Debug body.' },
243
+ { name: 'trailhead-fix', description: 'fix desc', tools: 'Read, Write', body: 'Fix body.' },
244
+ { name: 'trailhead-codebase-map', description: 'map desc', tools: 'Read', body: 'Map body.' },
245
+ ];
246
+
247
+ ok('codexAgentTomlPlan: 7 agentDefs -> 7 writes with only the pinned key carrying a model', (() => {
248
+ const plan = codexAgentTomlPlan('/c', { execute: 'gpt-5.6-terra' }, STUB_AGENT_DEFS);
249
+ if (plan.writes.length !== 7) return false;
250
+ const byName = Object.fromEntries(plan.writes.map((w) => [w.name, w]));
251
+ const execute = byName['trailhead-execute'];
252
+ if (!execute || execute.path !== '/c/agents/trailhead-execute.toml') return false;
253
+ if (!execute.content.includes('model = "gpt-5.6-terra"')) return false;
254
+ const others = plan.writes.filter((w) => w.name !== 'trailhead-execute');
255
+ return others.length === 6 && others.every((w) => !/^model = /m.test(w.content));
256
+ })());
257
+
258
+ ok('codexAgentTomlPlan: keyless trailhead-fix / trailhead-codebase-map are always pin-less', (() => {
259
+ const plan = codexAgentTomlPlan('/c', { fix: 'gpt-5.6-terra', 'codebase-map': 'gpt-5.6-terra' }, STUB_AGENT_DEFS);
260
+ const byName = Object.fromEntries(plan.writes.map((w) => [w.name, w]));
261
+ const fix = byName['trailhead-fix'];
262
+ const map = byName['trailhead-codebase-map'];
263
+ return fix && map &&
264
+ fix.path === '/c/agents/trailhead-fix.toml' && !/^model = /m.test(fix.content) &&
265
+ map.path === '/c/agents/trailhead-codebase-map.toml' && !/^model = /m.test(map.content);
266
+ })());
267
+
268
+ ok('codexAgentTomlPlan: empty models object -> 7 pin-less writes (not zero)', (() => {
269
+ const plan = codexAgentTomlPlan('/c', {}, STUB_AGENT_DEFS);
270
+ return plan.writes.length === 7 && plan.writes.every((w) => !/^model = /m.test(w.content));
271
+ })());
272
+
273
+ ok('codexAgentTomlPlan: null models -> 7 pin-less writes (not zero)', (() => {
274
+ const plan = codexAgentTomlPlan('/c', null, STUB_AGENT_DEFS);
275
+ return plan.writes.length === 7 && plan.writes.every((w) => !/^model = /m.test(w.content));
276
+ })());
277
+
278
+ ok('codexAgentTomlPlan: object model value on the execute key carries model + model_reasoning_effort', (() => {
279
+ const plan = codexAgentTomlPlan('/c', { execute: { model: 'gpt-5.6-sol', effort: 'high' } }, STUB_AGENT_DEFS);
280
+ const execute = plan.writes.find((w) => w.name === 'trailhead-execute');
281
+ return execute.content.includes('model = "gpt-5.6-sol"') && execute.content.includes('model_reasoning_effort = "high"');
282
+ })());
283
+
284
+ ok('codexAgentTomlPlan: a keyed key whose object value has no model stays pin-less', (() => {
285
+ const plan = codexAgentTomlPlan('/c', { execute: { effort: 'high' } }, STUB_AGENT_DEFS);
286
+ const execute = plan.writes.find((w) => w.name === 'trailhead-execute');
287
+ return plan.writes.length === 7 && !/^model = /m.test(execute.content) && !execute.content.includes('model_reasoning_effort');
288
+ })());
289
+
290
+ ok('codexAgentTomlPlan: description is routed through convertToCodex (host paths rewritten)', (() => {
291
+ const defs = [{ name: 'trailhead-plan', description: 'see ~/.claude/skills', tools: 'Read', body: 'b' }];
292
+ const plan = codexAgentTomlPlan('/c', {}, defs);
293
+ return plan.writes[0].content.includes('description = "see ~/.codex/skills"');
294
+ })());
295
+
296
+ ok('codexAgentTomlPlan: a missing description renders as an empty string, not "undefined"', (() => {
297
+ const defs = [{ name: 'trailhead-plan', tools: 'Read', body: 'b' }];
298
+ const plan = codexAgentTomlPlan('/c', {}, defs);
299
+ return plan.writes[0].content.includes('description = ""') && !plan.writes[0].content.includes('description = "undefined"');
300
+ })());
301
+
302
+ ok('codexAgentTomlPlan: empty/undefined agentDefs -> no writes (installer then leaves multi_agent_v2 off)', (() => {
303
+ return codexAgentTomlPlan('/c', {}, []).writes.length === 0 &&
304
+ codexAgentTomlPlan('/c', {}).writes.length === 0 &&
305
+ codexAgentTomlPlan('/c', {}, null).writes.length === 0;
306
+ })());
205
307
 
206
308
  // --- enableCodexMultiAgentV2Feature ---
207
309
  ok('enableCodexMultiAgentV2Feature: dotted form already true -> null',
package/bin/trailhead.js CHANGED
@@ -23,10 +23,41 @@ const readline = require('readline');
23
23
  const { spawnSync } = require('child_process');
24
24
 
25
25
  const { getHost, configDirFor, codexVersionGate } = require('./lib/host-descriptor.js');
26
- const { codexLayout, convertToCodex, injectCodexAdapterHeader, codexAgentsYaml, codexHookEntries, enableCodexHooksFeature, codexAgentTomlPlan, codexVerbSkillPlan, enableCodexMultiAgentV2Feature } = require('./lib/codex-projection.js');
26
+ const { codexLayout, convertToCodex, injectCodexAdapterHeader, codexAgentsYaml, codexClusterAgentsYaml, codexHookEntries, enableCodexHooksFeature, codexAgentTomlPlan, codexVerbSkillPlan, enableCodexMultiAgentV2Feature } = require('./lib/codex-projection.js');
27
27
 
28
28
  const PKG = path.resolve(__dirname, '..');
29
29
  const SRC = path.join(PKG, 'plugins', 'trailhead');
30
+
31
+ // Enumerate the engine skill dirs shipped under plugins/trailhead/skills: the
32
+ // dispatcher, the cluster skills, and the shared core. A subdir qualifies when
33
+ // it is `_shared` (the shared core, no SKILL.md) or contains a SKILL.md, so a
34
+ // stray empty dir is skipped and a new cluster is picked up automatically.
35
+ function engineSkillDirs() {
36
+ const skillsRoot = path.join(SRC, 'skills');
37
+ if (!fs.existsSync(skillsRoot)) return [];
38
+ return fs.readdirSync(skillsRoot, { withFileTypes: true })
39
+ .filter((e) => e.isDirectory())
40
+ .map((e) => e.name)
41
+ .filter((name) => name === '_shared' || fs.existsSync(path.join(skillsRoot, name, 'SKILL.md')))
42
+ .sort();
43
+ }
44
+
45
+ // Sweep every trailhead-owned skill dir under a skills root, by name: the
46
+ // dispatcher (`trailhead`), the shared core (`_shared`), and every
47
+ // `trailhead-<cluster|verb>` sibling. Clears a prior layout's stale dirs (an
48
+ // old monolith fallback, a removed cluster, a renamed verb skill) the current
49
+ // install no longer ships, so a re-install or /trailhead:update never leaves a
50
+ // dangling skill. The skills root is shared with other plugins, so only
51
+ // trailhead's own names are touched, never the whole dir.
52
+ function sweepTrailheadSkills(skillsRoot) {
53
+ rmrf(path.join(skillsRoot, 'trailhead'));
54
+ rmrf(path.join(skillsRoot, '_shared'));
55
+ if (!fs.existsSync(skillsRoot)) return;
56
+ for (const f of fs.readdirSync(skillsRoot)) {
57
+ if (f.startsWith('trailhead-')) rmrf(path.join(skillsRoot, f));
58
+ }
59
+ }
60
+
30
61
  const args = process.argv.slice(2);
31
62
  const has = (f) => args.includes(f);
32
63
  const useSymlink = has('--symlink');
@@ -138,14 +169,14 @@ async function resolveHostName() {
138
169
  // --- Claude adapter (unchanged behaviour) -----------------------------------
139
170
  function claudePaths(configDir) {
140
171
  return {
141
- skill: path.join(configDir, 'skills', 'trailhead'),
142
172
  commands: path.join(configDir, 'commands', 'trailhead'),
143
173
  hooksDir: path.join(configDir, 'hooks'),
144
174
  templates: path.join(configDir, 'trailhead', 'templates'),
175
+ agents: path.join(configDir, 'agents'),
145
176
  settings: path.join(configDir, 'settings.json'),
146
177
  };
147
178
  }
148
- const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-check-update.js'];
179
+ const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-check-update.js'];
149
180
  // Runtime libs the hook scripts require (trailhead-commit-guard.js does
150
181
  // require('./lib/commit-message-check.js')). Copied by name, never a recursive
151
182
  // sweep: the Claude hooks/lib dir is shared with other plugins, so we must not
@@ -155,18 +186,28 @@ const HOOK_LIB_FILES = ['commit-message-check.js'];
155
186
  // Copy the hook scripts and their runtime libs into a destination hooks dir
156
187
  // (Claude's ~/.claude/hooks or Codex's skills/trailhead/hooks). Shared by both
157
188
  // install paths so the lib copy can never drift from the script copy again.
158
- function copyHookScripts(destHooksDir) {
189
+ // Place the allow-listed hook scripts into destHooksDir. Normal install copies
190
+ // (+chmod so the exec bit is set regardless of the source mode); a --symlink dev
191
+ // install links each file per-file into the package source so hook edits are
192
+ // live too, matching skills/commands. Never symlink the whole hooks/ dir: it is
193
+ // shared with other plugins, so only the curated allowlist is touched. rmrf the
194
+ // dest first so a reinstall over an existing file or symlink never hits EEXIST.
195
+ function copyHookScripts(destHooksDir, { useSymlink = false } = {}) {
159
196
  ensure(destHooksDir);
160
- for (const f of HOOK_FILES) {
161
- fs.copyFileSync(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
162
- fs.chmodSync(path.join(destHooksDir, f), 0o755);
163
- }
197
+ const put = (src, dest) => {
198
+ rmrf(dest);
199
+ if (useSymlink) {
200
+ fs.symlinkSync(src, dest, 'file');
201
+ } else {
202
+ fs.copyFileSync(src, dest);
203
+ fs.chmodSync(dest, 0o755);
204
+ }
205
+ };
206
+ for (const f of HOOK_FILES) put(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
164
207
  if (HOOK_LIB_FILES.length) {
165
208
  const libDest = path.join(destHooksDir, 'lib');
166
209
  ensure(libDest);
167
- for (const f of HOOK_LIB_FILES) {
168
- fs.copyFileSync(path.join(SRC, 'hooks', 'lib', f), path.join(libDest, f));
169
- }
210
+ for (const f of HOOK_LIB_FILES) put(path.join(SRC, 'hooks', 'lib', f), path.join(libDest, f));
170
211
  }
171
212
  }
172
213
 
@@ -177,6 +218,43 @@ function place(src, dest, symlink) {
177
218
  else fs.cpSync(src, dest, { recursive: true });
178
219
  }
179
220
 
221
+ // The committed engine agents (plugins/trailhead/agents/trailhead-*.md). On a
222
+ // Claude install they must register as user subagent types under
223
+ // <configDir>/agents/, so the engine can dispatch each technique to its
224
+ // trailhead-<technique> agent and honour config.models.<key>; without them
225
+ // every technique falls back to running inline on the session model (the
226
+ // per-technique model split silently never applies). The agents dir is shared
227
+ // with the user's own agents and other plugins, so touch trailhead's own files
228
+ // by name only, never the whole dir (mirrors the skills/hooks discipline).
229
+ function agentFileNames() {
230
+ const dir = path.join(SRC, 'agents');
231
+ if (!fs.existsSync(dir)) return [];
232
+ return fs.readdirSync(dir).filter((f) => f.startsWith('trailhead-') && f.endsWith('.md'));
233
+ }
234
+ function sweepTrailheadAgents(agentsDir) {
235
+ if (!fs.existsSync(agentsDir)) return;
236
+ for (const f of fs.readdirSync(agentsDir)) {
237
+ if (f.startsWith('trailhead-') && f.endsWith('.md')) rmrf(path.join(agentsDir, f));
238
+ }
239
+ }
240
+ // Place the engine agents into destAgentsDir by name (copy, or per-file symlink
241
+ // under a --symlink dev install so agent edits are live). Returns the names
242
+ // placed. rmrf each dest first so a reinstall over a file or symlink never
243
+ // hits EEXIST.
244
+ function copyAgentFiles(destAgentsDir, { useSymlink = false } = {}) {
245
+ const names = agentFileNames();
246
+ if (!names.length) return names;
247
+ ensure(destAgentsDir);
248
+ for (const f of names) {
249
+ const src = path.join(SRC, 'agents', f);
250
+ const dest = path.join(destAgentsDir, f);
251
+ rmrf(dest);
252
+ if (useSymlink) fs.symlinkSync(src, dest, 'file');
253
+ else fs.copyFileSync(src, dest);
254
+ }
255
+ return names;
256
+ }
257
+
180
258
  function addHook(s, event, matcher, command) {
181
259
  s.hooks = s.hooks || {};
182
260
  s.hooks[event] = s.hooks[event] || [];
@@ -195,7 +273,13 @@ function stripHook(s, event, substr) {
195
273
 
196
274
  function uninstallClaude(configDir) {
197
275
  const P = claudePaths(configDir);
198
- [P.skill, P.commands, path.join(configDir, 'trailhead')].forEach(rmrf);
276
+ // Sweep every trailhead-owned skill dir (current set + any stale dir a prior
277
+ // layout left that this package no longer ships), never the shared dir itself.
278
+ sweepTrailheadSkills(path.join(configDir, 'skills'));
279
+ [P.commands, path.join(configDir, 'trailhead')].forEach(rmrf);
280
+ // Remove trailhead's own agents by name; the agents dir is shared, so never
281
+ // rmrf the whole dir (same discipline as skills/hooks).
282
+ sweepTrailheadAgents(P.agents);
199
283
  HOOK_FILES.forEach((f) => rmrf(path.join(P.hooksDir, f)));
200
284
  // Remove only trailhead's own lib files by name: the hooks/lib dir is shared
201
285
  // with other plugins, so never rmrf the whole dir.
@@ -203,6 +287,7 @@ function uninstallClaude(configDir) {
203
287
  const s = readJSON(P.settings);
204
288
  stripHook(s, 'PreToolUse', 'trailhead-commit-guard.js');
205
289
  stripHook(s, 'PreToolUse', 'trailhead-secret-guard.js');
290
+ stripHook(s, 'PreToolUse', 'trailhead-install-guard.js');
206
291
  stripHook(s, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
207
292
  stripHook(s, 'SessionStart', 'trailhead-check-update.js');
208
293
  writeJSON(P.settings, s);
@@ -213,10 +298,29 @@ function installClaude(configDir, { useSymlink }) {
213
298
  const P = claudePaths(configDir);
214
299
  const hookCmd = (name) => `node "${path.join(P.hooksDir, name)}"`;
215
300
 
216
- place(path.join(SRC, 'skills', 'trailhead'), P.skill, useSymlink);
301
+ // Place each engine skill dir as a sibling under configDir/skills so the
302
+ // ../_shared/ and ../trailhead-<cluster>/ relative refs resolve. The skills
303
+ // dir is shared with other plugins: place by name, never touch the whole dir.
304
+ // Migration: sweep any stale trailhead-owned skill dirs from a prior layout
305
+ // (an old monolith, a removed cluster) before laying the current set down, so
306
+ // an update over an old install leaves no dangling skill. The skills dir is
307
+ // shared with other plugins: place by name, never touch the whole dir.
308
+ const skillDirs = engineSkillDirs();
309
+ sweepTrailheadSkills(path.join(configDir, 'skills'));
310
+ for (const name of skillDirs) {
311
+ place(path.join(SRC, 'skills', name), path.join(configDir, 'skills', name), useSymlink);
312
+ }
217
313
  place(path.join(SRC, 'commands'), P.commands, useSymlink);
218
314
  place(path.join(SRC, 'templates'), P.templates, useSymlink);
219
- copyHookScripts(P.hooksDir);
315
+ copyHookScripts(P.hooksDir, { useSymlink });
316
+ // Register the engine agents as Claude subagents: sweep any stale
317
+ // trailhead-*.md first (a removed/renamed agent), then place the current set
318
+ // by name. This is what makes config.models.<key> take effect on the
319
+ // skills/npm channel; without it every technique runs inline (see
320
+ // agentFileNames above). The agents dir is shared, so only our names are
321
+ // touched.
322
+ sweepTrailheadAgents(P.agents);
323
+ const agentFiles = copyAgentFiles(P.agents, { useSymlink });
220
324
  // version marker: serve al check-update hook per la versione installata (canale npm)
221
325
  const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
222
326
  if (pkgVersion) {
@@ -228,14 +332,16 @@ function installClaude(configDir, { useSymlink }) {
228
332
  const added = [
229
333
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-commit-guard.js')),
230
334
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
335
+ addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-install-guard.js')),
231
336
  addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
232
337
  addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
233
338
  ].some(Boolean);
234
339
  writeJSON(P.settings, s);
235
340
 
236
341
  console.log(`✓ trailhead installed for Claude Code → ${configDir}`);
237
- console.log(` skill → ${P.skill}${useSymlink ? ' (symlink)' : ''}`);
342
+ console.log(` skills → ${path.join(configDir, 'skills')}/ (${skillDirs.join(', ')})${useSymlink ? ' (symlink)' : ''}`);
238
343
  console.log(` commands → ${P.commands} (/trailhead:*)`);
344
+ console.log(` agents → ${P.agents}/ (${agentFiles.length} agent(s): trailhead-*)${useSymlink ? ' (symlink)' : ''}`);
239
345
  console.log(` hooks → ${P.hooksDir}/ (${added ? 'registered' : 'already present'} in settings.json)`);
240
346
  console.log(` templates → ${P.templates}`);
241
347
  console.log('\nRestart or reload your agent to pick up the commands, then run /trailhead to start.');
@@ -295,6 +401,39 @@ function readCommandVerbs() {
295
401
  .sort((a, b) => a.verb.localeCompare(b.verb));
296
402
  }
297
403
 
404
+ // Unquote a YAML double-quoted scalar (unlike commands/*.md, the agent .md
405
+ // frontmatter wraps `description:` in double quotes). Leaves a plain/
406
+ // unquoted scalar untouched, so this is safe to apply unconditionally.
407
+ function unquoteYamlScalar(v) {
408
+ if (typeof v !== 'string' || v.length < 2 || v[0] !== '"' || v[v.length - 1] !== '"') return v;
409
+ return v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
410
+ }
411
+
412
+ // Read the 7 committed agent sources from plugins/trailhead/agents/*.md: each
413
+ // file's basename is the agent's name; frontmatter description/tools ride
414
+ // along, and `body` is the markdown after the closing frontmatter `---`.
415
+ // Single-sources the Codex agent-TOML projection so it never drifts from the
416
+ // committed agent prose. Mirrors readCommandVerbs's shape. Pure fs; kept out
417
+ // of codex-projection.js, which must stay fs-free.
418
+ function readAgentDefs() {
419
+ const dir = path.join(SRC, 'agents');
420
+ if (!fs.existsSync(dir)) return [];
421
+ return fs.readdirSync(dir)
422
+ .filter((f) => f.startsWith('trailhead-') && f.endsWith('.md'))
423
+ .map((f) => {
424
+ const raw = fs.readFileSync(path.join(dir, f), 'utf8');
425
+ const fm = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(raw);
426
+ const body = fm ? raw.slice(fm[0].length) : raw;
427
+ return {
428
+ name: f.slice(0, -3),
429
+ description: unquoteYamlScalar(fmValue(raw, 'description')),
430
+ tools: fmValue(raw, 'tools'),
431
+ body,
432
+ };
433
+ })
434
+ .sort((a, b) => a.name.localeCompare(b.name));
435
+ }
436
+
298
437
  // Extract one frontmatter scalar's raw value verbatim (an already-valid YAML
299
438
  // scalar re-serialises unchanged). Looks only inside the leading ---...--- block.
300
439
  // Returns undefined when the key is absent or its value is empty.
@@ -333,7 +472,7 @@ function installCodex(configDir, { useSymlink }) {
333
472
  const L = codexLayout(configDir);
334
473
 
335
474
  if (useSymlink) {
336
- console.log('Note: symlink install is unavailable for Codex (artifacts are generated, not copied verbatim); installing copies instead.');
475
+ console.log('Note: on Codex, --symlink links the verbatim artifacts (hooks, templates) into the package source for live edits; the skill files are text-converted at install, so they are written (projected), not linked.');
337
476
  }
338
477
 
339
478
  // Migration: sweep the pre-#25 layout AND the #42 prompt shims that never
@@ -346,31 +485,44 @@ function installCodex(configDir, { useSymlink }) {
346
485
  }
347
486
  rmrf(L.legacyEngineDir);
348
487
 
349
- // Engine: clean skill dir, then project with conversion.
350
- rmrf(L.skillDir);
351
- ensure(L.skillDir);
352
- copySkillConverted(path.join(SRC, 'skills', 'trailhead'), L.skillDir, true);
488
+ // Sweep the whole engine + any prior per-verb skills, then project fresh.
489
+ sweepTrailheadSkills(L.skillsRoot);
490
+
491
+ // Project each engine skill dir (dispatcher + 5 clusters + _shared) as a
492
+ // sibling under skills/, converting text and injecting the adapter header into
493
+ // each top-level SKILL.md. The ../_shared/ relative refs survive conversion and
494
+ // resolve as siblings (decision #54). _shared has no SKILL.md, so no header.
495
+ const engineDirs = engineSkillDirs();
496
+ for (const name of engineDirs) {
497
+ copySkillConverted(path.join(SRC, 'skills', name), path.join(L.skillsRoot, name), true);
498
+ }
353
499
 
354
500
  // Templates (verbatim, never converted), bundled inside the skill dir so
355
501
  // ${CLAUDE_PLUGIN_ROOT}/templates resolves to ~/.codex/skills/trailhead/templates.
356
- ensure(L.templatesDir);
357
- fs.cpSync(path.join(SRC, 'templates'), L.templatesDir, { recursive: true });
502
+ place(path.join(SRC, 'templates'), L.templatesDir, useSymlink);
358
503
 
359
504
  // agents/openai.yaml: UI metadata + explicit-only invocation (no auto-trigger).
360
505
  ensure(L.agentsDir);
361
506
  fs.writeFileSync(L.agentsYaml, codexAgentsYaml());
507
+ // Each cluster skill also gets explicit-only UI metadata (never auto-invoked).
508
+ for (const name of engineDirs) {
509
+ if (name === 'trailhead' || name === '_shared') continue;
510
+ const dir = path.join(L.skillsRoot, name, 'agents');
511
+ ensure(dir);
512
+ fs.writeFileSync(path.join(dir, 'openai.yaml'), codexClusterAgentsYaml(name));
513
+ }
362
514
 
363
515
  // Per-verb discoverability (#46): one thin Codex skill per verb
364
516
  // (skills/trailhead-<verb>/, invocable as $trailhead-<verb>), each delegating
365
517
  // to $trailhead <verb>. Codex custom prompts (~/.codex/prompts/) never surface
366
518
  // as slash commands, so per-verb skills carry the surface, exactly as GSD does.
367
- // Sweep stale trailhead-* verb skills first (a removed verb, or an upgrade from
368
- // the old prompt-shim layout), never the main skills/trailhead. Verbs come from
369
- // the canonical commands/*.md so the surfaces never drift.
370
- for (const f of (fs.existsSync(L.skillsRoot) ? fs.readdirSync(L.skillsRoot) : [])) {
371
- if (f.startsWith('trailhead-')) rmrf(path.join(L.skillsRoot, f));
372
- }
373
- const verbSkillPlan = codexVerbSkillPlan(configDir, readCommandVerbs());
519
+ // Verbs come from the canonical commands/*.md so the surfaces never drift.
520
+ // Skip any verb whose skill dir would collide with a projected cluster skill
521
+ // (e.g. verb `work` vs the trailhead-work cluster): the cluster skill is its
522
+ // own $trailhead-<name> entry. Compute the skip-set from the projected dirs.
523
+ const clusterDirNames = new Set(engineDirs);
524
+ const verbList = readCommandVerbs().filter((c) => !clusterDirNames.has(`trailhead-${c.verb}`));
525
+ const verbSkillPlan = codexVerbSkillPlan(configDir, verbList);
374
526
  for (const d of verbSkillPlan.dirs) ensure(path.join(d, 'agents'));
375
527
  for (const w of verbSkillPlan.writes) fs.writeFileSync(w.path, w.content);
376
528
 
@@ -379,7 +531,7 @@ function installCodex(configDir, { useSymlink }) {
379
531
 
380
532
  // Hooks: copy the 4 guard scripts into the skill dir and register them in
381
533
  // ~/.codex/hooks.json (same shape as Claude's settings.json hooks block).
382
- copyHookScripts(L.hooksScriptsDir);
534
+ copyHookScripts(L.hooksScriptsDir, { useSymlink });
383
535
  const h = readJSON(L.hooksJson);
384
536
  for (const e of codexHookEntries(L.hooksScriptsDir)) addHook(h, e.event, e.matcher, e.command);
385
537
  writeJSON(L.hooksJson, h);
@@ -397,9 +549,13 @@ function installCodex(configDir, { useSymlink }) {
397
549
  featureManual = true;
398
550
  }
399
551
 
400
- // Per-technique model pins: project models.codex.* (merged project + global
401
- // config) into trailhead-<technique>.toml under the real ~/.codex/agents/
402
- // dir. Sweep only trailhead's own files first, so a stale pin from a prior
552
+ // Per-technique agent registry: project ALL 7 committed agents
553
+ // (readAgentDefs, sourced from plugins/trailhead/agents/*.md) as
554
+ // trailhead-<technique>.toml under the real ~/.codex/agents/ dir, uniformly.
555
+ // A keyed technique (plan/execute/research/review/debug) carries its
556
+ // models.codex.<key> pin (merged project + global config) when set, else is
557
+ // pin-less; the 2 keyless agents (fix, codebase-map) are always pin-less.
558
+ // Sweep only trailhead's own files first, so a stale pin from a prior
403
559
  // install (or a removed technique) never lingers, without touching the
404
560
  // user's other custom-agent TOMLs.
405
561
  const codexModels = readCodexModels(configDir);
@@ -407,16 +563,15 @@ function installCodex(configDir, { useSymlink }) {
407
563
  for (const f of fs.readdirSync(L.codexAgentsDir)) {
408
564
  if (/^trailhead-.*\.toml$/.test(f)) rmrf(path.join(L.codexAgentsDir, f));
409
565
  }
410
- const plan = codexAgentTomlPlan(configDir, codexModels);
566
+ const plan = codexAgentTomlPlan(configDir, codexModels, readAgentDefs());
411
567
  for (const w of plan.writes) fs.writeFileSync(w.path, w.content);
412
568
 
413
569
  // Feature flag: Codex gates per-subagent model pinning behind
414
- // features.multi_agent_v2 = true. Enable it ONLY when we actually projected
415
- // pins: with no models.codex.* set, base multi_agent already fans out and the
416
- // adapter's §D runtime detection expects no trailhead agent_type registry, so
417
- // leaving v2 off keeps that contract sound. (A reinstall that drops all pins
418
- // sweeps the TOMLs above but leaves any prior v2 flag in place; harmless, as
419
- // no trailhead-* agents remain for the runtime to dispatch to.)
570
+ // features.multi_agent_v2 = true. All 7 agent TOMLs are normally projected
571
+ // (pinned or pin-less), so v2 is enabled on every healthy Codex install: the
572
+ // adapter's §D runtime detection expects a trailhead agent_type registry,
573
+ // pins or not. Still guarded on writes so a degenerate case (no agent .md
574
+ // found, zero writes) never flips the flag on with an empty registry.
420
575
  let featureManualV2 = false;
421
576
  if (plan.writes.length > 0) {
422
577
  const updatedTomlV2 = enableCodexMultiAgentV2Feature(current);
@@ -433,17 +588,19 @@ function installCodex(configDir, { useSymlink }) {
433
588
  console.log(` templates → ${L.templatesDir}/`);
434
589
  console.log(` verb skills → ${L.skillsRoot}/trailhead-<verb>/ (${verbSkillPlan.dirs.length} discoverability skills: invoke $trailhead-<verb>)`);
435
590
  console.log(` hooks → ${L.hooksScriptsDir}/ (registered in hooks.json)`);
436
- if (plan.writes.length > 0) {
437
- console.log(` agents → ${L.codexAgentsDir}/ (${plan.writes.length} model pin(s): ${plan.writes.map((w) => w.name + '.toml').join(', ')})`);
438
- } else {
439
- console.log(' agents → no models.codex.* set, subagents inherit the session model');
591
+ {
592
+ const pinned = plan.writes.filter((w) => w.content.includes('\nmodel = '));
593
+ const pinsNote = pinned.length > 0
594
+ ? `${pinned.length} pinned: ${pinned.map((w) => w.name + '.toml').join(', ')}`
595
+ : 'no models.codex.* set, all pin-less (session model)';
596
+ console.log(` agents → ${L.codexAgentsDir}/ (${plan.writes.length} agent(s) projected; ${pinsNote})`);
440
597
  }
441
598
  console.log('\nRestart or reload Codex to pick up the skill, then run $trailhead to start.');
442
599
  // The trust prompt only fires once the feature flag is on. When we could not
443
600
  // edit config.toml, the hooks stay inert until the user sets it by hand, so
444
601
  // don't reassure them about a trust review that won't happen yet.
445
602
  if (featureManual) {
446
- console.log(` ⚠ could not edit ${L.configToml} automatically — add \`features.hooks = true\` under [features] by hand, then Codex will ask you to trust trailhead's hooks on next start.`);
603
+ console.log(` ⚠ could not edit ${L.configToml} automatically; add \`features.hooks = true\` under [features] by hand, then Codex will ask you to trust trailhead's hooks on next start.`);
447
604
  } else {
448
605
  console.log('Note: Codex will ask you to trust trailhead\'s hooks on next start (feature `features.hooks`).');
449
606
  }
@@ -454,12 +611,8 @@ function installCodex(configDir, { useSymlink }) {
454
611
 
455
612
  function uninstallCodex(configDir) {
456
613
  const L = codexLayout(configDir);
457
- rmrf(L.skillDir);
458
- // sweep the per-verb discoverability skills (skills/trailhead-<verb>/, #46),
459
- // never the main skills/trailhead (already removed above; does not match).
460
- for (const f of (fs.existsSync(L.skillsRoot) ? fs.readdirSync(L.skillsRoot) : [])) {
461
- if (f.startsWith('trailhead-')) rmrf(path.join(L.skillsRoot, f));
462
- }
614
+ // Sweep the dispatcher, _shared, the clusters, and the per-verb skills.
615
+ sweepTrailheadSkills(L.skillsRoot);
463
616
  // sweep any pre-#25 layout AND the #42 prompt shims from prompts/
464
617
  rmrf(path.join(L.promptsDir, 'trailhead.md'));
465
618
  for (const f of (fs.existsSync(L.promptsDir) ? fs.readdirSync(L.promptsDir) : [])) {
@@ -482,6 +635,7 @@ function uninstallCodex(configDir) {
482
635
  const h = readJSON(L.hooksJson);
483
636
  stripHook(h, 'PreToolUse', 'trailhead-commit-guard.js');
484
637
  stripHook(h, 'PreToolUse', 'trailhead-secret-guard.js');
638
+ stripHook(h, 'PreToolUse', 'trailhead-install-guard.js');
485
639
  stripHook(h, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
486
640
  stripHook(h, 'SessionStart', 'trailhead-check-update.js');
487
641
  writeJSON(L.hooksJson, h);