@marcomigozzi/trailhead 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,7 +54,7 @@ function fakeBinDir(names) {
54
54
 
55
55
  // --- codex install -----------------------------------------------------------
56
56
  const codexDir = mktmp();
57
- runInstaller([`--codex`, `--dir=${codexDir}`]);
57
+ const codexInstallOut = String(runInstaller([`--codex`, `--dir=${codexDir}`]));
58
58
 
59
59
  const skillMainPath = path.join(codexDir, 'skills', 'trailhead', 'SKILL.md');
60
60
  ok('codex: SKILL.md exists', fs.existsSync(skillMainPath));
@@ -85,6 +85,12 @@ ok('codex: commit-msg template projected', fs.existsSync(codexTemplate));
85
85
  ok('codex: commit-msg template is verbatim (not converted)',
86
86
  fs.readFileSync(codexTemplate, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg'), 'utf8'));
87
87
 
88
+ // #185: the commit-msg hook sync script ships wholesale with templates/ too.
89
+ const codexCommitMsgSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
90
+ ok('codex: commit-msg-sync script projected', fs.existsSync(codexCommitMsgSync));
91
+ ok('codex: commit-msg-sync script is byte-identical to source',
92
+ fs.readFileSync(codexCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
93
+
88
94
  ok('codex: no commands dir', !fs.existsSync(path.join(codexDir, 'commands')));
89
95
  ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.json')));
90
96
  // #46: per-verb discoverability now projects one Codex SKILL per verb
@@ -238,6 +244,163 @@ ok('source: capture.md says a map ticket stays on its map', /a map ticket stays
238
244
  const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
239
245
  ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
240
246
 
247
+ // --- #181: scope code review and verify to the ticket's own commits ---------
248
+ // Source-level invariant: Code review and Goal-backward verification scope to
249
+ // the commits carrying this ticket's `Refs: #<n>` trailer (the "Ticket diff"
250
+ // rule), never a `<base>...HEAD` range, which under `git: main` sweeps in
251
+ // other tickets' interleaved commits. code-review.md is canonical; verify.md
252
+ // restates the same pinned command inline. Also a behavioral pin: run the
253
+ // extracted command against a scratch repo with decoys, an interleaved
254
+ // foreign commit, and a conflicting trunk-sync merge.
255
+ const codeReviewSourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md');
256
+ const codeReviewSource181 = fs.readFileSync(codeReviewSourcePath181, 'utf8');
257
+ const verifySourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques', 'verify.md');
258
+ const verifySource181 = fs.readFileSync(verifySourcePath181, 'utf8');
259
+ const techniquesSourcePath181 = path.join(sourceSkillsDir, '_shared', 'techniques.md');
260
+ const techniquesSource181 = fs.readFileSync(techniquesSourcePath181, 'utf8');
261
+ const ticketEnginesSource181 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
262
+
263
+ ok("code-review.md states the Ticket diff heading sentence",
264
+ codeReviewSource181.includes("**Ticket diff: review the ticket's own commits, never a range.**"));
265
+ ok('code-review.md contains the exact per-commit read form',
266
+ codeReviewSource181.includes('git -C <repo> show --remerge-diff <sha>'));
267
+ ok('code-review.md does NOT contain the old range-scoped diff', !codeReviewSource181.includes('git diff <base>...HEAD'));
268
+ ok('code-review.md does NOT contain the old "pin a fixed point" phrasing', !codeReviewSource181.includes('pin a fixed point'));
269
+ ok('code-review.md states an empty set is an error', /\*\*An empty set is an error\*\*/.test(codeReviewSource181));
270
+ ok('code-review.md says the set is recomputed every round and every verify run',
271
+ codeReviewSource181.includes('Recompute the set on every review round and every verify run'));
272
+ ok('code-review.md says the set is never collapsed into one span',
273
+ codeReviewSource181.includes('Never collapse the set into one'));
274
+ ok('code-review.md mentions git: pr', codeReviewSource181.includes('git: pr'));
275
+ ok('code-review.md mentions trailhead/t<n>', codeReviewSource181.includes('trailhead/t<n>'));
276
+
277
+ const ticketDiffLogCmdRegex = /git -C <repo> log [^\n`]*HEAD/;
278
+ const codeReviewCmdMatch181 = codeReviewSource181.match(ticketDiffLogCmdRegex);
279
+ ok('code-review.md contains the pinned Ticket diff log command', !!codeReviewCmdMatch181);
280
+ const codeReviewLogCmd181 = codeReviewCmdMatch181 ? codeReviewCmdMatch181[0] : '';
281
+
282
+ ok('verify.md contains the same per-commit read form as code-review.md',
283
+ verifySource181.includes('git -C <repo> show --remerge-diff <sha>'));
284
+ ok('verify.md references the Ticket diff', verifySource181.includes('**Ticket diff**'));
285
+ ok('verify.md references code-review.md as canonical', verifySource181.includes('code-review.md'));
286
+ ok('verify.md does NOT contain the old range-scoped diff', !verifySource181.includes('git diff <base>...HEAD'));
287
+
288
+ const verifyCmdMatch181 = verifySource181.match(ticketDiffLogCmdRegex);
289
+ ok('verify.md contains the pinned Ticket diff log command', !!verifyCmdMatch181);
290
+ const verifyLogCmd181 = verifyCmdMatch181 ? verifyCmdMatch181[0] : '';
291
+ ok('code-review.md and verify.md state the exact same log command',
292
+ codeReviewLogCmd181 !== '' && codeReviewLogCmd181 === verifyLogCmd181);
293
+
294
+ const verifyStepLines181 = ticketEnginesSource181.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
295
+ ok('ticket-engines.md has exactly two Verify (step 4) lines', verifyStepLines181.length === 2);
296
+ ok('both Verify step lines reference the Ticket diff', verifyStepLines181.every((l) => l.includes('**Ticket diff**')));
297
+ ok('ticket-engines.md does NOT contain the old range-scoped diff', !ticketEnginesSource181.includes('git diff <base>...HEAD'));
298
+
299
+ ok("techniques.md index row updates Code review to the ticket's own commits",
300
+ techniquesSource181.includes("review the ticket's own commits (its `Refs: #<n>` set) on 4 axes"));
301
+
302
+ for (const [label, src] of [
303
+ ['code-review.md', codeReviewSource181],
304
+ ['verify.md', verifySource181],
305
+ ['ticket-engines.md', ticketEnginesSource181],
306
+ ['techniques.md', techniquesSource181],
307
+ ]) {
308
+ ok(`${label} has no em-dash`, !src.includes('\u2014'));
309
+ }
310
+
311
+ // --- #181 behavioral pin: build a scratch repo and run the extracted command -
312
+ function gitEnv181(extra = {}) {
313
+ return {
314
+ ...process.env,
315
+ GIT_CONFIG_GLOBAL: '/dev/null',
316
+ GIT_CONFIG_NOSYSTEM: '1',
317
+ GIT_AUTHOR_NAME: 'Trailhead Test',
318
+ GIT_AUTHOR_EMAIL: 'trailhead-test@example.com',
319
+ GIT_COMMITTER_NAME: 'Trailhead Test',
320
+ GIT_COMMITTER_EMAIL: 'trailhead-test@example.com',
321
+ LC_ALL: 'C',
322
+ ...extra,
323
+ };
324
+ }
325
+ function git181(dir, args, extraEnv = {}) {
326
+ return execFileSync('git', ['-C', dir, ...args], { env: gitEnv181(extraEnv), stdio: ['ignore', 'pipe', 'pipe'] }).toString();
327
+ }
328
+ function commit181(dir, message, dateStr) {
329
+ git181(dir, ['add', '-A']);
330
+ git181(dir, ['commit', '-m', message], { GIT_AUTHOR_DATE: dateStr, GIT_COMMITTER_DATE: dateStr });
331
+ return git181(dir, ['rev-parse', 'HEAD']).trim();
332
+ }
333
+
334
+ const t181Repo = mktmp();
335
+ git181(t181Repo, ['init', '-b', 'main']);
336
+ const fFile181 = path.join(t181Repo, 'f.txt');
337
+ const D181 = (n) => `2026-01-01T00:00:${String(n).padStart(2, '0')}Z`;
338
+
339
+ fs.writeFileSync(fFile181, 'a\n');
340
+ const shaA181 = commit181(t181Repo, 'feat: a\n\nRefs: #18', D181(1));
341
+
342
+ fs.writeFileSync(path.join(t181Repo, 'decoy-b.txt'), 'b\n');
343
+ commit181(t181Repo, 'feat: b\n\nRefs: #181', D181(2));
344
+
345
+ fs.writeFileSync(path.join(t181Repo, 'decoy-c.txt'), 'c\n');
346
+ commit181(t181Repo, 'feat: c\n\nRefs: #1800', D181(3));
347
+
348
+ fs.writeFileSync(path.join(t181Repo, 'decoy-d.txt'), 'd\n');
349
+ commit181(t181Repo, 'feat: d\n\nmentions Refs: #18 inline\nRefs: #99', D181(4));
350
+
351
+ fs.writeFileSync(fFile181, 'foreign\n');
352
+ commit181(t181Repo, 'feat: foreign\n\nRefs: #99', D181(5));
353
+
354
+ git181(t181Repo, ['checkout', '-b', 'side']);
355
+ fs.writeFileSync(fFile181, 'side-edit\n');
356
+ fs.writeFileSync(path.join(t181Repo, 'foreign.txt'), 'foreign-file\n');
357
+ commit181(t181Repo, 'chore: side edit', D181(6));
358
+
359
+ git181(t181Repo, ['checkout', 'main']);
360
+ fs.writeFileSync(fFile181, 'main-edit\n');
361
+ const shaF181 = commit181(t181Repo, 'fix: e\n\nRefs: #18', D181(7));
362
+
363
+ let mergeConflicted181 = false;
364
+ try {
365
+ git181(t181Repo, ['merge', 'side', '--no-commit', '--no-ff']);
366
+ } catch {
367
+ mergeConflicted181 = true;
368
+ }
369
+ ok('#181 scratch repo: the trunk-sync merge actually conflicts on f.txt', mergeConflicted181);
370
+ fs.writeFileSync(fFile181, 'RESOLVED\n');
371
+ git181(t181Repo, ['add', 'f.txt', 'foreign.txt']);
372
+ const shaMerge181 = commit181(t181Repo, 'merge: sync\n\nRefs: #18', D181(8));
373
+
374
+ ok('#181 scratch repo: merge sha differs from the fix sha', shaMerge181 !== shaF181);
375
+
376
+ function runShellGit181(cmd, extraEnv = {}) {
377
+ return execFileSync('sh', ['-c', cmd], { env: gitEnv181(extraEnv), stdio: ['ignore', 'pipe', 'pipe'] }).toString();
378
+ }
379
+
380
+ const substituted181 = (cmd, n) => cmd.replace(/<repo>/g, JSON.stringify(t181Repo)).replace(/<n>/g, String(n));
381
+
382
+ const fixedPatternTypeEnv181 = {
383
+ GIT_CONFIG_COUNT: '1',
384
+ GIT_CONFIG_KEY_0: 'grep.patternType',
385
+ GIT_CONFIG_VALUE_0: 'fixed',
386
+ };
387
+
388
+ const out18_181 = runShellGit181(substituted181(codeReviewLogCmd181, 18), fixedPatternTypeEnv181);
389
+ const lines18_181 = out18_181.split('\n').filter((l) => l.length > 0);
390
+ ok('#181 log command returns exactly [a, f, merge] oldest-first for #18, even under grep.patternType=fixed',
391
+ lines18_181.length === 3 && lines18_181[0] === shaA181 && lines18_181[1] === shaF181 && lines18_181[2] === shaMerge181);
392
+
393
+ const out7_181 = runShellGit181(substituted181(codeReviewLogCmd181, 7), fixedPatternTypeEnv181);
394
+ ok('#181 log command returns empty output for a ticket with no commits', out7_181.trim() === '');
395
+
396
+ const remergeCmd181 = 'git -C <repo> show --remerge-diff <sha>'
397
+ .replace('<repo>', JSON.stringify(t181Repo))
398
+ .replace('<sha>', shaMerge181);
399
+ const remergeOut181 = runShellGit181(remergeCmd181);
400
+ ok('#181 --remerge-diff on the merge shows the resolution hunk', remergeOut181.includes('RESOLVED'));
401
+ ok('#181 --remerge-diff on the merge does NOT show the foreign file the merge pulled in cleanly',
402
+ !remergeOut181.includes('foreign.txt'));
403
+
241
404
  // --- #151: eager edge drop at close (superseded / out-of-scope only) ---------
242
405
  // Source-level invariant: closing a ticket as trailhead:superseded or
243
406
  // trailhead:out-of-scope drops its native sub-issue edge at close (reusing the
@@ -574,7 +737,7 @@ ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
574
737
 
575
738
  // --- claude regression --------------------------------------------------------
576
739
  const claudeDir = mktmp();
577
- runInstaller([`--claude`, `--dir=${claudeDir}`]);
740
+ const claudeInstallOut = String(runInstaller([`--claude`, `--dir=${claudeDir}`]));
578
741
 
579
742
  const claudeSkillPath = path.join(claudeDir, 'skills', 'trailhead', 'SKILL.md');
580
743
  ok('claude: skills/trailhead/SKILL.md exists', fs.existsSync(claudeSkillPath));
@@ -602,6 +765,12 @@ ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require targe
602
765
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
603
766
  ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
604
767
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
768
+
769
+ // #185: the commit-msg hook sync script ships wholesale with templates/ too.
770
+ const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
771
+ ok('claude: commit-msg-sync script projected', fs.existsSync(claudeCommitMsgSync));
772
+ ok('claude: commit-msg-sync script is byte-identical to source',
773
+ fs.readFileSync(claudeCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
605
774
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
606
775
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
607
776
  // payload and assert it does not crash with a missing-module error.
@@ -859,6 +1028,225 @@ try {
859
1028
  ok('unknown flag after a valid flag: still rejected', unknownAfterValidRejected);
860
1029
  ok('unknown flag after a valid flag: installs nothing', nothingInstalledUnder(unknownAfterValidDir));
861
1030
 
1031
+ // --- #185: teamwork.md/substrate-commands.md/trailhead-work SKILL.md point at
1032
+ // the commit-msg-sync script instead of the old manual copy/chmod steps -------
1033
+ ok('source: teamwork.md no longer says an existing hook is never replaced',
1034
+ !teamworkSource.includes('an existing hook is never replaced'));
1035
+ ok('source: teamwork.md no longer says reinstalled by hand',
1036
+ !teamworkSource.includes("until it's reinstalled by hand"));
1037
+ for (const [label, src] of [
1038
+ ['teamwork.md', teamworkSource],
1039
+ ['substrate-commands.md', substrateCommandsSource],
1040
+ ['trailhead-work/SKILL.md', workSkillSource],
1041
+ ]) {
1042
+ ok(`source: ${label} references trailhead-commit-msg-sync.js`, src.includes('trailhead-commit-msg-sync.js'));
1043
+ ok(`source: ${label} has no em-dash`, !src.includes('—'));
1044
+ }
1045
+
1046
+ // --- #182 ---------------------------------------------------------------------
1047
+ // A broken install fails loudly (verifyInstall names the gap and the run exits
1048
+ // non-zero, printing no "✓ trailhead installed"); a clean one still prints ✓.
1049
+ // brokenPkg copies bin/ + plugins/ into a fresh tmp root (so __dirname-relative
1050
+ // PKG/SRC resolution in trailhead.js still lines up) and applies a mutation
1051
+ // that breaks one source artifact; runPkg drives that copy (or the real repo)
1052
+ // through execFileSync without throwing on a non-zero exit.
1053
+ function brokenPkg(mutate) {
1054
+ const pkgDir = mktmp();
1055
+ fs.cpSync(path.join(repoRoot, 'bin'), path.join(pkgDir, 'bin'), { recursive: true });
1056
+ fs.cpSync(path.join(repoRoot, 'plugins'), path.join(pkgDir, 'plugins'), { recursive: true });
1057
+ if (mutate) mutate(pkgDir);
1058
+ return pkgDir;
1059
+ }
1060
+ function runPkg(pkgDir, args, env) {
1061
+ try {
1062
+ const stdout = execFileSync(process.execPath, [path.join(pkgDir, 'bin', 'trailhead.js'), ...args], {
1063
+ cwd: pkgDir, stdio: 'pipe', env: env || process.env,
1064
+ });
1065
+ return { status: 0, stdout: String(stdout), stderr: '' };
1066
+ } catch (e) {
1067
+ return { status: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') };
1068
+ }
1069
+ }
1070
+
1071
+ // (a) no engine agents at all: both hosts refuse and print no success line.
1072
+ {
1073
+ const noAgentsPkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'agents'), { recursive: true, force: true }));
1074
+ const claudeOut = runPkg(noAgentsPkg, ['--claude', `--dir=${mktmp()}`]);
1075
+ ok('(a) claude, no agents: non-zero exit', claudeOut.status !== 0);
1076
+ ok('(a) claude, no agents: stderr names agents', claudeOut.stderr.includes('agents'));
1077
+ ok('(a) claude, no agents: stdout has no ✓ trailhead installed', !claudeOut.stdout.includes('✓ trailhead installed'));
1078
+ const codexOut = runPkg(noAgentsPkg, ['--codex', `--dir=${mktmp()}`]);
1079
+ ok('(a) codex, no agents: non-zero exit', codexOut.status !== 0);
1080
+ ok('(a) codex, no agents: stderr names agents', codexOut.stderr.includes('agents'));
1081
+ ok('(a) codex, no agents: stdout has no ✓ trailhead installed', !codexOut.stdout.includes('✓ trailhead installed'));
1082
+ }
1083
+
1084
+ // (b) a cluster's SKILL.md is missing from the source.
1085
+ {
1086
+ const pkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'skills', 'trailhead-view', 'SKILL.md'), { force: true }));
1087
+ const out = runPkg(pkg, ['--claude', `--dir=${mktmp()}`]);
1088
+ ok('(b) missing cluster SKILL.md: non-zero exit', out.status !== 0);
1089
+ ok('(b) missing cluster SKILL.md: stderr names trailhead-view/SKILL.md', out.stderr.includes('trailhead-view/SKILL.md'));
1090
+ }
1091
+
1092
+ // (c) the shared core is missing a file.
1093
+ {
1094
+ const pkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'skills', '_shared', 'substrate.md'), { force: true }));
1095
+ const out = runPkg(pkg, ['--claude', `--dir=${mktmp()}`]);
1096
+ ok('(c) missing _shared/substrate.md: non-zero exit', out.status !== 0);
1097
+ ok('(c) missing _shared/substrate.md: stderr names _shared/substrate.md', out.stderr.includes('_shared/substrate.md'));
1098
+ }
1099
+
1100
+ // (d) a hook script source is missing, fresh dir: named cleanly, no ENOENT stack.
1101
+ const missingBodyGuardPkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', 'trailhead', 'hooks', 'trailhead-body-guard.js'), { force: true }));
1102
+ {
1103
+ const out = runPkg(missingBodyGuardPkg, ['--claude', `--dir=${mktmp()}`]);
1104
+ ok('(d) missing hook source, fresh dir: non-zero exit', out.status !== 0);
1105
+ ok('(d) missing hook source, fresh dir: stderr names trailhead-body-guard.js', out.stderr.includes('trailhead-body-guard.js'));
1106
+ ok('(d) missing hook source, fresh dir: no ENOENT stack', !out.stderr.includes('ENOENT'));
1107
+ }
1108
+
1109
+ // (e) reinstalling that same broken package over a prior clean install removes
1110
+ // the stale hook file rather than leaving it behind, on both hosts.
1111
+ {
1112
+ const xDir = mktmp();
1113
+ runInstaller([`--claude`, `--dir=${xDir}`]);
1114
+ const out = runPkg(missingBodyGuardPkg, ['--claude', `--dir=${xDir}`]);
1115
+ ok('(e) claude reinstall over an old install: non-zero exit', out.status !== 0);
1116
+ ok('(e) claude reinstall: stderr names trailhead-body-guard.js', out.stderr.includes('trailhead-body-guard.js'));
1117
+ ok('(e) claude reinstall: the stale hook file is gone', !fs.existsSync(path.join(xDir, 'hooks', 'trailhead-body-guard.js')));
1118
+
1119
+ const wDir = mktmp();
1120
+ runInstaller([`--codex`, `--dir=${wDir}`]);
1121
+ const outCodex = runPkg(missingBodyGuardPkg, ['--codex', `--dir=${wDir}`]);
1122
+ ok('(e) codex reinstall over an old install: non-zero exit', outCodex.status !== 0);
1123
+ ok('(e) codex reinstall: stderr names trailhead-body-guard.js', outCodex.stderr.includes('trailhead-body-guard.js'));
1124
+ ok('(e) codex reinstall: the stale hook file is gone', !fs.existsSync(path.join(wDir, 'skills', 'trailhead', 'hooks', 'trailhead-body-guard.js')));
1125
+ }
1126
+
1127
+ // (f) a stale Claude registration in settings.json (target gone).
1128
+ {
1129
+ const yDir = mktmp();
1130
+ runInstaller([`--claude`, `--dir=${yDir}`]);
1131
+ const settingsPath = path.join(yDir, 'settings.json');
1132
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1133
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1134
+ { type: 'command', command: `node "${path.join(yDir, 'hooks', 'trailhead-gone-guard.js')}"` },
1135
+ ] });
1136
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1137
+ const out = runPkg(repoRoot, [`--claude`, `--dir=${yDir}`]);
1138
+ ok('(f) stale Claude registration: non-zero exit', out.status !== 0);
1139
+ ok('(f) stale Claude registration: stderr names the stale target', out.stderr.includes('trailhead-gone-guard.js'));
1140
+ }
1141
+
1142
+ // (g) a stale generic Codex registration in hooks.json (target gone).
1143
+ {
1144
+ const wgDir = mktmp();
1145
+ runInstaller([`--codex`, `--dir=${wgDir}`]);
1146
+ const hooksJsonPath = path.join(wgDir, 'hooks.json');
1147
+ const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
1148
+ hooksJson.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1149
+ { type: 'command', command: `node "${path.join(wgDir, 'skills', 'trailhead', 'hooks', 'trailhead-gone-guard.js')}"` },
1150
+ ] });
1151
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
1152
+ const out = runPkg(repoRoot, [`--codex`, `--dir=${wgDir}`]);
1153
+ ok('(g) stale Codex registration: non-zero exit', out.status !== 0);
1154
+ ok('(g) stale Codex registration: stderr names the stale target', out.stderr.includes('trailhead-gone-guard.js'));
1155
+ ok('(g) stale Codex registration: stderr names hooks.json', out.stderr.includes('hooks.json'));
1156
+ }
1157
+
1158
+ // (h) the pre-#169 search-guard registration migrates away silently on reinstall.
1159
+ // May already pass before the installer wires verifyInstall in (no check exists
1160
+ // yet to trip on it); it stays here to pin the migration's intent regardless.
1161
+ {
1162
+ const zDir = mktmp();
1163
+ runInstaller([`--codex`, `--dir=${zDir}`]);
1164
+ const hooksJsonPath = path.join(zDir, 'hooks.json');
1165
+ const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
1166
+ hooksJson.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1167
+ { type: 'command', command: `node "${path.join(zDir, 'skills', 'trailhead', 'hooks', 'trailhead-search-guard.js')}"` },
1168
+ ] });
1169
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
1170
+ const out = runPkg(repoRoot, [`--codex`, `--dir=${zDir}`]);
1171
+ ok('(h) pre-#169 search-guard migration: exit 0', out.status === 0);
1172
+ ok('(h) pre-#169 search-guard migration: prints ✓', out.stdout.includes('✓'));
1173
+ const afterHooksJson = fs.readFileSync(hooksJsonPath, 'utf8');
1174
+ ok('(h) pre-#169 search-guard migration: the stale entry is gone', !afterHooksJson.includes('trailhead-search-guard.js'));
1175
+ }
1176
+
1177
+ // (i) an unresolved env var on a registered target warns instead of failing.
1178
+ {
1179
+ const vDir = mktmp();
1180
+ runInstaller([`--claude`, `--dir=${vDir}`]);
1181
+ const settingsPath = path.join(vDir, 'settings.json');
1182
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1183
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1184
+ { type: 'command', command: 'node "${TRAILHEAD_T182_UNSET}/hooks/trailhead-x-guard.js"' },
1185
+ ] });
1186
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1187
+ // Delete the key outright (never set it to undefined), so the child process
1188
+ // never receives a literal "undefined" string for it.
1189
+ const envWithoutVar = { ...process.env };
1190
+ delete envWithoutVar.TRAILHEAD_T182_UNSET;
1191
+ const out = runPkg(repoRoot, [`--claude`, `--dir=${vDir}`], envWithoutVar);
1192
+ ok('(i) unresolved env var: exit 0', out.status === 0);
1193
+ ok('(i) unresolved env var: prints ✓', out.stdout.includes('✓'));
1194
+ ok('(i) unresolved env var: stdout warns ⚠ unverified', out.stdout.includes('⚠ unverified'));
1195
+ ok('(i) unresolved env var: stdout names the unresolved variable', out.stdout.includes('TRAILHEAD_T182_UNSET'));
1196
+ }
1197
+
1198
+ // (j) regression: the existing clean installs at the top of this file still
1199
+ // print ✓ with no ⚠ unverified.
1200
+ ok('(j) claude clean install: prints ✓ trailhead installed', claudeInstallOut.includes('✓ trailhead installed'));
1201
+ ok('(j) claude clean install: no ⚠ unverified', !claudeInstallOut.includes('⚠ unverified'));
1202
+ ok('(j) codex clean install: prints ✓ trailhead installed', codexInstallOut.includes('✓ trailhead installed'));
1203
+ ok('(j) codex clean install: no ⚠ unverified', !codexInstallOut.includes('⚠ unverified'));
1204
+
1205
+ // --- #183: severity counts and per-finding disposition gate on Resolve -------
1206
+ // Code review classifies findings as Critical/Warning/Info, but only Criticals
1207
+ // gated resolution and Warnings/Info were never tracked to a decision. Pin the
1208
+ // counts line under `status:`, the disposition vocabulary, and the Resolve gate
1209
+ // that requires every counted finding to carry one before a ticket can close.
1210
+ const codeReviewSourcePath183 = path.join(sourceSkillsDir, '_shared', 'techniques', 'code-review.md');
1211
+ const codeReviewSource183 = fs.readFileSync(codeReviewSourcePath183, 'utf8');
1212
+ const ticketEnginesSource183 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
1213
+
1214
+ ok('code-review.md states the counts format right under the status tag',
1215
+ codeReviewSource183.includes('critical: <n> · warning: <n> · info: <n>'));
1216
+ ok('code-review.md says the counts line sits right under the status tag',
1217
+ /right under[^.]*`status:`/.test(codeReviewSource183) || /`status:`[^.]*right under/.test(codeReviewSource183));
1218
+
1219
+ ok('code-review.md names the fixed disposition', codeReviewSource183.includes('`fixed`'));
1220
+ ok('code-review.md names the deferred disposition', codeReviewSource183.includes('`deferred → <ticket>`'));
1221
+ ok('code-review.md names the accepted disposition', codeReviewSource183.includes('`accepted: <one-line reason>`'));
1222
+ ok('code-review.md names the transient open disposition', codeReviewSource183.includes('`open`'));
1223
+
1224
+ ok('code-review.md says finding IDs are stable across rounds',
1225
+ /stable/i.test(codeReviewSource183) && /across (every|fix-and-re-review) round/i.test(codeReviewSource183));
1226
+ ok('code-review.md says the counts are cumulative', codeReviewSource183.includes('cumulative'));
1227
+ ok('code-review.md says no finding reaches Resolve with open or without a disposition',
1228
+ /no finding reaches Resolve with[^.]*`open`[^.]*without a disposition/.test(codeReviewSource183));
1229
+
1230
+ ok('code-review.md no longer defers Warnings/Info as "noted for the caller to weigh"',
1231
+ !codeReviewSource183.includes('noted for the caller to weigh'));
1232
+
1233
+ const verifyStepLines183 = ticketEnginesSource183.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
1234
+ ok('ticket-engines.md has exactly two Verify (step 4) lines', verifyStepLines183.length === 2);
1235
+ ok('both Verify step lines mention the counts line',
1236
+ verifyStepLines183.every((l) => l.includes('counts line')));
1237
+
1238
+ const resolveStepLines183 = ticketEnginesSource183.split('\n').filter((l) => l.trim().startsWith('5. **Resolve**'));
1239
+ ok('ticket-engines.md has exactly two Resolve (step 5) lines', resolveStepLines183.length === 2);
1240
+ ok('both Resolve step lines carry the Disposition gate',
1241
+ resolveStepLines183.every((l) => l.includes('**Disposition gate (before the close)**')));
1242
+
1243
+ for (const [label, src] of [
1244
+ ['code-review.md', codeReviewSource183],
1245
+ ['ticket-engines.md', ticketEnginesSource183],
1246
+ ]) {
1247
+ ok(`${label} has no em-dash (#183)`, !src.includes('\u2014'));
1248
+ }
1249
+
862
1250
  // --- cleanup -------------------------------------------------------------------
