@marcomigozzi/trailhead 0.5.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 (35) hide show
  1. package/README.it.md +4 -2
  2. package/README.md +4 -2
  3. package/bin/lib/codex-projection.js +79 -58
  4. package/bin/lib/codex-projection.test.js +103 -15
  5. package/bin/trailhead.js +106 -15
  6. package/bin/trailhead.test.js +65 -10
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  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/hooks/hooks.json +4 -0
  17. package/plugins/trailhead/hooks/trailhead-install-guard.js +176 -0
  18. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +118 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -6
  20. package/plugins/trailhead/skills/_shared/techniques/code-review.md +2 -2
  21. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  22. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  23. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  24. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -2
  25. package/plugins/trailhead/skills/_shared/techniques/research.md +2 -1
  26. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  27. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  28. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -0
  29. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -0
  30. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +6 -6
  31. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -0
  32. package/plugins/trailhead/skills/trailhead-manage/references/updating.md +12 -0
  33. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  34. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  35. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +8 -8
package/bin/trailhead.js CHANGED
@@ -172,10 +172,11 @@ function claudePaths(configDir) {
172
172
  commands: path.join(configDir, 'commands', 'trailhead'),
173
173
  hooksDir: path.join(configDir, 'hooks'),
174
174
  templates: path.join(configDir, 'trailhead', 'templates'),
175
+ agents: path.join(configDir, 'agents'),
175
176
  settings: path.join(configDir, 'settings.json'),
176
177
  };
177
178
  }
178
- 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'];
179
180
  // Runtime libs the hook scripts require (trailhead-commit-guard.js does
180
181
  // require('./lib/commit-message-check.js')). Copied by name, never a recursive
181
182
  // sweep: the Claude hooks/lib dir is shared with other plugins, so we must not
@@ -217,6 +218,43 @@ function place(src, dest, symlink) {
217
218
  else fs.cpSync(src, dest, { recursive: true });
218
219
  }
