@marcomigozzi/trailhead 0.9.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.
Files changed (37) hide show
  1. package/README.it.md +11 -11
  2. package/README.md +11 -11
  3. package/bin/lib/codex-projection.js +13 -6
  4. package/bin/lib/codex-projection.test.js +45 -3
  5. package/bin/lib/install-verify.js +257 -0
  6. package/bin/lib/install-verify.test.js +379 -0
  7. package/bin/trailhead.js +91 -5
  8. package/bin/trailhead.test.js +588 -4
  9. package/package.json +1 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  11. package/plugins/trailhead/commands/quick.md +1 -1
  12. package/plugins/trailhead/hooks/lib/commit-message-check.js +58 -2
  13. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +103 -13
  14. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +375 -0
  15. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-a319156 +57 -0
  16. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +32 -3
  17. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +41 -0
  18. package/plugins/trailhead/skills/_shared/choices.md +3 -0
  19. package/plugins/trailhead/skills/_shared/configuration-reference.md +27 -20
  20. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  21. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  22. package/plugins/trailhead/skills/_shared/session-handoff.md +8 -6
  23. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  24. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  25. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +13 -4
  26. package/plugins/trailhead/skills/_shared/techniques/code-review.md +15 -3
  27. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  28. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  29. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -2
  30. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  31. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  32. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -10
  34. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  35. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +12 -9
  36. package/plugins/trailhead/templates/trailhead-commit-msg +49 -2
  37. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +171 -0
@@ -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
@@ -206,11 +212,195 @@ ok('source: trailhead-chart SKILL.md config-offer points at the next-step block'
206
212
  // (session-handoff.md legitimately offers /trailhead:quick <n> for the next loose ticket).
207
213
  const captureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
208
214
  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 off-map / 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 off-map \/ no-split alternative/.test(captureSource));
215
+ ok('source: capture.md demotes /trailhead:quick to only the no-split alternative, after the work anchor',
216
+ /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is \*\*only\*\* the no-split alternative/.test(captureSource));
211
217
  ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
212
218
  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
219
 
220
+ // --- #179: `quick <n>` on a map ticket is never "off the map" ----------------
221
+ // Source-level invariant: `quick <n>` on an EXISTING ticket does not leave the
222
+ // map (it keeps its trailhead:map-<n> label and sub-issue edge, and dependent-
223
+ // unblocking still runs); "off the map" describes only `quick "<text>"`, which
224
+ // opens a NEW whiteboard ticket. capture.md, session-handoff.md, and
225
+ // trailhead-work/SKILL.md must no longer carry the old "that same work done
226
+ // off the map" phrasing, nor describe `quick <n>` itself as off the map.
227
+ const sessionHandoffSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'session-handoff.md'), 'utf8');
228
+ const workSkillSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'SKILL.md'), 'utf8');
229
+ const offMapQuickN = /`?quick <n>`?[^.\n]{0,80}off the map/i;
230
+ // The explicit "never describe ... as off the map" clause legitimately puts
231
+ // `quick <n>` near "off the map" in the same sentence, so strip it before
232
+ // running the negative proximity check below.
233
+ const neverDescribeClause = /[Nn]ever describe `quick <n>`[^\n]*?off the map\.?/;
234
+ const sessionHandoffMinusClause = sessionHandoffSource.replace(neverDescribeClause, '');
235
+ ok('source: capture.md drops the old "that same work done off the map" phrasing', !captureSource.includes('that same work done off the map'));
236
+ ok('source: session-handoff.md drops the old "that same work done off the map" phrasing', !sessionHandoffSource.includes('that same work done off the map'));
237
+ ok('source: trailhead-work SKILL.md drops the old "that same work done off the map" phrasing', !workSkillSourceFor179.includes('that same work done off the map'));
238
+ ok('source: capture.md never describes quick <n> as off the map', !offMapQuickN.test(captureSource));
239
+ ok('source: session-handoff.md never describes quick <n> as off the map (outside the explicit never-clause)', !offMapQuickN.test(sessionHandoffMinusClause));
240
+ ok('source: trailhead-work SKILL.md never describes quick <n> as off the map', !offMapQuickN.test(workSkillSourceFor179));
241
+ ok('source: session-handoff.md explicitly says never to describe quick <n> on a map ticket as off the map',
242
+ neverDescribeClause.test(sessionHandoffSource));
243
+ ok('source: capture.md says a map ticket stays on its map', /a map ticket stays on its map/.test(captureSource));
244
+ const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
245
+ ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
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
+
214
404
  // --- #151: eager edge drop at close (superseded / out-of-scope only) ---------