863
1251
  for (const d of tmpDirs) {
864
1252
  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.0",
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.0",
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",
@@ -5,13 +5,18 @@
5
5
  // - templates/trailhead-commit-msg (host-independent git commit-msg hook,
6
6
  // installed into .git/hooks at repo first-use so it runs on ANY host)
7
7
  // Two rules: NEVER a Co-Authored-By trailer; a Conventional Commits subject
8
- // (<type>(scope)?: subject) of at most 72 chars.
8
+ // (<type>(scope)?: subject) of at most 72 chars. Plus a conditional third rule:
9
+ // while a ticket is active (a caller passes opts.ticket, resolved from the
10
+ // .trailhead/session-ticket marker), the message must carry a matching
11
+ // `Refs: #<n>` trailer, so every commit made while resolving a ticket threads
12
+ // into that ticket's timeline.
9
13
  // Pure and dependency-free: takes a commit message string, returns a verdict.
10
14
 
11
15
  const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
12
16
  const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
13
17
  const CO_AUTHORED = /co-authored-by\s*:/i;
14
18
  const SCISSORS = '# ------------------------ >8';
19
+ const REFS_LINE = /^\s*refs\s*:(.*)$/i;
15
20
 
16
21
  // The message region: everything before git's --verbose scissors line (the
17
22
  // block after it is the diff, not the message). Everything else is kept, so a
@@ -39,11 +44,46 @@ function subjectOf(raw, opts = {}) {
39
44
  return '';
40
45
  }
41
46
 
47
+ // parseSessionTicket(text) -> ticket number | null
48
+ // Parses the first line of a .trailhead/session-ticket marker (`#<n> <title>`).
49
+ // Strips a leading BOM and any trailing \r before matching, so a marker read
50
+ // with either line-ending convention parses the same way. Anything else
51
+ // (empty text, no leading '#', a non-digit, a leading zero, or trailing
52
+ // characters glued to the digits) is not a valid marker and yields null.
53
+ function parseSessionTicket(text) {
54
+ const raw = String(text == null ? '' : text).replace(/^/, '');
55
+ const firstLine = raw.split('\n')[0].replace(/\r$/, '');
56
+ const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
57
+ return m ? Number(m[1]) : null;
58
+ }
59
+
60
+ // hasRefsFor(message, n) -> boolean
61
+ // Scans the message region (see messageRegion) for a `Refs:` trailer line
62
+ // whose value contains `#<n>` not immediately followed by another word
63
+ // character (so `#184` matches but `#18` and `#184abc` do not). A `#`-leading
64
+ // line never counts: it is a git comment in a commit-msg file, and a
65
+ // `# Refs: ...` line is not a trailer in a literal `-m` string either.
66
+ function hasRefsFor(message, n) {
67
+ const target = new RegExp('#' + n + '(?![\\w])');
68
+ for (const line of messageRegion(message).split('\n')) {
69
+ if (line.trim().startsWith('#')) continue;
70
+ const m = line.match(REFS_LINE);
71
+ if (m && target.test(m[1])) return true;
72
+ }
73
+ return false;
74
+ }
75
+
42
76
  // checkCommitMessage(message, opts?) -> { ok: true } | { ok: false, code, reason }
43
77
  // opts.stripComments is forwarded to subjectOf (see there): the git commit-msg
44
78
  // hook uses the default (file semantics); the PreToolUse `-m` guard passes false.
79
+ // opts.ticket activates the Refs rule when it is a positive integer (the
80
+ // ticket resolved from a live .trailhead/session-ticket marker); any other
81
+ // value (undefined, a non-integer, a string, 0, negative) leaves the rule off,
82
+ // it never blocks by mistake. When active it applies even to an empty
83
+ // message, closing a `--allow-empty-message` bypass.
45
84
  function checkCommitMessage(message, opts = {}) {
46
85
  const region = messageRegion(message);
86
+ const ticketActive = Number.isInteger(opts.ticket) && opts.ticket > 0;
47
87
 
48
88
  if (CO_AUTHORED.test(region)) {
49
89
  return {
@@ -68,7 +108,23 @@ function checkCommitMessage(message, opts = {}) {
68
108
  reason: 'trailhead: commit subject must be 72 characters or less.',
69
109
  };
70
110
  }
111
+ if (ticketActive && !hasRefsFor(message, opts.ticket)) {
112
+ return {
113
+ ok: false,
114
+ code: 'REFS_TRAILER_MISSING',
115
+ reason: `trailhead: this commit is on ticket #${opts.ticket} (per .trailhead/session-ticket) but has no "Refs: #${opts.ticket}" trailer. Add "Refs: #${opts.ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${opts.ticket}, remove the stale .trailhead/session-ticket marker first.`,
116
+ };
117
+ }
71
118
  return { ok: true };
72
119
  }
73
120
 
74
- module.exports = { checkCommitMessage, subjectOf, messageRegion, TYPES, CONVENTIONAL, CO_AUTHORED };
121
+ module.exports = {
122
+ checkCommitMessage,
123
+ subjectOf,
124
+ messageRegion,
125
+ parseSessionTicket,
126
+ hasRefsFor,
127
+ TYPES,
128
+ CONVENTIONAL,
129
+ CO_AUTHORED,
130
+ };