219
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
+
220
258
  function addHook(s, event, matcher, command) {
221
259
  s.hooks = s.hooks || {};
222
260
  s.hooks[event] = s.hooks[event] || [];
@@ -239,6 +277,9 @@ function uninstallClaude(configDir) {
239
277
  // layout left that this package no longer ships), never the shared dir itself.
240
278
  sweepTrailheadSkills(path.join(configDir, 'skills'));
241
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);
242
283
  HOOK_FILES.forEach((f) => rmrf(path.join(P.hooksDir, f)));
243
284
  // Remove only trailhead's own lib files by name: the hooks/lib dir is shared
244
285
  // with other plugins, so never rmrf the whole dir.
@@ -246,6 +287,7 @@ function uninstallClaude(configDir) {
246
287
  const s = readJSON(P.settings);
247
288
  stripHook(s, 'PreToolUse', 'trailhead-commit-guard.js');
248
289
  stripHook(s, 'PreToolUse', 'trailhead-secret-guard.js');
290
+ stripHook(s, 'PreToolUse', 'trailhead-install-guard.js');
249
291
  stripHook(s, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
250
292
  stripHook(s, 'SessionStart', 'trailhead-check-update.js');
251
293
  writeJSON(P.settings, s);
@@ -271,6 +313,14 @@ function installClaude(configDir, { useSymlink }) {
271
313
  place(path.join(SRC, 'commands'), P.commands, useSymlink);
272
314
  place(path.join(SRC, 'templates'), P.templates, useSymlink);
273
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 });
274
324
  // version marker: serve al check-update hook per la versione installata (canale npm)
275
325
  const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
276
326
  if (pkgVersion) {
@@ -282,6 +332,7 @@ function installClaude(configDir, { useSymlink }) {
282
332
  const added = [
283
333
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-commit-guard.js')),
284
334
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
335
+ addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-install-guard.js')),
285
336
  addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
286
337
  addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
287
338
  ].some(Boolean);
@@ -290,6 +341,7 @@ function installClaude(configDir, { useSymlink }) {
290
341
  console.log(`✓ trailhead installed for Claude Code → ${configDir}`);
291
342
  console.log(` skills → ${path.join(configDir, 'skills')}/ (${skillDirs.join(', ')})${useSymlink ? ' (symlink)' : ''}`);
292
343
  console.log(` commands → ${P.commands} (/trailhead:*)`);
344
+ console.log(` agents → ${P.agents}/ (${agentFiles.length} agent(s): trailhead-*)${useSymlink ? ' (symlink)' : ''}`);
293
345
  console.log(` hooks → ${P.hooksDir}/ (${added ? 'registered' : 'already present'} in settings.json)`);
294
346
  console.log(` templates → ${P.templates}`);
295
347
  console.log('\nRestart or reload your agent to pick up the commands, then run /trailhead to start.');
@@ -349,6 +401,39 @@ function readCommandVerbs() {
349
401
  .sort((a, b) => a.verb.localeCompare(b.verb));
350
402
  }
351
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
+
352
437
  // Extract one frontmatter scalar's raw value verbatim (an already-valid YAML
353
438
  // scalar re-serialises unchanged). Looks only inside the leading ---...--- block.
354
439
  // Returns undefined when the key is absent or its value is empty.
@@ -464,9 +549,13 @@ function installCodex(configDir, { useSymlink }) {
464
549
  featureManual = true;
465
550
  }
466
551
 
467
- // Per-technique model pins: project models.codex.* (merged project + global
468
- // config) into trailhead-<technique>.toml under the real ~/.codex/agents/
469
- // 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
470
559
  // install (or a removed technique) never lingers, without touching the
471
560
  // user's other custom-agent TOMLs.
472
561
  const codexModels = readCodexModels(configDir);
@@ -474,16 +563,15 @@ function installCodex(configDir, { useSymlink }) {
474
563
  for (const f of fs.readdirSync(L.codexAgentsDir)) {
475
564
  if (/^trailhead-.*\.toml$/.test(f)) rmrf(path.join(L.codexAgentsDir, f));
476
565
  }
477
- const plan = codexAgentTomlPlan(configDir, codexModels);
566
+ const plan = codexAgentTomlPlan(configDir, codexModels, readAgentDefs());
478
567
  for (const w of plan.writes) fs.writeFileSync(w.path, w.content);
479
568
 
480
569
  // Feature flag: Codex gates per-subagent model pinning behind
481
- // features.multi_agent_v2 = true. Enable it ONLY when we actually projected
482
- // pins: with no models.codex.* set, base multi_agent already fans out and the
483
- // adapter's §D runtime detection expects no trailhead agent_type registry, so
484
- // leaving v2 off keeps that contract sound. (A reinstall that drops all pins
485
- // sweeps the TOMLs above but leaves any prior v2 flag in place; harmless, as
486
- // 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.
487
575
  let featureManualV2 = false;
488
576
  if (plan.writes.length > 0) {
489
577
  const updatedTomlV2 = enableCodexMultiAgentV2Feature(current);
@@ -500,10 +588,12 @@ function installCodex(configDir, { useSymlink }) {
500
588
  console.log(` templates → ${L.templatesDir}/`);
501
589
  console.log(` verb skills → ${L.skillsRoot}/trailhead-<verb>/ (${verbSkillPlan.dirs.length} discoverability skills: invoke $trailhead-<verb>)`);
502
590
  console.log(` hooks → ${L.hooksScriptsDir}/ (registered in hooks.json)`);
503
- if (plan.writes.length > 0) {
504
- console.log(` agents → ${L.codexAgentsDir}/ (${plan.writes.length} model pin(s): ${plan.writes.map((w) => w.name + '.toml').join(', ')})`);
505
- } else {
506
- 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})`);
507
597
  }
508
598
  console.log('\nRestart or reload Codex to pick up the skill, then run $trailhead to start.');
509
599
  // The trust prompt only fires once the feature flag is on. When we could not
@@ -545,6 +635,7 @@ function uninstallCodex(configDir) {
545
635
  const h = readJSON(L.hooksJson);
546
636
  stripHook(h, 'PreToolUse', 'trailhead-commit-guard.js');
547
637
  stripHook(h, 'PreToolUse', 'trailhead-secret-guard.js');
638
+ stripHook(h, 'PreToolUse', 'trailhead-install-guard.js');
548
639
  stripHook(h, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
549
640
  stripHook(h, 'SessionStart', 'trailhead-check-update.js');
550
641
  writeJSON(L.hooksJson, h);
@@ -131,11 +131,19 @@ ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-gua
131
131
  const codexConfigTomlPath = path.join(codexDir, 'config.toml');
132
132
  ok('codex: config.toml exists', fs.existsSync(codexConfigTomlPath));
133
133
  ok('codex: config.toml enables hooks feature', fs.readFileSync(codexConfigTomlPath, 'utf8').includes('hooks = true'));
134
- // v2 is gated on projected pins: this install (repoRoot, no models.codex.*)
135
- // projects none, so multi_agent_v2 stays OFF (base multi_agent still fans out;
136
- // adapter §D expects no trailhead agent_type registry in the no-pins case).
137
- ok('codex: config.toml does NOT enable multi_agent_v2 when no models.codex.* pins are projected',
138
- !fs.readFileSync(codexConfigTomlPath, 'utf8').includes('multi_agent_v2 = true'));
134
+ // All 7 agents are always projected uniformly (pinned or pin-less), so
135
+ // multi_agent_v2 is ON even with no models.codex.* set (this install,
136
+ // repoRoot, sets none): the adapter §D registry is always present.
137
+ ok('codex: config.toml enables multi_agent_v2 even with no models.codex.* pins projected',
138
+ fs.readFileSync(codexConfigTomlPath, 'utf8').includes('multi_agent_v2 = true'));
139
+
140
+ const codexAgentsDirPath = path.join(codexDir, 'agents');
141
+ const noPinFixTomlPath = path.join(codexAgentsDirPath, 'trailhead-fix.toml');
142
+ const noPinMapTomlPath = path.join(codexAgentsDirPath, 'trailhead-codebase-map.toml');
143
+ ok('codex: trailhead-fix.toml is projected with no models.codex.* set', fs.existsSync(noPinFixTomlPath));
144
+ ok('codex: trailhead-codebase-map.toml is projected with no models.codex.* set', fs.existsSync(noPinMapTomlPath));
145
+ ok('codex: trailhead-fix.toml is pin-less (no model = line) with no models.codex.* set',
146
+ fs.existsSync(noPinFixTomlPath) && !/^model = /m.test(fs.readFileSync(noPinFixTomlPath, 'utf8')));
139
147
 
140
148
  // --- codex --symlink: link verbatim artifacts (hooks + templates), keep skills projected ---
141
149
  const codexSymDir = mktmp();
@@ -161,12 +169,21 @@ ok('codex copy: hooks are regular files (not symlinks)',
161
169
  ok('codex copy: templates is a regular dir (not a symlink)',
162
170
  !fs.lstatSync(path.join(codexDir, 'skills', 'trailhead', 'templates')).isSymbolicLink());
163
171
 
164
- // --- codex agent TOML projection (#38) -----------------------------------------
172
+ // --- codex agent TOML projection (#38, #88) -------------------------------------
165
173
  // repoRoot's own .trailhead/config.json has no models.codex.*, so a plain
166
- // install (cwd: repoRoot, no models.codex anywhere) projects no agent TOMLs.
167
- ok('codex: no trailhead-*.toml under agents/ when models.codex.* is unset',
168
- !fs.existsSync(path.join(codexDir, 'agents')) ||
169
- !fs.readdirSync(path.join(codexDir, 'agents')).some((f) => /^trailhead-.*\.toml$/.test(f)));
174
+ // install (cwd: repoRoot, no models.codex anywhere) still projects all 7
175
+ // agents pin-less (uniform projection, #88), never zero.
176
+ ok('codex: all 7 trailhead-*.toml exist under agents/ even with models.codex.* unset', (() => {
177
+ const dir = path.join(codexDir, 'agents');
178
+ if (!fs.existsSync(dir)) return false;
179
+ const tomls = fs.readdirSync(dir).filter((f) => /^trailhead-.*\.toml$/.test(f));
180
+ return tomls.length === 7;
181
+ })());
182
+ ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (() => {
183
+ const dir = path.join(codexDir, 'agents');
184
+ const tomls = fs.readdirSync(dir).filter((f) => /^trailhead-.*\.toml$/.test(f));
185
+ return tomls.every((f) => !/^model = /m.test(fs.readFileSync(path.join(dir, f), 'utf8')));
186
+ })());
170
187
 
171
188
  // --- codex migration (#46): install over old prompt shims sweeps them --------
172
189
  const migCodexDir = mktmp();
@@ -236,6 +253,20 @@ ok('claude: trailhead-commit-guard.js loads without MODULE_NOT_FOUND', (() => {
236
253
  })());
237
254
  ok('claude: SKILL.md does not start with the codex adapter header', !fs.readFileSync(claudeSkillPath, 'utf8').startsWith('<codex_skill_adapter>'));
238
255
 
256
+ // --- claude: engine agents registered as user subagents -----------------------
257
+ // Without these under <configDir>/agents/, every technique dispatch fails and
258
+ // falls back to running inline on the session model, so the per-technique
259
+ // config.models.* split silently never applies.
260
+ const claudeAgentsDir = path.join(claudeDir, 'agents');
261
+ const expectedAgents = ['trailhead-plan', 'trailhead-executor', 'trailhead-research', 'trailhead-code-review', 'trailhead-debug', 'trailhead-fix', 'trailhead-codebase-map'];
262
+ for (const a of expectedAgents) {
263
+ ok(`claude: agents/${a}.md placed`, fs.existsSync(path.join(claudeAgentsDir, `${a}.md`)));
264
+ }
265
+ ok('claude: agent file registers its subagent name in frontmatter',
266
+ fs.readFileSync(path.join(claudeAgentsDir, 'trailhead-executor.md'), 'utf8').startsWith('---\nname: trailhead-executor\n'));
267
+ ok('claude: agents are real files on a copy install (not symlinks)',
268
+ !fs.lstatSync(path.join(claudeAgentsDir, 'trailhead-plan.md')).isSymbolicLink());
269
+
239
270
  // --- claude --symlink: dev install links hooks live too (not just skills/commands) ---
240
271
  const symDir = mktmp();
241
272
  runInstaller([`--claude`, `--symlink`, `--dir=${symDir}`]);
@@ -260,6 +291,11 @@ ok('claude symlink: a symlinked commit-guard still loads its lib (no MODULE_NOT_
260
291
  runInstaller([`--claude`, `--symlink`, `--dir=${symDir}`]);
261
292
  ok('claude symlink: reinstall over existing symlinks succeeds',
262
293
  fs.lstatSync(path.join(symDir, 'hooks', 'trailhead-secret-guard.js')).isSymbolicLink());
294
+ ok('claude symlink: agents/trailhead-plan.md is a symlink',
295
+ fs.lstatSync(path.join(symDir, 'agents', 'trailhead-plan.md')).isSymbolicLink());
296
+ ok('claude symlink: agent symlink resolves into the package source',
297
+ fs.realpathSync(path.join(symDir, 'agents', 'trailhead-plan.md')) ===
298
+ fs.realpathSync(path.join(repoRoot, 'plugins', 'trailhead', 'agents', 'trailhead-plan.md')));
263
299
  // Default (copy) install keeps hooks as real files, not symlinks.
264
300
  ok('claude copy: hooks/trailhead-secret-guard.js is a regular file (not a symlink)',
265
301
  !fs.lstatSync(path.join(claudeDir, 'hooks', 'trailhead-secret-guard.js')).isSymbolicLink());
@@ -283,6 +319,14 @@ ok('claude migration: stale trailhead-legacy cluster swept on reinstall', !fs.ex
283
319
  ok('claude migration: co-tenant skill preserved (only trailhead names swept)', fs.existsSync(path.join(migClaudeDir, 'skills', 'other-plugin')));
284
320
  ok('claude migration: current dispatcher present after reinstall', fs.existsSync(path.join(migClaudeDir, 'skills', 'trailhead', 'SKILL.md')));
285
321
  ok('claude migration: current cluster present after reinstall', fs.existsSync(path.join(migClaudeDir, 'skills', 'trailhead-work', 'SKILL.md')));
322
+ // The agents dir gets the same name-only sweep on reinstall: a stale trailhead
323
+ // agent (renamed/removed) is swept, a co-tenant agent left alone.
324
+ fs.writeFileSync(path.join(migClaudeDir, 'agents', 'trailhead-oldagent.md'), '---\nname: trailhead-oldagent\n---\n');
325
+ fs.writeFileSync(path.join(migClaudeDir, 'agents', 'other-plugin-agent.md'), '---\nname: other-plugin-agent\n---\n');
326
+ runInstaller([`--claude`, `--dir=${migClaudeDir}`]);
327
+ ok('claude migration: stale trailhead-oldagent.md swept on reinstall', !fs.existsSync(path.join(migClaudeDir, 'agents', 'trailhead-oldagent.md')));
328
+ ok('claude migration: co-tenant agent preserved on reinstall', fs.existsSync(path.join(migClaudeDir, 'agents', 'other-plugin-agent.md')));
329
+ ok('claude migration: engine agents present after reinstall', fs.existsSync(path.join(migClaudeDir, 'agents', 'trailhead-executor.md')));
286
330
 
287
331
  // Uninstall also sweeps a stale trailhead-owned dir the current package no
288
332
  // longer ships (monolith), not just the current engineSkillDirs() set.
@@ -305,6 +349,17 @@ runInstaller([`--claude`, `--dir=${coTenantDir}`, '--uninstall']);
305
349
  ok('claude uninstall: trailhead lib removed', !fs.existsSync(path.join(coTenantDir, 'hooks', 'lib', 'commit-message-check.js')));
306
350
  ok('claude uninstall: co-tenant lib preserved (no recursive wipe)', fs.existsSync(coTenantLib));
307
351
 
352
+ // The agents dir is shared too: a co-tenant's own agent survives uninstall,
353
+ // while trailhead's are removed by name.
354
+ const coTenantAgentDir = mktmp();
355
+ runInstaller([`--claude`, `--dir=${coTenantAgentDir}`]);
356
+ const foreignAgent = path.join(coTenantAgentDir, 'agents', 'other-plugin-agent.md');
357
+ fs.writeFileSync(foreignAgent, '---\nname: other-plugin-agent\n---\n');
358
+ ok('claude: engine agent present before uninstall', fs.existsSync(path.join(coTenantAgentDir, 'agents', 'trailhead-plan.md')));
359
+ runInstaller([`--claude`, `--dir=${coTenantAgentDir}`, '--uninstall']);
360
+ ok('claude uninstall: trailhead agents removed by name', !fs.existsSync(path.join(coTenantAgentDir, 'agents', 'trailhead-plan.md')));
361
+ ok('claude uninstall: co-tenant agent preserved (no recursive wipe)', fs.existsSync(foreignAgent));
362
+
308
363
  // Uninstall removes every split skill (dispatcher + clusters + _shared) by name.
309
364
  const splitDir = mktmp();
310
365
  runInstaller([`--claude`, `--dir=${splitDir}`]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
5
5
  "bin": {
6
6
  "trailhead": "bin/trailhead.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trailhead",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-code-review
3
+ description: "trailhead code-review subagent: adversarially reviews a ticket's diff and reports; never edits or commits."
4
+ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's code-review subagent. Review the ticket's diff adversarially on the Code review technique's axes, verifying each finding before reporting. Do not defer to a convention or a settled-decision memory.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/code-review.md`); follow that, not any embedded copy. Read-only by policy: it reviews and reports into trailhead's `VERIFY` comment, it must never Edit/Write or commit. Model-agnostic by design: the engine passes the `config.models.review` model as the spawn-time override, so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-codebase-map
3
+ description: "trailhead codebase-map reader subagent: maps one non-overlapping facet of the repo. Read-only."
4
+ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's codebase-map reader subagent. Map ONE non-overlapping facet of the repo for the distilled `trailhead:codebase` issue; do not read another reader's territory.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/codebase-map.md`); follow that, not any embedded copy. Read-only by policy: read-only by construction. Model-agnostic by design: the engine passes the session model at spawn (no dedicated model key), so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-debug
3
+ description: "trailhead debug subagent: finds a defect's root cause by the scientific method."
4
+ tools: Read, Write, Edit, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's debug subagent. Find the root cause of the ticket's defect by the scientific method: reproduce, localise, falsifiable hypotheses, confirm cause, verify.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/debug.md`); follow that, not any embedded copy. Full tools by policy: it adds instrumentation and regression tests, so it needs Write. Model-agnostic by design: the engine passes the `config.models.debug` model as the spawn-time override, so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-executor
3
+ description: "trailhead execute subagent: implements an approved PLAN with atomic conventional commits (each carrying a Refs: #<n> trailer)."
4
+ tools: Read, Write, Edit, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's execute subagent. Implement the approved PLAN for a build ticket with atomic conventional commits, each carrying a `Refs: #<n>` trailer for the ticket, following the repo's `trailhead:conventions`.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (the Execute step in `trailhead-work/references/ticket-engines.md`); follow that, not any embedded copy. Full tools by policy: it can Edit/Write/commit. Model-agnostic by design: the engine passes the `config.models.execute` model as the spawn-time override, so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-fix
3
+ description: "trailhead fix subagent: implements a bug fix with atomic commits (each carrying a Refs: #<n> trailer)."
4
+ tools: Read, Write, Edit, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's fix subagent. Implement the correction for a bug ticket with atomic conventional commits, each carrying a `Refs: #<n>` trailer for the ticket, following the repo's `trailhead:conventions`; where sensible, a test that fails before and passes after.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (the Fix step in `trailhead-work/references/ticket-engines.md`); follow that, not any embedded copy. Full tools by policy: it can Edit/Write/commit. Model-agnostic by design: the engine passes the `config.models.execute` model as the spawn-time override (fix tracks the execute key), so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-plan
3
+ description: "trailhead planning subagent: produces a build ticket's PLAN (steps, seams, files touched, verification criteria). Read-only, never implements."
4
+ tools: Read, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's planning subagent. Produce the PLAN for a build ticket: the steps, the seams, the files touched, and the verification criteria, applying TDD per the ticket's config.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (the Plan step in `trailhead-work/references/ticket-engines.md`); follow that, not any embedded copy. Read-only by policy: plan, do not implement or commit. Model-agnostic by design: the engine passes the `config.models.plan` model as the spawn-time override, so config stays authoritative.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: trailhead-research
3
+ description: "trailhead research subagent: gathers a decision-ready fact from primary sources."
4
+ tools: Read, Write, Edit, Grep, Glob, Bash, WebFetch, WebSearch, TodoWrite
5
+ ---
6
+
7
+ trailhead's research subagent. Gather the decision-ready fact a ticket waits on from primary sources, recording findings with citations to a throwaway `research/<name>` branch; change no product code beyond the research artifact.
8
+
9
+ The full protocol is injected at spawn from the trailhead skill's single source (`_shared/techniques/research.md`); follow that, not any embedded copy. Full tools by policy: it needs Write for the research artifact. Model-agnostic by design: the engine passes the `config.models.research` model as the spawn-time override, so config stays authoritative.
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "type": "command",
13
13
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-secret-guard.js\""
14
+ },
15
+ {
16
+ "type": "command",
17
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-install-guard.js\""
14
18
  }
15
19
  ]
16
20
  }
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-install-guard.js: PreToolUse(Bash) hook.
3
+ // Slopsquatting barrier. trailhead's executor/fix agents can install packages
4
+ // (npm / PyPI / crates.io / Go / RubyGems) while resolving a ticket, so a
5
+ // hallucinated or typosquatted package NAME could be fetched silently. This gate
6
+ // BLOCKS any public-registry install that names a package until the agent has
7
+ // vetted the name, then instructs it to verify legitimacy (typosquat similarity,
8
+ // package age, download counts, repo metadata) and checkpoint the human on a
9
+ // suspicious result (block-and-ask). A vetted install proceeds by re-running the
10
+ // command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
11
+ // Only a NAMED install is gated: lockfile/manifest installs (bare `npm install`,
12
+ // `npm ci`, `pip install -r`, `cargo build`, `go mod download`, `bundle install`)
13
+ // and local paths / URLs / VCS refs carry no typed name and pass through.
14
+ // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
15
+ // Crash-safe: any error → exit 0 (never wedge the user's workflow).
16
+ // Self-contained, no dependencies. Pure functions exported for tests; runs as a
17
+ // hook when executed directly.
18
+
19
+ const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
20
+
21
+ // Per-ecosystem: the binaries that own it, and the install verbs that fetch a
22
+ // named package. `add` covers pnpm/yarn/bun/uv/poetry/cargo; `install`/`i` npm
23
+ // and pip; `get`/`install` go.
24
+ const ECOSYSTEMS = {
25
+ npm: { bins: ['npm', 'pnpm', 'yarn', 'bun'], verbs: ['install', 'i', 'add'] },
26
+ pip: { bins: ['pip', 'pip3', 'pipx', 'uv', 'poetry'], verbs: ['install', 'add'] },
27
+ crates: { bins: ['cargo'], verbs: ['add', 'install'] },
28
+ go: { bins: ['go'], verbs: ['get', 'install'] },
29
+ gem: { bins: ['gem'], verbs: ['install'] },
30
+ };
31
+
32
+ // Flags that consume a SEPARATE-TOKEN value (a file, url, or dir), so that value
33
+ // is never mistaken for a package name (e.g. `pip install -r requirements.txt`).
34
+ const VALUE_FLAGS = new Set([
35
+ '-r', '--requirement', '-c', '--constraint', '-e', '--editable',
36
+ '-i', '--index-url', '--extra-index-url', '-f', '--find-links',
37
+ '-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
38
+ '-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
39
+ '--no-binary', '--only-binary',
40
+ ]);
41
+
42
+ function basename(p) {
43
+ const s = String(p).split('/');
44
+ return s[s.length - 1];
45
+ }
46
+
47
+ // A positional that is NOT a public-registry name: local path, url, vcs ref, or a
48
+ // file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
49
+ // module, so for go only a leading . / ~ disqualifies.
50
+ function isLocalOrUrl(t, ecosystem) {
51
+ if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
52
+ if (ecosystem === 'go') return false; // a bare module path is the name; gate it
53
+ if (t.includes('://')) return true;
54
+ if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
55
+ if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return true;
56
+ return false;
57
+ }
58
+
59
+ // Best-effort bare name for the human-facing reason (detection does not depend on
60
+ // this being exact). Strips pip version specifiers / extras and npm @version.
61
+ function bareName(t) {
62
+ let n = t.split(/[=<>~!\[]/)[0]; // pip: pkg==1, pkg>=1, pkg[extra]
63
+ if (n.startsWith('@')) {
64
+ const at = n.indexOf('@', 1); // scoped @scope/pkg@1 → cut the version @
65
+ if (at !== -1) n = n.slice(0, at);
66
+ } else {
67
+ const at = n.indexOf('@'); // npm pkg@1.2.3
68
+ if (at !== -1) n = n.slice(0, at);
69
+ }
70
+ return n;
71
+ }
72
+
73
+ // Detect a public-registry install that NAMES at least one package, in a single
74
+ // shell segment. Returns { ecosystem, packages: [names] } or null.
75
+ function detectSegment(cmd) {
76
+ const toks = String(cmd).trim().split(/\s+/).filter(Boolean);
77
+ let i = 0;
78
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
79
+ if (i >= toks.length) return null;
80
+
81
+ const bin = basename(toks[i]);
82
+ let ecosystem = null;
83
+ for (const [name, spec] of Object.entries(ECOSYSTEMS)) {
84
+ if (spec.bins.includes(bin)) { ecosystem = name; break; }
85
+ }
86
+ // `python -m pip install …` → treat as pip.
87
+ if (!ecosystem && /^python3?$/.test(bin)) {
88
+ if (toks[i + 1] === '-m' && /^pip3?$/.test(toks[i + 2] || '')) {
89
+ ecosystem = 'pip';
90
+ i += 2; // land on `pip`
91
+ } else {
92
+ return null;
93
+ }
94
+ }
95
+ if (!ecosystem) return null;
96
+ i++; // past the binary (or the `pip` peeled above)
97
+
98
+ // `uv pip install …` → peel the `pip`.
99
+ if (bin === 'uv' && toks[i] === 'pip') i++;
100
+
101
+ const verb = toks[i];
102
+ if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
103
+ i++;
104
+
105
+ const packages = [];
106
+ for (let j = i; j < toks.length; j++) {
107
+ const t = toks[j];
108
+ if (t.startsWith('-')) {
109
+ if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
110
+ continue;
111
+ }
112
+ if (isLocalOrUrl(t, ecosystem)) continue;
113
+ packages.push(bareName(t));
114
+ }
115
+ if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
116
+ return { ecosystem, packages };
117
+ }
118
+
119
+ // Agents chain commands (`cd app && npm install foo`), so check every shell
120
+ // segment, not just the first. Split on the common separators (&& || ; | and
121
+ // newlines); a registry name never contains one, so a stray split is harmless.
122
+ // Returns the first segment's hit, or null.
123
+ function detectInstall(cmd) {
124
+ for (const seg of String(cmd).split(/&&|\|\||;|\n|\|/)) {
125
+ const hit = detectSegment(seg);
126
+ if (hit) return hit;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // The agent's "I have vetted these" acknowledgement.
132
+ function isVerified(cmd) {
133
+ return SENTINEL.test(String(cmd));
134
+ }
135
+
136
+ function block(hit) {
137
+ const list = hit.packages.join(', ');
138
+ const reason =
139
+ `trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
140
+ 'Guard against slopsquatting before installing: verify each package is legitimate, weighing ' +
141
+ 'name / typosquat similarity to popular packages, package age / first-seen date, download counts / popularity, ' +
142
+ 'and repository metadata (present and matching). ' +
143
+ 'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
144
+ 'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
145
+ process.stdout.write(JSON.stringify({ decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason }));
146
+ process.exit(2);
147
+ }
148
+
149
+ function run(data) {
150
+ let cmd = '';
151
+ try {
152
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
153
+ } catch {
154
+ process.exit(0); // unparseable input: nothing to gate
155
+ }
156
+ try {
157
+ if (isVerified(cmd)) process.exit(0); // agent has vetted these
158
+ const hit = detectInstall(cmd);
159
+ if (hit) block(hit);
160
+ } catch {
161
+ // fall through to allow: a guard must never wedge the workflow
162
+ }
163
+ process.exit(0);
164
+ }
165
+
166
+ if (require.main === module) {
167
+ let data = '';
168
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends → give up
169
+ process.stdin.on('data', (c) => (data += c));
170
+ process.stdin.on('end', () => {
171
+ clearTimeout(timer);
172
+ run(data);
173
+ });
174
+ }
175
+
176
+ module.exports = { detectInstall, isVerified };