215
405
  // Source-level invariant: closing a ticket as trailhead:superseded or
216
406
  // trailhead:out-of-scope drops its native sub-issue edge at close (reusing the
@@ -255,6 +445,167 @@ const ticketEnginesSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhea
255
445
  ok('source: ticket-engines.md resolved-close path does NOT drop the native sub-issue edge',
256
446
  !/drop (its|any) native sub-issue edge/.test(ticketEnginesSource));
257
447
 
448
+ // --- #178: drive-mode ask (AI-driven vs human-driven) at every UAT start -----
449
+ // Single-sourced in techniques/acceptance-testing.md as the "Drive mode" bullet;
450
+ // every other call-site (ticket-engines.md, auto.md, choices.md) references it
451
+ // by name ("drive-mode ask") rather than restating its clauses. Extract the
452
+ // bullet's own span (its marker line through the end of its indented ` - `
453
+ // sub-bullets) and pin each clause inside that span, mirroring the #139/#146
454
+ // source-level anti-drift style.
455
+ function extractBulletSpan(source, markerLine) {
456
+ const lines = source.split('\n');
457
+ const startIdx = lines.findIndex((l) => l.includes(markerLine));
458
+ if (startIdx === -1) return '';
459
+ let endIdx = startIdx + 1;
460
+ while (endIdx < lines.length && lines[endIdx].startsWith(' - ')) endIdx++;
461
+ return lines.slice(startIdx, endIdx).join('\n');
462
+ }
463
+ const driveModeMarker = '- **Drive mode: ask who drives the UAT.**';
464
+ const acceptanceTestingSourcePath = path.join(sourceSkillsDir, '_shared', 'techniques', 'acceptance-testing.md');
465
+ const acceptanceTestingSource = fs.readFileSync(acceptanceTestingSourcePath, 'utf8');
466
+ const driveModeSpan = extractBulletSpan(acceptanceTestingSource, driveModeMarker);
467
+ ok('source: acceptance-testing.md carries the Drive-mode bullet', driveModeSpan.length > 0);
468
+ for (const phrase of [
469
+ '**before any step runs**',
470
+ '**wait for an explicit answer**',
471
+ '**silence or an ambiguous reply re-asks**',
472
+ '**One-path skip**',
473
+ '**state the reason in one line**',
474
+ 'VERIFY',
475
+ 'only for the steps it cannot perform',
476
+ 'runs unchanged',
477
+ '**in both modes**',
478
+ 'acceptance.browser: off',
479
+ 'without the confirm gate',
480
+ ]) {
481
+ ok(`source: acceptance-testing.md Drive-mode span pins "${phrase}"`, driveModeSpan.includes(phrase));
482
+ }
483
+ // The three start paths.
484
+ ok('source: Drive-mode span names build/bug Verify as a start path', driveModeSpan.includes('Verify'));
485
+ ok("source: Drive-mode span names the Pending-UAT guard's option (a) as a start path", driveModeSpan.includes('Pending-UAT guard'));
486
+ ok('source: Drive-mode span names a UAT follow-up (UAT of:) as a start path', driveModeSpan.includes('UAT of:'));
487
+ // The three performability classes.
488
+ ok('source: Drive-mode span names the physical-device/hardware performability class', driveModeSpan.includes('a physical device or hardware'));
489
+ ok('source: Drive-mode span names the credentials/accounts performability class', driveModeSpan.includes('credentials or accounts it does not hold'));
490
+ ok('source: Drive-mode span names the human sensory-judgement performability class', driveModeSpan.includes('a human sensory judgement'));
491
+
492
+ // The "Pending UAT must stay tracked" bullet references the ask for BOTH (a)
493
+ // run-now and (b) the follow-up (grouped: two mentions in the same bullet).
494
+ const pendingUatBulletMarker = '**Pending UAT must stay tracked: a closed ticket is not tracking.**';
495
+ const pendingUatLine = acceptanceTestingSource.split('\n').find((l) => l.includes(pendingUatBulletMarker)) || '';
496
+ ok('source: Pending-UAT bullet exists', pendingUatLine.length > 0);
497
+ ok('source: Pending-UAT bullet references the drive-mode ask for both (a) and the follow-up',
498
+ (pendingUatLine.match(/drive-mode ask/g) || []).length >= 2);
499
+
500
+ // Single-source anti-drift: the bullet's own signature phrase appears in
501
+ // exactly one file across the whole skills tree (mirrors #146).
502
+ const driveModeSignature = 'ask who drives the UAT';
503
+ const driveModeSignatureFiles = mdFilesUnder(sourceSkillsDir).filter((f) => fs.readFileSync(f, 'utf8').includes(driveModeSignature));
504
+ ok('source: "ask who drives the UAT" signature appears in exactly one skills-tree file', driveModeSignatureFiles.length === 1);
505
+ ok('source: that one file is techniques/acceptance-testing.md', driveModeSignatureFiles.length === 1 && driveModeSignatureFiles[0] === acceptanceTestingSourcePath);
506
+
507
+ // The Codex install projects acceptance-testing.md with the Drive-mode bullet.
508
+ ok('codex: skills/_shared/techniques/acceptance-testing.md is projected with the Drive-mode bullet',
509
+ fs.readFileSync(path.join(codexDir, 'skills', '_shared', 'techniques', 'acceptance-testing.md'), 'utf8').includes(driveModeMarker));
510
+
511
+ // --- #178 (2/4): route UAT follow-ups by pointer, engines through the ask ----
512
+ // A new type-independent section, keyed on the `UAT of:` pointer rather than
513
+ // the ticket's type label, must sit before the first per-type `## `decision``
514
+ // heading (work/quick/auto all dispatch through this file).
515
+ function extractSection(source, headingMarker) {
516
+ const start = source.indexOf(headingMarker);
517
+ if (start === -1) return '';
518
+ const rest = source.slice(start + headingMarker.length);
519
+ const nextHeadingIdx = rest.search(/\n## /);
520
+ return headingMarker + (nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx));
521
+ }
522
+ const uatFollowupHeadingMarker = '## UAT follow-ups (any type)';
523
+ const decisionHeadingMarker = '## `decision`';
524
+ ok('source: ticket-engines.md has the "UAT follow-ups (any type)" section', ticketEnginesSource.includes(uatFollowupHeadingMarker));
525
+ ok('source: the UAT follow-ups section precedes the first `decision` heading',
526
+ ticketEnginesSource.indexOf(uatFollowupHeadingMarker) !== -1 &&
527
+ ticketEnginesSource.indexOf(uatFollowupHeadingMarker) < ticketEnginesSource.indexOf(decisionHeadingMarker));
528
+ const uatFollowupSection = extractSection(ticketEnginesSource, uatFollowupHeadingMarker);
529
+ for (const phrase of ['UAT of:', 'whatever its type label', 'trailhead:seed', '`VERIFY` comment on the follow-up', 'acceptance-testing.md']) {
530
+ ok(`source: the UAT follow-ups section contains "${phrase}"`, uatFollowupSection.includes(phrase));
531
+ }
532
+
533
+ // Both the build and bug engines' Verify step, and both Resolve steps'
534
+ // Pending-UAT guard, must route through the drive-mode ask by name (the
535
+ // single source stays acceptance-testing.md; these reference it).
536
+ const verifyStepLines = ticketEnginesSource.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
537
+ ok('source: ticket-engines.md carries both the build and bug Verify step lines', verifyStepLines.length === 2);
538
+ ok('source: both Verify step lines reference the drive-mode ask', verifyStepLines.length === 2 && verifyStepLines.every((l) => l.includes('drive-mode ask')));
539
+
540
+ const pendingUatGuardLines = ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)'));
541
+ ok('source: ticket-engines.md carries both build and bug Pending-UAT guard lines', pendingUatGuardLines.length === 2);
542
+ ok('source: both Pending-UAT guard lines reference the drive-mode ask', pendingUatGuardLines.length === 2 && pendingUatGuardLines.every((l) => l.includes('drive-mode ask')));
543
+
544
+ // auto.md mentions the ask, AND (plan-review addition) a single localized span
545
+ // states its outcomes TOGETHER: takes it without the confirm gate, picks
546
+ // AI-driven when any step is agent-performable, never fakes a human-only step,
547
+ // and sets the ticket aside listing them in the run-end summary.
548
+ const autoSourcePath = path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md');
549
+ const autoSource = fs.readFileSync(autoSourcePath, 'utf8');
550
+ ok('source: auto.md mentions the drive-mode ask', autoSource.includes('drive-mode ask'));
551
+ ok('source: auto.md states the drive-mode-ask outcomes together (grouped, not scattered)',
552
+ /drive-mode ask[\s\S]{0,120}without the confirm gate[\s\S]{0,300}AI-driven[\s\S]{0,200}agent-performable[\s\S]{0,300}never fake[\s\S]{0,300}run-end summary/.test(autoSource));
553
+
554
+ // choices.md carries the call-site row for the drive-mode ask.
555
+ const choicesSourcePath = path.join(sourceSkillsDir, '_shared', 'choices.md');
556
+ const choicesSource = fs.readFileSync(choicesSourcePath, 'utf8');
557
+ ok('source: choices.md call-site table carries the drive-mode ask row', /\|[^\n]*[Dd]rive-mode ask[^\n]*\|/.test(choicesSource));
558
+
559
+ // --- #178 (3/4): acceptance.browser off keeps AI-driven UAT via commands ----
560
+ const configReferencePath = path.join(sourceSkillsDir, '_shared', 'configuration-reference.md');
561
+ const configReferenceSource = fs.readFileSync(configReferencePath, 'utf8');
562
+ ok('source: configuration-reference.md acceptance.browser explanation clarifies off still runs AI-driven checks via commands',
563
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,1000}`off`[\s\S]{0,300}AI-driven[\s\S]{0,300}commands/.test(configReferenceSource));
564
+ ok('source: configuration-reference.md guided-setup menu no longer labels the off option "Guided UAT only"',
565
+ !configReferenceSource.includes('Guided UAT only'));
566
+ ok('source: configuration-reference.md guided-setup menu relabels the off option "No browser"',
567
+ /Acceptance testing[\s\S]{0,200}No browser/.test(configReferenceSource));
568
+
569
+ // --- #178 (4/4): mirror the UAT drive-mode ask in READMEs and /docs ---------
570
+ const readmeSource = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
571
+ const readmeItSource = fs.readFileSync(path.join(repoRoot, 'README.it.md'), 'utf8');
572
+ const ticketTypesSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'ticket-types.md'), 'utf8');
573
+ const workflowSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'workflow.md'), 'utf8');
574
+ const configurationMdxSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'configuration.mdx'), 'utf8');
575
+
576
+ ok('README.md build row frames acceptance testing as asking who drives', /🔨 `build`[\s\S]{0,400}who drives/.test(readmeSource));
577
+ ok('README.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(readmeSource));
578
+ ok('README.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeSource));
579
+
580
+ ok('README.it.md build row frames acceptance testing as asking who drives (chi guida)', /🔨 `build`[\s\S]{0,400}chi guida/.test(readmeItSource));
581
+ ok('README.it.md worked example frames the login-flow acceptance as AI-driven', /chi guida[\s\S]{0,100}AI-driven/.test(readmeItSource));
582
+ ok('README.it.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeItSource));
583
+
584
+ ok('site ticket-types.md build row frames acceptance testing as asking who drives', /`build`[\s\S]{0,400}who drives/.test(ticketTypesSource));
585
+ ok('site workflow.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(workflowSource));
586
+ ok('site configuration.mdx acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(configurationMdxSource));
587
+
588
+ // --- #178 fix: keep the drive-mode ask ahead of URL resolution --------------
589
+ // Code review found the acceptance.browser clause made the drive-mode ask
590
+ // CONDITIONAL on URL resolution ("fall back to the drive-mode ask ... only
591
+ // when none can be determined"), contradicting the single source
592
+ // (acceptance-testing.md: the ask fires before any step runs, always) and
593
+ // conflating it with the URL-not-found fallback (ask for the URL, or guided
594
+ // UAT). The clause must instead state the ask always comes first and never
595
+ // gate it on URL detection.
596
+ ok('source: configuration-reference.md acceptance.browser clause does NOT make the drive-mode ask conditional on URL resolution',
597
+ !configReferenceSource.includes('fall back to the drive-mode ask'));
598
+ ok('source: configuration-reference.md acceptance.browser clause states the drive-mode ask always comes first',
599
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,200}drive-mode ask[\s\S]{0,120}always comes first/.test(configReferenceSource));
600
+
601
+ // The run-end summary's enumerated set-aside category list is missing the new
602
+ // human-only-UAT-steps class (added by #178); it must be listed there too,
603
+ // matching the wording already used for it at line 29 ("human-only steps").
604
+ const runEndSummarySection = extractSection(autoSource, '## Run-end summary');
605
+ ok('source: auto.md Run-end summary section exists', runEndSummarySection.length > 0);
606
+ ok('source: auto.md Run-end summary "what remains" list includes human-only UAT steps',
607
+ /what remains[\s\S]{0,400}human-only UAT steps/.test(runEndSummarySection));
608
+
258
609
  // --- codex hooks (#29) --------------------------------------------------------
259
610
  const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
260
611
  ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
@@ -271,6 +622,14 @@ ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-gua
271
622
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
272
623
  ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
273
624
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
625
+ // #169: search-guard is Claude-Code-specific (it only prevents arming
626
+ // Claude's Read() deny rule under bypass permissions, a mechanism Codex
627
+ // lacks), so it must neither be registered nor copied on a Codex install.
628
+ ok('codex: hooks.json does NOT register search-guard under any event', !codexHooksJsonStr.includes('trailhead-search-guard.js'));
629
+ ok('codex: skills/trailhead/hooks/trailhead-search-guard.js does NOT exist',
630
+ !fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-search-guard.js')));
631
+ ok('codex: skills/trailhead/hooks/lib/shell-scan.js still exists (secret-read-guard require target)',
632
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'shell-scan.js')));
274
633
 
275
634
  const codexConfigTomlPath = path.join(codexDir, 'config.toml');
276
635
  ok('codex: config.toml exists', fs.existsSync(codexConfigTomlPath));
@@ -378,7 +737,7 @@ ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
378
737
 
379
738
  // --- claude regression --------------------------------------------------------
380
739
  const claudeDir = mktmp();
381
- runInstaller([`--claude`, `--dir=${claudeDir}`]);
740
+ const claudeInstallOut = String(runInstaller([`--claude`, `--dir=${claudeDir}`]));
382
741
 
383
742
  const claudeSkillPath = path.join(claudeDir, 'skills', 'trailhead', 'SKILL.md');
384
743
  ok('claude: skills/trailhead/SKILL.md exists', fs.existsSync(claudeSkillPath));
@@ -406,6 +765,12 @@ ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require targe
406
765
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
407
766
  ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
408
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'));
409
774
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
410
775
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
411
776
  // payload and assert it does not crash with a missing-module error.
@@ -663,6 +1028,225 @@ try {
663
1028
  ok('unknown flag after a valid flag: still rejected', unknownAfterValidRejected);
664
1029
  ok('unknown flag after a valid flag: installs nothing', nothingInstalledUnder(unknownAfterValidDir));
665
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
+
666
1250
  // --- cleanup -------------------------------------------------------------------
667
1251
  for (const d of tmpDirs) {
668
1252
  fs.rmSync(d, { recursive: true, force: true });