@marcomigozzi/trailhead 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.it.md +2 -2
  2. package/README.md +2 -2
  3. package/bin/lib/install-verify.js +257 -0
  4. package/bin/lib/install-verify.test.js +379 -0
  5. package/bin/trailhead.js +86 -5
  6. package/bin/trailhead.test.js +500 -4
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  10. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  11. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  12. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  13. package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
  14. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +437 -0
  15. package/plugins/trailhead/hooks/lib/model-defaults.json +12 -0
  16. package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  18. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +9 -6
  20. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  21. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  22. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  23. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -4
  24. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  25. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  31. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  34. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  35. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  36. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
@@ -52,9 +52,42 @@ function fakeBinDir(names) {
52
52
  return dir;
53
53
  }
54
54
 
55
+ // #187: the pinned-model review's documented lib path (see load-first.md) is
56
+ // relative to a skills/_shared/ dir; assert it actually resolves on every
57
+ // install layout, survives realpathSync, and its check/ack CLI round-trips.
58
+ const MODEL_DEFAULTS_LIB_CANDIDATES = [
59
+ '../../hooks/lib/model-defaults-review.js',
60
+ '../trailhead/hooks/lib/model-defaults-review.js',
61
+ ];
62
+ function assertModelDefaultsLibLayout(label, sharedDir) {
63
+ const resolved = MODEL_DEFAULTS_LIB_CANDIDATES
64
+ .map((c) => path.join(sharedDir, c))
65
+ .find((p) => fs.existsSync(p));
66
+ ok(`${label}: documented model-defaults-review.js lib path resolves`, !!resolved);
67
+ if (!resolved) return;
68
+ ok(`${label}: resolved lib path still resolves after realpathSync`,
69
+ fs.existsSync(fs.realpathSync(resolved)));
70
+
71
+ const tmpProject = mktmp();
72
+ fs.mkdirSync(path.join(tmpProject, '.trailhead'), { recursive: true });
73
+ fs.writeFileSync(path.join(tmpProject, '.trailhead', 'config.json'),
74
+ JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
75
+
76
+ const noticeOut = String(execFileSync(process.execPath, [resolved, 'check', tmpProject], { encoding: 'utf8' }));
77
+ ok(`${label}: CLI check prints a notice`, noticeOut.trim().length > 0 && noticeOut.includes('models.plan'));
78
+
79
+ execFileSync(process.execPath, [resolved, 'ack', tmpProject], { encoding: 'utf8' });
80
+ const afterAckOut = String(execFileSync(process.execPath, [resolved, 'check', tmpProject], { encoding: 'utf8' }));
81
+ ok(`${label}: CLI check prints nothing after CLI ack`, afterAckOut === '');
82
+ }
83
+
84
+ // Source/plugin layout itself (not an installed copy), so drift is caught
85
+ // even before an install is exercised.
86
+ assertModelDefaultsLibLayout('source', path.join(repoRoot, 'plugins', 'trailhead', 'skills', '_shared'));
87
+
55
88
  // --- codex install -----------------------------------------------------------
56
89
  const codexDir = mktmp();
57
- runInstaller([`--codex`, `--dir=${codexDir}`]);
90
+ const codexInstallOut = String(runInstaller([`--codex`, `--dir=${codexDir}`]));
58
91
 
59
92
  const skillMainPath = path.join(codexDir, 'skills', 'trailhead', 'SKILL.md');
60
93
  ok('codex: SKILL.md exists', fs.existsSync(skillMainPath));
@@ -85,6 +118,12 @@ ok('codex: commit-msg template projected', fs.existsSync(codexTemplate));
85
118
  ok('codex: commit-msg template is verbatim (not converted)',
86
119
  fs.readFileSync(codexTemplate, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg'), 'utf8'));
87
120
 
121
+ // #185: the commit-msg hook sync script ships wholesale with templates/ too.
122
+ const codexCommitMsgSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
123
+ ok('codex: commit-msg-sync script projected', fs.existsSync(codexCommitMsgSync));
124
+ ok('codex: commit-msg-sync script is byte-identical to source',
125
+ fs.readFileSync(codexCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
126
+
88
127
  ok('codex: no commands dir', !fs.existsSync(path.join(codexDir, 'commands')));
89
128
  ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.json')));
90
129
  // #46: per-verb discoverability now projects one Codex SKILL per verb
@@ -206,8 +245,8 @@ ok('source: trailhead-chart SKILL.md config-offer points at the next-step block'
206
245
  // (session-handoff.md legitimately offers /trailhead:quick <n> for the next loose ticket).
207
246
  const captureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
208
247
  ok('source: capture.md confirmation anchors the next step to /trailhead:work', /[Aa]nchor the next step to `\/trailhead:work <n>`, always/.test(captureSource));
209
- ok('source: capture.md demotes /trailhead:quick to only the no-split alternative, after the work anchor',
210
- /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is \*\*only\*\* the no-split alternative/.test(captureSource));
248
+ ok('source: capture.md offers /trailhead:quick as the lighter alternative, after the work anchor',
249
+ /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is the \*\*lighter alternative\*\*/.test(captureSource));
211
250
  ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
212
251
  ok('source: capture.md keeps /trailhead:work the anchor even for a whiteboard capture', /whiteboard[\s\S]{0,200}the anchor stays `\/trailhead:work <n>`/.test(captureSource));
213
252
 
@@ -238,6 +277,180 @@ ok('source: capture.md says a map ticket stays on its map', /a map ticket stays
238
277
  const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
239
278
  ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
240
279
 
280
+ // --- quick is the lighter path, never "just without splitting" --------------
281
+ // Source-level invariant: wherever a next-step suggestion characterizes quick,
282
+ // it names what quick is for (the lighter path: always size-triaged, offers to
283
+ // skip the heavy steps on a small change, no map book-keeping), with no-split
284
+ // as a side note. The old "only the no-split alternative" / "same work as
285
+ // work, without split" framing made agents tell users quick merely avoids
286
+ // splitting.
287
+ const dispatcherSourceForQuick = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead', 'SKILL.md'), 'utf8');
288
+ ok('source: capture.md drops the "only the no-split alternative" framing', !/only\*\* the no-split alternative/.test(captureSource));
289
+ ok('source: session-handoff.md drops the "as the no-split alternative" framing', !sessionHandoffSource.includes('as the no-split alternative'));
290
+ ok('source: dispatcher SKILL.md drops the "as the no-split alternative" framing', !dispatcherSourceForQuick.includes('as the no-split alternative'));
291
+ ok('source: session-handoff.md example drops "lo stesso lavoro di `work`, senza split"', !sessionHandoffSource.includes('lo stesso lavoro di `work`, senza split'));
292
+ ok('source: capture.md says quick offers to skip the heavy steps on a small change', /quick <n>`[\s\S]{0,400}skip the heavy steps/.test(captureSource));
293
+ ok('source: capture.md forbids describing quick as merely avoiding a split', /never as merely "without splitting"/.test(captureSource));
294
+ ok('source: session-handoff.md forbids describing quick as merely avoiding a split', /never as merely "without splitting"/.test(sessionHandoffSource));
295
+ ok('source: session-handoff.md example renders quick as the lighter path', /> - oppure `\/trailhead:quick[^\n]*percorso leggero/.test(sessionHandoffSource));
296
+
297
+ // --- #181: scope code review and verify to the ticket's own commits ---------
298
+ // Source-level invariant: Code review and Goal-backward verification scope to
299
+ // the commits carrying this ticket's `Refs: #<n>` trailer (the "Ticket diff"
300
+ // rule), never a `<base>...HEAD` range, which under `git: main` sweeps in
301
+ // other tickets' interleaved commits. code-review.md is canonical; verify.md
302
+ // restates the same pinned command inline. Also a behavioral pin: run the
303
+ // extracted command against a scratch repo with decoys, an interleaved
304
+ // foreign commit, and a conflicting trunk-sync merge.
305
+ const codeReviewSourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md');
306
+ const codeReviewSource181 = fs.readFileSync(codeReviewSourcePath181, 'utf8');
307
+ const verifySourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques', 'verify.md');
308
+ const verifySource181 = fs.readFileSync(verifySourcePath181, 'utf8');
309
+ const techniquesSourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques.md');
310
+ const techniquesSource181 = fs.readFileSync(techniquesSourcePath181, 'utf8');
311
+ const ticketEnginesSource181 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
312
+
313
+ ok("code-review.md states the Ticket diff heading sentence",
314
+ codeReviewSource181.includes("**Ticket diff: review the ticket's own commits, never a range.**"));
315
+ ok('code-review.md contains the exact per-commit read form',
316
+ codeReviewSource181.includes('git -C <repo> show --remerge-diff <sha>'));
317
+ ok('code-review.md does NOT contain the old range-scoped diff', !codeReviewSource181.includes('git diff <base>...HEAD'));
318
+ ok('code-review.md does NOT contain the old "pin a fixed point" phrasing', !codeReviewSource181.includes('pin a fixed point'));
319
+ ok('code-review.md states an empty set is an error', /\*\*An empty set is an error\*\*/.test(codeReviewSource181));
320
+ ok('code-review.md says the set is recomputed every round and every verify run',
321
+ codeReviewSource181.includes('Recompute the set on every review round and every verify run'));
322
+ ok('code-review.md says the set is never collapsed into one span',
323
+ codeReviewSource181.includes('Never collapse the set into one'));
324
+ ok('code-review.md mentions git: pr', codeReviewSource181.includes('git: pr'));
325
+ ok('code-review.md mentions trailhead/t<n>', codeReviewSource181.includes('trailhead/t<n>'));
326
+
327
+ const ticketDiffLogCmdRegex = /git -C <repo> log [^\n`]*HEAD/;
328
+ const codeReviewCmdMatch181 = codeReviewSource181.match(ticketDiffLogCmdRegex);
329
+ ok('code-review.md contains the pinned Ticket diff log command', !!codeReviewCmdMatch181);
330
+ const codeReviewLogCmd181 = codeReviewCmdMatch181 ? codeReviewCmdMatch181[0] : '';
331
+
332
+ ok('verify.md contains the same per-commit read form as code-review.md',
333
+ verifySource181.includes('git -C <repo> show --remerge-diff <sha>'));
334
+ ok('verify.md references the Ticket diff', verifySource181.includes('**Ticket diff**'));
335
+ ok('verify.md references code-review.md as canonical', verifySource181.includes('code-review.md'));
336
+ ok('verify.md does NOT contain the old range-scoped diff', !verifySource181.includes('git diff <base>...HEAD'));
337
+
338
+ const verifyCmdMatch181 = verifySource181.match(ticketDiffLogCmdRegex);
339
+ ok('verify.md contains the pinned Ticket diff log command', !!verifyCmdMatch181);
340
+ const verifyLogCmd181 = verifyCmdMatch181 ? verifyCmdMatch181[0] : '';
341
+ ok('code-review.md and verify.md state the exact same log command',
342
+ codeReviewLogCmd181 !== '' && codeReviewLogCmd181 === verifyLogCmd181);
343
+
344
+ const verifyStepLines181 = ticketEnginesSource181.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
345
+ ok('ticket-engines.md has exactly two Verify (step 4) lines', verifyStepLines181.length === 2);
346
+ ok('both Verify step lines reference the Ticket diff', verifyStepLines181.every((l) => l.includes('**Ticket diff**')));
347
+ ok('ticket-engines.md does NOT contain the old range-scoped diff', !ticketEnginesSource181.includes('git diff <base>...HEAD'));
348
+
349
+ ok("techniques.md index row updates Code review to the ticket's own commits",
350
+ techniquesSource181.includes("review the ticket's own commits (its `Refs: #<n>` set) on 4 axes"));
351
+
352
+ for (const [label, src] of [
353
+ ['code-review.md', codeReviewSource181],
354
+ ['verify.md', verifySource181],
355
+ ['ticket-engines.md', ticketEnginesSource181],
356
+ ['techniques.md', techniquesSource181],
357
+ ]) {
358
+ ok(`${label} has no em-dash`, !src.includes('\u2014'));
359
+ }
360
+
361
+ // --- #181 behavioral pin: build a scratch repo and run the extracted command -
362
+ function gitEnv181(extra = {}) {
363
+ return {
364
+ ...process.env,
365
+ GIT_CONFIG_GLOBAL: '/dev/null',
366
+ GIT_CONFIG_NOSYSTEM: '1',
367
+ GIT_AUTHOR_NAME: 'Trailhead Test',
368
+ GIT_AUTHOR_EMAIL: 'trailhead-test@example.com',
369
+ GIT_COMMITTER_NAME: 'Trailhead Test',
370
+ GIT_COMMITTER_EMAIL: 'trailhead-test@example.com',
371
+ LC_ALL: 'C',
372
+ ...extra,
373
+ };
374
+ }
375
+ function git181(dir, args, extraEnv = {}) {
376
+ return execFileSync('git', ['-C', dir, ...args], { env: gitEnv181(extraEnv), stdio: ['ignore', 'pipe', 'pipe'] }).toString();
377
+ }
378
+ function commit181(dir, message, dateStr) {
379
+ git181(dir, ['add', '-A']);
380
+ git181(dir, ['commit', '-m', message], { GIT_AUTHOR_DATE: dateStr, GIT_COMMITTER_DATE: dateStr });
381
+ return git181(dir, ['rev-parse', 'HEAD']).trim();
382
+ }
383
+
384
+ const t181Repo = mktmp();
385
+ git181(t181Repo, ['init', '-b', 'main']);
386
+ const fFile181 = path.join(t181Repo, 'f.txt');
387
+ const D181 = (n) => `2026-01-01T00:00:${String(n).padStart(2, '0')}Z`;
388
+
389
+ fs.writeFileSync(fFile181, 'a\n');
390
+ const shaA181 = commit181(t181Repo, 'feat: a\n\nRefs: #18', D181(1));
391
+
392
+ fs.writeFileSync(path.join(t181Repo, 'decoy-b.txt'), 'b\n');
393
+ commit181(t181Repo, 'feat: b\n\nRefs: #181', D181(2));
394
+
395
+ fs.writeFileSync(path.join(t181Repo, 'decoy-c.txt'), 'c\n');
396
+ commit181(t181Repo, 'feat: c\n\nRefs: #1800', D181(3));
397
+
398
+ fs.writeFileSync(path.join(t181Repo, 'decoy-d.txt'), 'd\n');
399
+ commit181(t181Repo, 'feat: d\n\nmentions Refs: #18 inline\nRefs: #99', D181(4));
400
+
401
+ fs.writeFileSync(fFile181, 'foreign\n');
402
+ commit181(t181Repo, 'feat: foreign\n\nRefs: #99', D181(5));
403
+
404
+ git181(t181Repo, ['checkout', '-b', 'side']);
405
+ fs.writeFileSync(fFile181, 'side-edit\n');
406
+ fs.writeFileSync(path.join(t181Repo, 'foreign.txt'), 'foreign-file\n');
407
+ commit181(t181Repo, 'chore: side edit', D181(6));
408
+
409
+ git181(t181Repo, ['checkout', 'main']);
410
+ fs.writeFileSync(fFile181, 'main-edit\n');
411
+ const shaF181 = commit181(t181Repo, 'fix: e\n\nRefs: #18', D181(7));
412
+
413
+ let mergeConflicted181 = false;
414
+ try {
415
+ git181(t181Repo, ['merge', 'side', '--no-commit', '--no-ff']);
416
+ } catch {
417
+ mergeConflicted181 = true;
418
+ }
419
+ ok('#181 scratch repo: the trunk-sync merge actually conflicts on f.txt', mergeConflicted181);
420
+ fs.writeFileSync(fFile181, 'RESOLVED\n');
421
+ git181(t181Repo, ['add', 'f.txt', 'foreign.txt']);
422
+ const shaMerge181 = commit181(t181Repo, 'merge: sync\n\nRefs: #18', D181(8));
423
+
424
+ ok('#181 scratch repo: merge sha differs from the fix sha', shaMerge181 !== shaF181);
425
+
426
+ function runShellGit181(cmd, extraEnv = {}) {
427
+ return execFileSync('sh', ['-c', cmd], { env: gitEnv181(extraEnv), stdio: ['ignore', 'pipe', 'pipe'] }).toString();
428
+ }
429
+
430
+ const substituted181 = (cmd, n) => cmd.replace(/<repo>/g, JSON.stringify(t181Repo)).replace(/<n>/g, String(n));
431
+
432
+ const fixedPatternTypeEnv181 = {
433
+ GIT_CONFIG_COUNT: '1',
434
+ GIT_CONFIG_KEY_0: 'grep.patternType',
435
+ GIT_CONFIG_VALUE_0: 'fixed',
436
+ };
437
+
438
+ const out18_181 = runShellGit181(substituted181(codeReviewLogCmd181, 18), fixedPatternTypeEnv181);
439
+ const lines18_181 = out18_181.split('\n').filter((l) => l.length > 0);
440
+ ok('#181 log command returns exactly [a, f, merge] oldest-first for #18, even under grep.patternType=fixed',
441
+ lines18_181.length === 3 && lines18_181[0] === shaA181 && lines18_181[1] === shaF181 && lines18_181[2] === shaMerge181);
442
+
443
+ const out7_181 = runShellGit181(substituted181(codeReviewLogCmd181, 7), fixedPatternTypeEnv181);
444
+ ok('#181 log command returns empty output for a ticket with no commits', out7_181.trim() === '');
445
+
446
+ const remergeCmd181 = 'git -C <repo> show --remerge-diff <sha>'
447
+ .replace('<repo>', JSON.stringify(t181Repo))
448
+ .replace('<sha>', shaMerge181);
449
+ const remergeOut181 = runShellGit181(remergeCmd181);
450
+ ok('#181 --remerge-diff on the merge shows the resolution hunk', remergeOut181.includes('RESOLVED'));
451
+ ok('#181 --remerge-diff on the merge does NOT show the foreign file the merge pulled in cleanly',
452
+ !remergeOut181.includes('foreign.txt'));
453
+
241
454
  // --- #151: eager edge drop at close (superseded / out-of-scope only) ---------
242
455
  // Source-level invariant: closing a ticket as trailhead:superseded or
243
456
  // trailhead:out-of-scope drops its native sub-issue edge at close (reusing the
@@ -459,6 +672,10 @@ ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-gua
459
672
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
460
673
  ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
461
674
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
675
+ ok('codex: skills/trailhead/hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
676
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults-review.js')));
677
+ ok('codex: skills/trailhead/hooks/lib/model-defaults.json exists (#186)',
678
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults.json')));
462
679
  // #169: search-guard is Claude-Code-specific (it only prevents arming
463
680
  // Claude's Read() deny rule under bypass permissions, a mechanism Codex
464
681
  // lacks), so it must neither be registered nor copied on a Codex install.
@@ -485,6 +702,8 @@ ok('codex: trailhead-codebase-map.toml is projected with no models.codex.* set',
485
702
  ok('codex: trailhead-fix.toml is pin-less (no model = line) with no models.codex.* set',
486
703
  fs.existsSync(noPinFixTomlPath) && !/^model = /m.test(fs.readFileSync(noPinFixTomlPath, 'utf8')));
487
704
 
705
+ assertModelDefaultsLibLayout('codex copy', path.join(codexDir, 'skills', '_shared'));
706
+
488
707
  // --- codex --symlink: link verbatim artifacts (hooks + templates), keep skills projected ---
489
708
  const codexSymDir = mktmp();
490
709
  runInstaller([`--codex`, `--symlink`, `--dir=${codexSymDir}`]);
@@ -509,6 +728,8 @@ ok('codex copy: hooks are regular files (not symlinks)',
509
728
  ok('codex copy: templates is a regular dir (not a symlink)',
510
729
  !fs.lstatSync(path.join(codexDir, 'skills', 'trailhead', 'templates')).isSymbolicLink());
511
730
 
731
+ assertModelDefaultsLibLayout('codex symlink', path.join(codexSymDir, 'skills', '_shared'));
732
+
512
733
  // --- codex agent TOML projection (#38, #88) -------------------------------------
513
734
  // With NO models.codex.* set, a codex install still projects all 8 agents
514
735
  // uniformly, pin-less (uniform projection, #88), never zero. Install from an
@@ -574,7 +795,7 @@ ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
574
795
 
575
796
  // --- claude regression --------------------------------------------------------
576
797
  const claudeDir = mktmp();
577
- runInstaller([`--claude`, `--dir=${claudeDir}`]);
798
+ const claudeInstallOut = String(runInstaller([`--claude`, `--dir=${claudeDir}`]));
578
799
 
579
800
  const claudeSkillPath = path.join(claudeDir, 'skills', 'trailhead', 'SKILL.md');
580
801
  ok('claude: skills/trailhead/SKILL.md exists', fs.existsSync(claudeSkillPath));
@@ -602,6 +823,16 @@ ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require targe
602
823
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
603
824
  ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
604
825
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
826
+ ok('claude: hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
827
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults-review.js')));
828
+ ok('claude: hooks/lib/model-defaults.json exists (#186)',
829
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults.json')));
830
+
831
+ // #185: the commit-msg hook sync script ships wholesale with templates/ too.
832
+ const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
833
+ ok('claude: commit-msg-sync script projected', fs.existsSync(claudeCommitMsgSync));
834
+ ok('claude: commit-msg-sync script is byte-identical to source',
835
+ fs.readFileSync(claudeCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
605
836
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
606
837
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
607
838
  // payload and assert it does not crash with a missing-module error.
@@ -631,6 +862,8 @@ ok('claude: agent file registers its subagent name in frontmatter',
631
862
  ok('claude: agents are real files on a copy install (not symlinks)',
632
863
  !fs.lstatSync(path.join(claudeAgentsDir, 'trailhead-plan.md')).isSymbolicLink());
633
864
 
865
+ assertModelDefaultsLibLayout('claude copy', path.join(claudeDir, 'skills', '_shared'));
866
+
634
867
  // --- claude --symlink: dev install links hooks live too (not just skills/commands) ---
635
868
  const symDir = mktmp();
636
869
  runInstaller([`--claude`, `--symlink`, `--dir=${symDir}`]);
@@ -664,6 +897,8 @@ ok('claude symlink: agent symlink resolves into the package source',
664
897
  ok('claude copy: hooks/trailhead-secret-guard.js is a regular file (not a symlink)',
665
898
  !fs.lstatSync(path.join(claudeDir, 'hooks', 'trailhead-secret-guard.js')).isSymbolicLink());
666
899
 
900
+ assertModelDefaultsLibLayout('claude symlink', path.join(symDir, 'skills', '_shared'));
901
+
667
902
  // --- claude migration: reinstall over an old layout sweeps stale skill dirs ---
668
903
  // An old install may carry a pre-split monolith (skills/trailhead-monolith) or a
669
904
  // removed cluster (skills/trailhead-legacy) the current install no longer ships.
@@ -859,6 +1094,267 @@ try {
859
1094
  ok('unknown flag after a valid flag: still rejected', unknownAfterValidRejected);
860
1095
  ok('unknown flag after a valid flag: installs nothing', nothingInstalledUnder(unknownAfterValidDir));
861
1096
 
1097
+ // --- #185: teamwork.md/substrate-commands.md/trailhead-work SKILL.md point at
1098
+ // the commit-msg-sync script instead of the old manual copy/chmod steps -------
1099
+ ok('source: teamwork.md no longer says an existing hook is never replaced',
1100
+ !teamworkSource.includes('an existing hook is never replaced'));
1101
+ ok('source: teamwork.md no longer says reinstalled by hand',
1102
+ !teamworkSource.includes("until it's reinstalled by hand"));
1103
+ for (const [label, src] of [
1104
+ ['teamwork.md', teamworkSource],
1105
+ ['substrate-commands.md', substrateCommandsSource],
1106
+ ['trailhead-work/SKILL.md', workSkillSource],
1107
+ ]) {
1108
+ ok(`source: ${label} references trailhead-commit-msg-sync.js`, src.includes('trailhead-commit-msg-sync.js'));
1109
+ ok(`source: ${label} has no em-dash`, !src.includes('—'));
1110
+ }
1111
+
1112
+ // --- #189: script mancante / errore node -> fallback che ispeziona la hooks dir
1113
+ // invece di dichiarare alla cieca "git non applica le regole" ------------------
1114
+ ok('source: teamwork.md fallback resolves the hooks dir via rev-parse --git-path hooks',
1115
+ teamworkSource.includes('git -C <working-root> rev-parse --git-path hooks'));
1116
+ ok('source: teamwork.md fallback classifies by the trailhead header line',
1117
+ teamworkSource.includes('// trailhead commit-msg hook (git).'));
1118
+ ok('source: teamwork.md fallback checks the executable bit (test -x)',
1119
+ teamworkSource.includes('test -x'));
1120
+ ok('source: teamwork.md fallback reads only the first 5 lines',
1121
+ teamworkSource.includes('first 5 lines'));
1122
+ ok('source: teamwork.md fallback treats a symlinked commit-msg as foreign',
1123
+ teamworkSource.includes('symlinked `commit-msg`'));
1124
+ for (const [label, src] of [
1125
+ ['trailhead-work/SKILL.md', workSkillSource],
1126
+ ['substrate-commands.md', substrateCommandsSource],
1127
+ ]) {
1128
+ ok(`source: ${label} mentions the missing-script hooks-dir fallback`,
1129
+ src.includes('rev-parse --git-path hooks') && /missing script or a node error|script is missing or node errors/.test(src));
1130
+ ok(`source: ${label} has no em-dash (#189)`, !src.includes('—'));
1131
+ }
1132
+ ok('source: teamwork.md has no em-dash (#189)', !teamworkSource.includes('—'));
1133
+
1134
+ // --- #182 ---------------------------------------------------------------------
1135
+ // A broken install fails loudly (verifyInstall names the gap and the run exits
1136
+ // non-zero, printing no "✓ trailhead installed"); a clean one still prints ✓.
1137
+ // brokenPkg copies bin/ + plugins/ into a fresh tmp root (so __dirname-relative
1138
+ // PKG/SRC resolution in trailhead.js still lines up) and applies a mutation
1139
+ // that breaks one source artifact; runPkg drives that copy (or the real repo)
1140
+ // through execFileSync without throwing on a non-zero exit.
1141
+ function brokenPkg(mutate) {
1142
+ const pkgDir = mktmp();
1143
+ fs.cpSync(path.join(repoRoot, 'bin'), path.join(pkgDir, 'bin'), { recursive: true });
1144
+ fs.cpSync(path.join(repoRoot, 'plugins'), path.join(pkgDir, 'plugins'), { recursive: true });
1145
+ if (mutate) mutate(pkgDir);
1146
+ return pkgDir;
1147
+ }
1148
+ function runPkg(pkgDir, args, env) {
1149
+ try {
1150
+ const stdout = execFileSync(process.execPath, [path.join(pkgDir, 'bin', 'trailhead.js'), ...args], {
1151
+ cwd: pkgDir, stdio: 'pipe', env: env || process.env,
1152
+ });
1153
+ return { status: 0, stdout: String(stdout), stderr: '' };
1154
+ } catch (e) {
1155
+ return { status: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') };
1156
+ }
1157
+ }
1158
+
1159
+ // (a) no engine agents at all: both hosts refuse and print no success line.
1160
+ {
1161
+ const noAgentsPkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'agents'), { recursive: true, force: true }));
1162
+ const claudeOut = runPkg(noAgentsPkg, ['--claude', `--dir=${mktmp()}`]);
1163
+ ok('(a) claude, no agents: non-zero exit', claudeOut.status !== 0);
1164
+ ok('(a) claude, no agents: stderr names agents', claudeOut.stderr.includes('agents'));
1165
+ ok('(a) claude, no agents: stdout has no ✓ trailhead installed', !claudeOut.stdout.includes('✓ trailhead installed'));
1166
+ const codexOut = runPkg(noAgentsPkg, ['--codex', `--dir=${mktmp()}`]);
1167
+ ok('(a) codex, no agents: non-zero exit', codexOut.status !== 0);
1168
+ ok('(a) codex, no agents: stderr names agents', codexOut.stderr.includes('agents'));
1169
+ ok('(a) codex, no agents: stdout has no ✓ trailhead installed', !codexOut.stdout.includes('✓ trailhead installed'));
1170
+ }
1171
+
1172
+ // (b) a cluster's SKILL.md is missing from the source.
1173
+ {
1174
+ const pkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'skills', 'trailhead-view', 'SKILL.md'), { force: true }));
1175
+ const out = runPkg(pkg, ['--claude', `--dir=${mktmp()}`]);
1176
+ ok('(b) missing cluster SKILL.md: non-zero exit', out.status !== 0);
1177
+ ok('(b) missing cluster SKILL.md: stderr names trailhead-view/SKILL.md', out.stderr.includes('trailhead-view/SKILL.md'));
1178
+ }
1179
+
1180
+ // (c) the shared core is missing a file.
1181
+ {
1182
+ const pkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'skills', '_shared', 'substrate.md'), { force: true }));
1183
+ const out = runPkg(pkg, ['--claude', `--dir=${mktmp()}`]);
1184
+ ok('(c) missing _shared/substrate.md: non-zero exit', out.status !== 0);
1185
+ ok('(c) missing _shared/substrate.md: stderr names _shared/substrate.md', out.stderr.includes('_shared/substrate.md'));
1186
+ }
1187
+
1188
+ // (d) a hook script source is missing, fresh dir: named cleanly, no ENOENT stack.
1189
+ const missingBodyGuardPkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'hooks', 'trailhead-body-guard.js'), { force: true }));
1190
+ {
1191
+ const out = runPkg(missingBodyGuardPkg, ['--claude', `--dir=${mktmp()}`]);
1192
+ ok('(d) missing hook source, fresh dir: non-zero exit', out.status !== 0);
1193
+ ok('(d) missing hook source, fresh dir: stderr names trailhead-body-guard.js', out.stderr.includes('trailhead-body-guard.js'));
1194
+ ok('(d) missing hook source, fresh dir: no ENOENT stack', !out.stderr.includes('ENOENT'));
1195
+ }
1196
+
1197
+ // (e) reinstalling that same broken package over a prior clean install removes
1198
+ // the stale hook file rather than leaving it behind, on both hosts.
1199
+ {
1200
+ const xDir = mktmp();
1201
+ runInstaller([`--claude`, `--dir=${xDir}`]);
1202
+ const out = runPkg(missingBodyGuardPkg, ['--claude', `--dir=${xDir}`]);
1203
+ ok('(e) claude reinstall over an old install: non-zero exit', out.status !== 0);
1204
+ ok('(e) claude reinstall: stderr names trailhead-body-guard.js', out.stderr.includes('trailhead-body-guard.js'));
1205
+ ok('(e) claude reinstall: the stale hook file is gone', !fs.existsSync(path.join(xDir, 'hooks', 'trailhead-body-guard.js')));
1206
+
1207
+ const wDir = mktmp();
1208
+ runInstaller([`--codex`, `--dir=${wDir}`]);
1209
+ const outCodex = runPkg(missingBodyGuardPkg, ['--codex', `--dir=${wDir}`]);
1210
+ ok('(e) codex reinstall over an old install: non-zero exit', outCodex.status !== 0);
1211
+ ok('(e) codex reinstall: stderr names trailhead-body-guard.js', outCodex.stderr.includes('trailhead-body-guard.js'));
1212
+ ok('(e) codex reinstall: the stale hook file is gone', !fs.existsSync(path.join(wDir, 'skills', 'trailhead', 'hooks', 'trailhead-body-guard.js')));
1213
+ }
1214
+
1215
+ // (f) a stale Claude registration in settings.json (target gone).
1216
+ {
1217
+ const yDir = mktmp();
1218
+ runInstaller([`--claude`, `--dir=${yDir}`]);
1219
+ const settingsPath = path.join(yDir, 'settings.json');
1220
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1221
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1222
+ { type: 'command', command: `node "${path.join(yDir, 'hooks', 'trailhead-gone-guard.js')}"` },
1223
+ ] });
1224
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1225
+ const out = runPkg(repoRoot, [`--claude`, `--dir=${yDir}`]);
1226
+ ok('(f) stale Claude registration: non-zero exit', out.status !== 0);
1227
+ ok('(f) stale Claude registration: stderr names the stale target', out.stderr.includes('trailhead-gone-guard.js'));
1228
+ }
1229
+
1230
+ // (g) a stale generic Codex registration in hooks.json (target gone).
1231
+ {
1232
+ const wgDir = mktmp();
1233
+ runInstaller([`--codex`, `--dir=${wgDir}`]);
1234
+ const hooksJsonPath = path.join(wgDir, 'hooks.json');
1235
+ const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
1236
+ hooksJson.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1237
+ { type: 'command', command: `node "${path.join(wgDir, 'skills', 'trailhead', 'hooks', 'trailhead-gone-guard.js')}"` },
1238
+ ] });
1239
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
1240
+ const out = runPkg(repoRoot, [`--codex`, `--dir=${wgDir}`]);
1241
+ ok('(g) stale Codex registration: non-zero exit', out.status !== 0);
1242
+ ok('(g) stale Codex registration: stderr names the stale target', out.stderr.includes('trailhead-gone-guard.js'));
1243
+ ok('(g) stale Codex registration: stderr names hooks.json', out.stderr.includes('hooks.json'));
1244
+ }
1245
+
1246
+ // (h) the pre-#169 search-guard registration migrates away silently on reinstall.
1247
+ // May already pass before the installer wires verifyInstall in (no check exists
1248
+ // yet to trip on it); it stays here to pin the migration's intent regardless.
1249
+ {
1250
+ const zDir = mktmp();
1251
+ runInstaller([`--codex`, `--dir=${zDir}`]);
1252
+ const hooksJsonPath = path.join(zDir, 'hooks.json');
1253
+ const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
1254
+ hooksJson.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1255
+ { type: 'command', command: `node "${path.join(zDir, 'skills', 'trailhead', 'hooks', 'trailhead-search-guard.js')}"` },
1256
+ ] });
1257
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
1258
+ const out = runPkg(repoRoot, [`--codex`, `--dir=${zDir}`]);
1259
+ ok('(h) pre-#169 search-guard migration: exit 0', out.status === 0);
1260
+ ok('(h) pre-#169 search-guard migration: prints ✓', out.stdout.includes('✓'));
1261
+ const afterHooksJson = fs.readFileSync(hooksJsonPath, 'utf8');
1262
+ ok('(h) pre-#169 search-guard migration: the stale entry is gone', !afterHooksJson.includes('trailhead-search-guard.js'));
1263
+ }
1264
+
1265
+ // (i) an unresolved env var on a registered target warns instead of failing.
1266
+ {
1267
+ const vDir = mktmp();
1268
+ runInstaller([`--claude`, `--dir=${vDir}`]);
1269
+ const settingsPath = path.join(vDir, 'settings.json');
1270
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1271
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1272
+ { type: 'command', command: 'node "${TRAILHEAD_T182_UNSET}/hooks/trailhead-x-guard.js"' },
1273
+ ] });
1274
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1275
+ // Delete the key outright (never set it to undefined), so the child process
1276
+ // never receives a literal "undefined" string for it.
1277
+ const envWithoutVar = { ...process.env };
1278
+ delete envWithoutVar.TRAILHEAD_T182_UNSET;
1279
+ const out = runPkg(repoRoot, [`--claude`, `--dir=${vDir}`], envWithoutVar);
1280
+ ok('(i) unresolved env var: exit 0', out.status === 0);
1281
+ ok('(i) unresolved env var: prints ✓', out.stdout.includes('✓'));
1282
+ ok('(i) unresolved env var: stdout warns ⚠ unverified', out.stdout.includes('⚠ unverified'));
1283
+ ok('(i) unresolved env var: stdout names the unresolved variable', out.stdout.includes('TRAILHEAD_T182_UNSET'));
1284
+ }
1285
+
1286
+ // (j) regression: the existing clean installs at the top of this file still
1287
+ // print ✓ with no ⚠ unverified.
1288
+ ok('(j) claude clean install: prints ✓ trailhead installed', claudeInstallOut.includes('✓ trailhead installed'));
1289
+ ok('(j) claude clean install: no ⚠ unverified', !claudeInstallOut.includes('⚠ unverified'));
1290
+ ok('(j) codex clean install: prints ✓ trailhead installed', codexInstallOut.includes('✓ trailhead installed'));
1291
+ ok('(j) codex clean install: no ⚠ unverified', !codexInstallOut.includes('⚠ unverified'));
1292
+
1293
+ // --- #183: severity counts and per-finding disposition gate on Resolve -------
1294
+ // Code review classifies findings as Critical/Warning/Info, but only Criticals
1295
+ // gated resolution and Warnings/Info were never tracked to a decision. Pin the
1296
+ // counts line under `status:`, the disposition vocabulary, and the Resolve gate
1297
+ // that requires every counted finding to carry one before a ticket can close.
1298
+ const codeReviewSourcePath183 = path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md');
1299
+ const codeReviewSource183 = fs.readFileSync(codeReviewSourcePath183, 'utf8');
1300
+ const ticketEnginesSource183 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
1301
+
1302
+ ok('code-review.md states the counts format right under the status tag',
1303
+ codeReviewSource183.includes('critical: <n> · warning: <n> · info: <n>'));
1304
+ ok('code-review.md says the counts line sits right under the status tag',
1305
+ /right under[^.]*`status:`/.test(codeReviewSource183) || /`status:`[^.]*right under/.test(codeReviewSource183));
1306
+
1307
+ ok('code-review.md names the fixed disposition', codeReviewSource183.includes('`fixed`'));
1308
+ ok('code-review.md names the deferred disposition', codeReviewSource183.includes('`deferred → <ticket>`'));
1309
+ ok('code-review.md names the accepted disposition', codeReviewSource183.includes('`accepted: <one-line reason>`'));
1310
+ ok('code-review.md names the transient open disposition', codeReviewSource183.includes('`open`'));
1311
+
1312
+ ok('code-review.md says finding IDs are stable across rounds',
1313
+ /stable/i.test(codeReviewSource183) && /across (every|fix-and-re-review) round/i.test(codeReviewSource183));
1314
+ ok('code-review.md says the counts are cumulative', codeReviewSource183.includes('cumulative'));
1315
+ ok('code-review.md says no finding reaches Resolve with open or without a disposition',
1316
+ /no finding reaches Resolve with[^.]*`open`[^.]*without a disposition/.test(codeReviewSource183));
1317
+
1318
+ ok('code-review.md no longer defers Warnings/Info as "noted for the caller to weigh"',
1319
+ !codeReviewSource183.includes('noted for the caller to weigh'));
1320
+
1321
+ const verifyStepLines183 = ticketEnginesSource183.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
1322
+ ok('ticket-engines.md has exactly two Verify (step 4) lines', verifyStepLines183.length === 2);
1323
+ ok('both Verify step lines mention the counts line',
1324
+ verifyStepLines183.every((l) => l.includes('counts line')));
1325
+
1326
+ const resolveStepLines183 = ticketEnginesSource183.split('\n').filter((l) => l.trim().startsWith('5. **Resolve**'));
1327
+ ok('ticket-engines.md has exactly two Resolve (step 5) lines', resolveStepLines183.length === 2);
1328
+ ok('both Resolve step lines carry the Disposition gate',
1329
+ resolveStepLines183.every((l) => l.includes('**Disposition gate (before the close)**')));
1330
+
1331
+ for (const [label, src] of [
1332
+ ['code-review.md', codeReviewSource183],
1333
+ ['ticket-engines.md', ticketEnginesSource183],
1334
+ ]) {
1335
+ ok(`${label} has no em-dash (#183)`, !src.includes('\u2014'));
1336
+ }
1337
+
1338
+ // --- plan-review: "run until it converges" option at the rounds-exhausted ask ---
1339
+ const planReviewSourceConv = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques', 'plan-review.md'), 'utf8');
1340
+ const planReviewStep4 = planReviewSourceConv.split('\n').find((l) => l.startsWith('4. **Record and decide.**')) || '';
1341
+ ok('plan-review.md step 4 still offers another bounded review cycle', planReviewStep4.includes('run another review cycle'));
1342
+ ok('plan-review.md step 4 offers running until the plan converges', /\(d\) \*\*run until it converges\*\*/.test(planReviewStep4));
1343
+ ok('plan-review.md converge option stops when no blocking concern remains', /run until it converges[\s\S]{0,400}no blocking concern remains/.test(planReviewStep4));
1344
+ ok('plan-review.md converge option has a no-progress guard that returns to this checkpoint',
1345
+ /run until it converges[\s\S]{0,600}no progress[\s\S]{0,300}back to this checkpoint/.test(planReviewStep4));
1346
+ ok('plan-review.md step 4 has no em-dash', !planReviewStep4.includes('\u2014'));
1347
+
1348
+ // --- code-review: "run until it converges" option at the rounds-exhausted ask ---
1349
+ const codeReviewSourceConv = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md'), 'utf8');
1350
+ const codeReviewBoundary = codeReviewSourceConv.split('\n').find((l) => l.startsWith('**Converge on the blockers')) || '';
1351
+ ok('code-review.md boundary ask still offers another 2 review rounds', codeReviewBoundary.includes('run another 2 review rounds'));
1352
+ ok('code-review.md boundary ask offers running until it converges', /\(c\) \*\*run until it converges\*\*/.test(codeReviewBoundary));
1353
+ ok('code-review.md converge option stops when no Critical remains', /run until it converges[\s\S]{0,400}no Critical remains/.test(codeReviewBoundary));
1354
+ ok('code-review.md converge option has a no-progress guard that returns to this checkpoint',
1355
+ /run until it converges[\s\S]{0,600}no progress[\s\S]{0,300}back to this checkpoint/.test(codeReviewBoundary));
1356
+ ok('code-review.md boundary ask has no em-dash', !codeReviewBoundary.includes('\u2014'));
1357
+
862
1358
  // --- cleanup -------------------------------------------------------------------
863
1359
  for (const d of tmpDirs) {
864
1360
  fs.rmSync(d, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
5
5
  "bin": {
6
6
  "trailhead": "bin/trailhead.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trailhead",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",