@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -118,11 +118,41 @@ ok('codex: commit-msg template projected', fs.existsSync(codexTemplate));
118
118
  ok('codex: commit-msg template is verbatim (not converted)',
119
119
  fs.readFileSync(codexTemplate, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg'), 'utf8'));
120
120
 
121
+ // #257: the hook logic ships as a .cjs beside the sh wrapper.
122
+ const codexCommitMsgCjs = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-commit-msg.cjs');
123
+ ok('codex: commit-msg .cjs logic projected', fs.existsSync(codexCommitMsgCjs));
124
+ ok('codex: commit-msg .cjs logic is verbatim (not converted)',
125
+ fs.readFileSync(codexCommitMsgCjs, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg.cjs'), 'utf8'));
126
+
121
127
  // #185: the commit-msg hook sync script ships wholesale with templates/ too.
122
128
  const codexCommitMsgSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
123
129
  ok('codex: commit-msg-sync script projected', fs.existsSync(codexCommitMsgSync));
124
130
  ok('codex: commit-msg-sync script is byte-identical to source',
125
131
  fs.readFileSync(codexCommitMsgSync, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg-sync.js'), 'utf8'));
132
+ // #205: the work state helper rides in templates/ on Codex too, executable.
133
+ const codexWorkState = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-work-state.js');
134
+ ok('codex: work-state helper projected', fs.existsSync(codexWorkState));
135
+ ok('codex: work-state helper is byte-identical to source',
136
+ fs.readFileSync(codexWorkState, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-work-state.js'), 'utf8'));
137
+ ok('codex: work-state helper keeps its executable bit', (fs.statSync(codexWorkState).mode & 0o111) !== 0);
138
+ // #242: the isolation copy helper rides in templates/ on Codex too, executable.
139
+ const codexIsolationCopy = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-isolation-copy.js');
140
+ ok('codex: isolation-copy helper projected', fs.existsSync(codexIsolationCopy));
141
+ ok('codex: isolation-copy helper is byte-identical to source',
142
+ fs.readFileSync(codexIsolationCopy, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-isolation-copy.js'), 'utf8'));
143
+ ok('codex: isolation-copy helper keeps its executable bit', (fs.statSync(codexIsolationCopy).mode & 0o111) !== 0);
144
+ // #243: the stale-base preflight helper rides in templates/ on Codex too, executable.
145
+ const codexBasePreflight = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-base-preflight.js');
146
+ ok('codex: base-preflight helper projected', fs.existsSync(codexBasePreflight));
147
+ ok('codex: base-preflight helper is byte-identical to source',
148
+ fs.readFileSync(codexBasePreflight, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-base-preflight.js'), 'utf8'));
149
+ ok('codex: base-preflight helper keeps its executable bit', (fs.statSync(codexBasePreflight).mode & 0o111) !== 0);
150
+ // #252: the UAT setup helper rides in templates/ on Codex too, executable.
151
+ const codexUatSetup = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-uat-setup.js');
152
+ ok('codex: uat-setup helper projected', fs.existsSync(codexUatSetup));
153
+ ok('codex: uat-setup helper is byte-identical to source',
154
+ fs.readFileSync(codexUatSetup, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-uat-setup.js'), 'utf8'));
155
+ ok('codex: uat-setup helper keeps its executable bit', (fs.statSync(codexUatSetup).mode & 0o111) !== 0);
126
156
  // Il sync del label guard (migrazione all'if a livello di job) viaggia con templates/.
127
157
  const codexLabelGuardSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-label-guard-sync.js');
128
158
  ok('codex: label-guard-sync script is byte-identical to source', fs.existsSync(codexLabelGuardSync) &&
@@ -134,6 +164,18 @@ ok('codex: audit-walk script is byte-identical to source',
134
164
  fs.readFileSync(codexAuditWalk, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-audit-walk.js'), 'utf8'));
135
165
  ok('codex: audit-walk script keeps its executable bit', (fs.statSync(codexAuditWalk).mode & 0o111) !== 0);
136
166
 
167
+ // #253: the Resolve bookkeeping helpers ride in templates/ on Codex too, executable,
168
+ // and find the secret-guard module from their installed location.
169
+ for (const helper of ['trailhead-issue-body.js', 'trailhead-dashboard.js']) {
170
+ const projected = path.join(codexDir, 'skills', 'trailhead', 'templates', helper);
171
+ ok(`codex: ${helper} projected`, fs.existsSync(projected));
172
+ ok(`codex: ${helper} is byte-identical to source`,
173
+ fs.readFileSync(projected, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', helper), 'utf8'));
174
+ ok(`codex: ${helper} keeps its executable bit`, (fs.statSync(projected).mode & 0o111) !== 0);
175
+ }
176
+ ok('codex: the installed issue-body helper finds the secret guard',
177
+ require(path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-issue-body.js')).loadGuard() !== null);
178
+
137
179
  ok('codex: no commands dir', !fs.existsSync(path.join(codexDir, 'commands')));
138
180
  ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.json')));
139
181
  // #46: per-verb discoverability now projects one Codex SKILL per verb
@@ -143,6 +185,8 @@ ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.
143
185
  // dir, so its discoverability is the cluster itself; use a non-colliding verb.
144
186
  ok('codex: per-verb skills/trailhead-bug/SKILL.md exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead-bug', 'SKILL.md')));
145
187
  ok('codex: trailhead-bug SKILL.md frontmatter is at byte 0', fs.readFileSync(path.join(codexDir, 'skills', 'trailhead-bug', 'SKILL.md'), 'utf8').startsWith('---\nname: trailhead-bug\n'));
188
+ // #208: /trailhead:panel is answered by the Claude Code mod only: no Codex skill for it.
189
+ ok('codex: no per-verb skill for the mod-only panel verb', !fs.existsSync(path.join(codexDir, 'skills', 'trailhead-panel')));
146
190
  ok('codex: trailhead-bug skill delegates to $trailhead bug', fs.readFileSync(path.join(codexDir, 'skills', 'trailhead-bug', 'SKILL.md'), 'utf8').includes('$trailhead bug'));
147
191
  // #190: audit verb projected per-verb on Codex, description from commands/audit.md.
148
192
  const auditCmdSrc190 = fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'commands', 'audit.md'), 'utf8');
@@ -202,6 +246,28 @@ for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-v
202
246
  ok(`source: ${cl}/SKILL.md does NOT duplicate the core-list signature line`, !fs.readFileSync(path.join(sourceSkillsDir, cl, 'SKILL.md'), 'utf8').includes(loadFirstSignature));
203
247
  }
204
248
 
249
+ // --- #237: the shared core is loaded by ABSOLUTE path with the Read tool -----
250
+ const clusters237 = ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage'];
251
+ const loadFirst237 = fs.readFileSync(loadFirstSourcePath, 'utf8');
252
+ for (const cl of clusters237) {
253
+ const src = fs.readFileSync(path.join(sourceSkillsDir, cl, 'SKILL.md'), 'utf8');
254
+ const start = src.indexOf('## Load first');
255
+ const rest = src.slice(start + 3);
256
+ const nextH = rest.search(/\n## /);
257
+ const para = start < 0 ? '' : (nextH < 0 ? rest : rest.slice(0, nextH));
258
+ ok(`#237: ${cl} Load first names the base directory`, /base directory/i.test(para));
259
+ ok(`#237: ${cl} Load first requires an absolute path`, /absolute path/i.test(para));
260
+ ok(`#237: ${cl} Load first requires the Read tool`, /Read tool/.test(para));
261
+ ok(`#237: ${cl} Load first has no bare relative "read ../_shared/load-first.md"`, !/read `\.\.\/_shared\/load-first\.md`/i.test(para));
262
+ ok(`#237: ${cl} SKILL.md has no shell read of a relative _shared/ path`, !/\b(cat|head|tail|sed\s+-n\s+\S+)\s+(\.\.\/)*_shared\//.test(src));
263
+ ok(`#237: ${cl} SKILL.md has no cd-and-read compound`, !/\bcd\s+\S+\s*&&\s*(cat|head|tail|sed|grep)\b/.test(src));
264
+ }
265
+ ok('#237: load-first.md states the absolute-path rule with the Read tool', /absolute path/i.test(loadFirst237) && /Read tool/.test(loadFirst237));
266
+ ok('#237: load-first.md resolves <lib> and <templates> to absolute paths', loadFirst237.includes('`<lib>` (an absolute path') && loadFirst237.includes('`<templates>` (an absolute path)'));
267
+ ok('#237: load-first.md covers every other ../_shared/ or references/ path', loadFirst237.includes('references/') && /every other/i.test(loadFirst237));
268
+ ok('#237: load-first.md no longer says paths are relative to the cluster SKILL.md', !loadFirst237.includes('relative to the cluster `SKILL.md`') && !loadFirst237.includes('relative to the cluster SKILL.md that'));
269
+ ok('#237: load-first.md has no shell read of a relative _shared/ path', !/\b(cat|head|tail|sed\s+-n\s+\S+)\s+(\.\.\/)*_shared\//.test(loadFirst237) && !/\bcd\s+\S+\s*&&\s*(cat|head|tail|sed|grep)\b/.test(loadFirst237));
270
+
205
271
  // --- #146: single-sourced ticket-language directive (anti-drift) -------------
206
272
  // The standing ticket-language rule (write Issue prose + commit bodies in
207
273
  // config.ticket.language, decoupled from the chat language) lives in ONE place,
@@ -279,6 +345,27 @@ ok('source: capture.md offers /trailhead:quick as the lighter alternative, after
279
345
  ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
280
346
  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));
281
347
 
348
+ // --- #246: `bug --of <ticket>` inherits the origin's destination ------------
349
+ // Source-level invariant: capture.md's `bug` bullet makes `--of` inherit the origin
350
+ // ticket's map (open) or the whiteboard without the which-map ask, checks the origin
351
+ // map's state before inheriting, falls back to the Map or whiteboard rule on a closed
352
+ // map / absent or contradictory labels, and names the inherited destination and the
353
+ // origin (each by name and number) in the confirmation line. The Map or whiteboard
354
+ // paragraph cross-references the exception (single source stays in the `bug` bullet).
355
+ const captureBugBullet = (captureSource.match(/^- \*\*`bug`\*\*.*$/m) || [''])[0];
356
+ const captureMapParagraph = (captureSource.match(/^\*\*Map or whiteboard:\*\*.*$/m) || [''])[0];
357
+ ok('source: capture.md `bug --of` inherits the origin destination instead of asking', /\*\*`--of` also inherits the origin's destination instead of asking/.test(captureBugBullet));
358
+ ok('source: capture.md `bug --of` lands on an OPEN origin map with no ask, and a closed one falls back',
359
+ /\*\*open\*\* -> land on map `<n>`[\s\S]{0,120}no ask; \*\*closed\*\* -> fall back/.test(captureBugBullet));
360
+ ok('source: capture.md `bug --of` lands on the whiteboard for a whiteboard origin, no ask, with the dashboard refresh',
361
+ /carries `trailhead:whiteboard`[\s\S]{0,80}land on the whiteboard, no ask \(dashboard refresh/.test(captureBugBullet));
362
+ ok('source: capture.md `bug --of` checks the origin map state (--json state) before inheriting', /gh issue view <n> --json state/.test(captureBugBullet));
363
+ ok('source: capture.md `bug --of` falls back to the Map or whiteboard rule on closed map, absent or contradictory labels',
364
+ /[Ff]all back to the existing Map or whiteboard rule[\s\S]{0,600}closed[\s\S]{0,300}neither label[\s\S]{0,300}contradictory/.test(captureBugBullet));
365
+ ok('source: capture.md `bug --of` confirmation names the inherited destination and the origin, each by name and number',
366
+ /[Cc]onfirmation line[\s\S]{0,300}inherited[\s\S]{0,200}by name and number[\s\S]{0,200}origin[\s\S]{0,100}by name and number/.test(captureBugBullet));
367
+ ok('source: capture.md Map or whiteboard paragraph cross-references the `bug --of` ask exception', /`bug --of`[\s\S]{0,200}skip/.test(captureMapParagraph));
368
+
282
369
  // --- #179: `quick <n>` on a map ticket is never "off the map" ----------------
283
370
  // Source-level invariant: `quick <n>` on an EXISTING ticket does not leave the
284
371
  // map (it keeps its trailhead:map-<n> label and sub-issue edge, and dependent-
@@ -512,6 +599,53 @@ const workSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-wo
512
599
  ok('source: trailhead-work SKILL.md out-of-scope close drops the native sub-issue edge',
513
600
  /out of scope\*\* \(label, then \*\*drop its native sub-issue edge\*\*/.test(workSkillSource));
514
601
 
602
+ // The text of a structural unit of a source, from its anchor: a missing anchor fails loudly.
603
+ const unitFrom = (source, anchor, endPattern) => {
604
+ const start = source.indexOf(anchor);
605
+ if (start === -1) throw new Error(`anchor not found in source: ${anchor}`);
606
+ const rest = source.slice(start);
607
+ const end = rest.slice(anchor.length).search(endPattern);
608
+ return end === -1 ? rest : rest.slice(0, anchor.length + end);
609
+ };
610
+ // work step 1: its paragraph, up to the next numbered step or blank line.
611
+ const workStep1 = unitFrom(workSkillSource, '1. Load the **map**', /\n(?:\d+\. |\s*\n)/);
612
+ // quick's isolation bullet: up to the end of its line.
613
+ const quickIsolationBullet = unitFrom(workSkillSource, '- **Same isolation setup as `work`.**', /\n/);
614
+ // The rule is stated once, in multi-map.md; the work places point to it.
615
+ const multiMapSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'multi-map.md'), 'utf8');
616
+ const namedTicketMapSentence = /a named ticket that carries a `trailhead:map-<n>` label makes that map the active map \(write `\.trailhead\/active-map`\); a whiteboard ticket leaves the active map alone/i;
617
+ const namedTicketMapPointer = /named ticket's map becomes the active map[\s\S]*whiteboard ticket leaves it alone[\s\S]*multi-map\.md/i;
618
+ ok('source: multi-map.md Active map paragraph states the named-ticket rule',
619
+ namedTicketMapSentence.test(unitFrom(multiMapSource, '**Active map.**', /\n/)));
620
+ ok('source: trailhead-work SKILL.md work step 1 points to the named-ticket active-map rule',
621
+ /its map becomes the active map, a whiteboard ticket leaves it alone[\s\S]*multi-map\.md/i.test(workStep1));
622
+ ok('source: trailhead-work SKILL.md quick isolation bullet points to the named-ticket active-map rule',
623
+ namedTicketMapPointer.test(quickIsolationBullet));
624
+
625
+ // --- #234: the ticket-start scope confirm is a host menu, not free text ------
626
+ const workStep3 = unitFrom(workSkillSource, '3. **Brief the ticket and confirm scope', /\n(?:\d+\. |\s*\n)/);
627
+ const quickBriefBullet = unitFrom(workSkillSource, '- **Briefs and confirms scope first', /\n/);
628
+ const choicesScopeSource = fs.readFileSync(path.join(sourceSkillsDir, "_shared", "choices.md"), "utf8");
629
+ ok('source: work step 3 poses the scope confirm as an AskUserQuestion menu, Proceed (Recommended) first, Change something second',
630
+ /`AskUserQuestion`[\s\S]{0,300}\*\*Proceed\*\* \(Recommended\)[\s\S]{0,200}\*\*Change something\*\*/.test(workStep3));
631
+ ok('source: work step 3 offers the delegate option on the scope confirm',
632
+ /\*\*delegate\*\*[\s\S]{0,200}choices\.md/i.test(workStep3));
633
+ ok('source: work step 3 divergent change is a menu (as-is vs separate ticket) without delegate',
634
+ /diverges[\s\S]{0,500}`AskUserQuestion`[\s\S]{0,300}without the delegate option/.test(workStep3));
635
+ ok('source: work step 3 has a plain-text numbered list fallback for hosts without a question tool (Codex)',
636
+ /no structured question tool[\s\S]{0,120}plain-text numbered list/.test(workStep3));
637
+ ok('source: work step 3 keeps the auto sentence (proceeds without stopping to brief)',
638
+ /`auto` proceeds \(takes the delegate option\) without stopping to brief/.test(workStep3));
639
+ ok('source: work step 3 no longer asks the change question as free text',
640
+ !/ask whether they want to change anything/.test(workStep3));
641
+ ok('source: quick brief bullet poses the same menu and keeps a one-line note for quick "<text>"',
642
+ /`AskUserQuestion`/.test(quickBriefBullet) && /\*\*Proceed\*\* \(Recommended\)/.test(quickBriefBullet) &&
643
+ /one-line note/.test(quickBriefBullet) && !/one-line confirmation/.test(quickBriefBullet));
644
+ ok('source: choices.md call-site table has the ticket-start scope confirm row (delegate yes, defer no)',
645
+ /\| Ticket-start scope confirm \(`work`\/`quick` brief\) \| Yes[^|]*\| No[^|]*\|/.test(choicesScopeSource));
646
+ ok('source: choices.md call-site table has the divergent-change row (delegate no, defer no)',
647
+ /\| Divergent change at the brief \(as-is vs separate ticket\) \| No[^|]*\| No[^|]*\|/.test(choicesScopeSource));
648
+
515
649
  const inboxSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'inbox.md'), 'utf8');
516
650
  ok('source: inbox.md out-of-scope close drops the native sub-issue edge',
517
651
  /`trailhead:out-of-scope`, \*\*drop its native sub-issue edge\*\*/.test(inboxSource));
@@ -549,7 +683,7 @@ for (const phrase of [
549
683
  '**wait for an explicit answer**',
550
684
  '**silence or an ambiguous reply re-asks**',
551
685
  '**One-path skip**',
552
- '**state the reason in one line**',
686
+ '**states the reason in one line**',
553
687
  'VERIFY',
554
688
  'only for the steps it cannot perform',
555
689
  'runs unchanged',
@@ -634,6 +768,11 @@ ok('source: auto.md states the drive-mode-ask outcomes together (grouped, not sc
634
768
  const choicesSourcePath = path.join(sourceSkillsDir, '_shared', 'choices.md');
635
769
  const choicesSource = fs.readFileSync(choicesSourcePath, 'utf8');
636
770
  ok('source: choices.md call-site table carries the drive-mode ask row', /\|[^\n]*[Dd]rive-mode ask[^\n]*\|/.test(choicesSource));
771
+ // Formato della scelta: fino a 4 opzioni reali -> menu; oltre -> lista a lettere in prosa.
772
+ ok('source: choices.md routes a choice of up to 4 real options to the AskUserQuestion menu',
773
+ /\*\*Which format\.\*\*[\s\S]{0,400}up to 4 real options[\s\S]{0,200}`AskUserQuestion` menu/.test(choicesSource));
774
+ ok('source: choices.md routes a choice of more than 4 real options to a lettered prose list',
775
+ /\*\*Which format\.\*\*[\s\S]{0,800}more than 4 real options[\s\S]{0,200}lettered list/.test(choicesSource));
637
776
 
638
777
  // --- #178 (3/4): acceptance.browser off keeps AI-driven UAT via commands ----
639
778
  const configReferencePath = path.join(sourceSkillsDir, '_shared', 'configuration-reference.md');
@@ -685,22 +824,124 @@ ok('source: auto.md Run-end summary section exists', runEndSummarySection.length
685
824
  ok('source: auto.md Run-end summary "what remains" list includes human-only UAT steps',
686
825
  /what remains[\s\S]{0,400}human-only UAT steps/.test(runEndSummarySection));
687
826
 
827
+ // --- #254: skip option at the drive-mode ask (a waiver, not a deferral) -----
828
+ // The waiver's operational contract is single-sourced in the Drive-mode bullet
829
+ // (acceptance-testing.md); the other call-sites carry only the routing /
830
+ // closure consequence and name the bullet.
831
+ const driveModeMarkerLine = driveModeSpan.split('\n')[0] || '';
832
+ ok('source: Drive-mode marker line names the skip option alongside AI-driven / human-driven',
833
+ driveModeMarkerLine.includes('AI-driven') && driveModeMarkerLine.includes('human-driven') && driveModeMarkerLine.includes('skip (waive acceptance)'));
834
+ const skipSubBullet = driveModeSpan.split('\n').find((l) => l.startsWith(' - **Skip (waive acceptance).**')) || '';
835
+ ok('source: Drive-mode span carries the "Skip (waive acceptance)" sub-bullet', skipSubBullet.length > 0);
836
+ for (const phrase of [
837
+ 'waived by the user (unconfirmed)',
838
+ 'never a pass',
839
+ 'the Pending-UAT guard does not fire',
840
+ 'no follow-up',
841
+ 'The delegate option never picks skip',
842
+ '`auto` never picks skip',
843
+ 'UAT of:',
844
+ 'closes as waived',
845
+ 'resolution comment',
846
+ 'all three start paths',
847
+ ]) {
848
+ ok(`source: Skip sub-bullet pins "${phrase}"`, skipSubBullet.includes(phrase));
849
+ }
850
+ const oneAskLine = driveModeSpan.split('\n').find((l) => l.startsWith(' - **One-path skip**')) || '';
851
+ ok('source: one-path skip no longer suppresses the ask outright (drops only the AI-driven option)',
852
+ oneAskLine.includes('drops only the AI-driven option') && oneAskLine.includes('human-driven or skip') && !oneAskLine.includes('skip the ask'));
853
+ ok('source: one-path skip still states the reason in one line, naming the blocking class',
854
+ oneAskLine.includes('**states the reason in one line**') && oneAskLine.includes('blocking class') && !oneAskLine.includes('prefaced by'));
855
+ ok('source: on the reduced ask (no performable step) the delegate option resolves to human-driven, never skip',
856
+ /delegate option resolves to human-driven \(never skip\)/.test(oneAskLine));
857
+ ok('source: Pending-UAT bullet distinguishes a waiver from a deferral', pendingUatLine.includes('a waiver is not a deferral'));
858
+ ok('source: Pending-UAT bullet pins the waiver exception (picked skip, recorded as unconfirmed, nothing opened)',
859
+ /The one exception is an explicit waiver[^.]*\. The human picked skip at the drive-mode ask[^.]*unconfirmed in `VERIFY`[^.]*nothing is opened/.test(pendingUatLine));
860
+ const waiverResolveRe = /resolv(e|ing) on a confirmed pass, or on an explicit waiver if the user skips at the ask/;
861
+ ok('source: Pending-UAT bullet option (a) resolves on a confirmed pass or an explicit waiver',
862
+ waiverResolveRe.test(pendingUatLine) && !pendingUatLine.includes('only on a confirmed pass'));
863
+ ok('source: both ticket-engines Resolve lines let option (a) resolve on a confirmed pass or an explicit waiver',
864
+ guardLines254Early().length === 2 && guardLines254Early().every((l) => waiverResolveRe.test(l) && !l.includes('only on a confirmed pass')));
865
+ ok('source: with-no-ticket preflight-then-ask phrase still occurs exactly twice in ticket-engines.md',
866
+ ticketEnginesSource.split('with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask').length - 1 === 2);
867
+ ok('source: both Verify steps let the user skip, recorded as waived',
868
+ verifyStepLines.length === 2 && verifyStepLines.every((l) => /or skip \(waive acceptance\), recorded as waived/.test(l)));
869
+ ok('source: techniques.md index row and auto.md lead-in name skip',
870
+ /ask who drives \(AI or human, or skip\)/.test(fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques.md'), 'utf8')) &&
871
+ autoSource.includes('drive-mode ask (AI-driven, human-driven, or skip)'));
872
+
873
+ function guardLines254Early() { return ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)')); }
874
+ const guardLines254 = ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)'));
875
+ ok('source: both Pending-UAT guard lines say the guard does not fire when acceptance was explicitly waived',
876
+ guardLines254.length === 2 && guardLines254.every((l) => l.includes('explicitly waived')));
877
+ ok('source: both Resolve steps state the waiver in the resolution comment',
878
+ guardLines254.length === 2 && guardLines254.every((l) => /resolution comment states the waiver/.test(l)));
879
+ ok('source: the UAT follow-ups section closes on a confirmed pass or an explicit waiver',
880
+ uatFollowupSection.includes('confirmed pass or an explicit waiver') && uatFollowupSection.includes('closes as waived'));
881
+ const driveRowLine = choicesSource.split('\n').find((l) => /^\|[^\n]*[Dd]rive-mode ask/.test(l)) || '';
882
+ ok('source: choices.md drive-mode row names skip and states delegate never picks it',
883
+ driveRowLine.includes('skip') && /delegate never picks skip/.test(driveRowLine) && driveRowLine.includes('reduced ask') && driveRowLine.includes('human-driven'));
884
+ ok('source: configuration-reference.md acceptance.browser paragraph names skip alongside AI-driven / human-driven',
885
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,200}human-driven,? or skip/.test(configReferenceSource));
886
+ ok('source: auto.md states auto never picks skip', /never picks skip[^\n]*waiving acceptance/.test(autoSource));
887
+
888
+ // Docs mirror: README, README.it, site (waiver recorded as unconfirmed; no unconditional "never closes").
889
+ ok('README.md acceptance testing section names the waiver (skip) recorded as unconfirmed',
890
+ /\*\*Skip is a waiver, not a deferral\*\*[^\n]{0,80}`waived by the user \(unconfirmed\)`, never as a pass/.test(readmeSource));
891
+ ok('README.md no-performable-step case asks human-driven or skip', /human-driven\*\* or \*\*skip\*\*/.test(readmeSource));
892
+ ok('README.md point 6 allows an explicit waiver', /never closes on a UAT nobody ran unless you explicitly waive it/.test(readmeSource));
893
+ const siteAcceptanceSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'acceptance-testing.md'), 'utf8');
894
+ ok('docs: auto never-picks-skip antecedent is the auto run, in all three docs',
895
+ /listed in the run's summary; `\/trailhead:auto` never picks skip/.test(readmeSource) && /listed in the run's summary; `\/trailhead:auto` never picks skip/.test(siteAcceptanceSource) && /elencato nel riepilogo della run; `\/trailhead:auto` non sceglie mai skip/.test(readmeItSource));
896
+ ok('README.md build row and acceptance.browser row name skip',
897
+ /🔨 `build`[\s\S]{0,500}or skip/.test(readmeSource) && /`acceptance\.browser`[\s\S]{0,300}or skip/.test(readmeSource));
898
+ ok('README.it.md acceptance testing section names the waiver (skip) recorded as unconfirmed',
899
+ /\*\*Skip è una rinuncia, non un rinvio\*\*[^\n]{0,160}`waived by the user \(unconfirmed\)`, cioè non confermata/.test(readmeItSource));
900
+ ok('README.it.md no-performable-step case asks human-driven o skip', /human-driven\*\* o \*\*skip\*\*/.test(readmeItSource));
901
+ ok('README.it.md point 6 allows an explicit waiver', /non si chiude mai su una UAT che nessuno ha eseguito, salvo che tu ci rinunci esplicitamente/.test(readmeItSource));
902
+ ok('README.it.md build row and acceptance.browser row name skip',
903
+ /🔨 `build`[\s\S]{0,500}o skip/.test(readmeItSource) && /`acceptance\.browser`[\s\S]{0,300}o skip/.test(readmeItSource));
904
+ ok('site acceptance-testing.md names the waiver (skip) recorded as unconfirmed', /\*\*Skip is a waiver, not a deferral\*\*[^\n]{0,80}`waived by the user \(unconfirmed\)`, never as a pass/.test(siteAcceptanceSource));
905
+ ok('site acceptance-testing.md no-performable-step case asks human-driven or skip', /human-driven\*\* or \*\*skip\*\*/.test(siteAcceptanceSource));
906
+ ok('site acceptance-testing.md point 6 allows an explicit waiver', /never closes on a UAT nobody ran unless you explicitly waive it/.test(siteAcceptanceSource));
907
+ ok('site ticket-types.md build row and configuration.mdx acceptance.browser row name skip',
908
+ /`build`[\s\S]{0,500}or skip/.test(ticketTypesSource) && /`acceptance\.browser`[\s\S]{0,300}or skip/.test(configurationMdxSource));
909
+
688
910
  // --- codex hooks (#29) --------------------------------------------------------
689
911
  const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
690
912
  ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
691
913
  const codexHooksJson = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
692
914
  const codexHooksJsonStr = JSON.stringify(codexHooksJson);
693
- ok('codex: hooks.json registers commit-guard under PreToolUse', codexHooksJsonStr.includes('trailhead-commit-guard.js') &&
694
- (codexHooksJson.hooks.PreToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-commit-guard.js'))));
695
- ok('codex: hooks.json registers secret-guard under PreToolUse', (codexHooksJson.hooks.PreToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-secret-guard.js'))));
915
+ ok('codex: hooks.json registers the merged Bash guards under PreToolUse', (codexHooksJson.hooks.PreToolUse || []).some((g) => g.matcher === 'Bash' &&
916
+ (g.hooks || []).some((h) => h.command.includes('trailhead-bash-guards.js') && h.command.endsWith(' --guards=commit,secret,install,body'))));
917
+ ok('codex: hooks.json registers none of the per-guard scripts on their own', !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(codexHooksJsonStr));
918
+ ok('codex: the five guard modules the merged hook requires are still shipped (no search)',
919
+ ['commit', 'secret', 'install', 'body'].every((n) => fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', `trailhead-${n}-guard.js`))) &&
920
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-bash-guards.js')));
696
921
  ok('codex: hooks.json registers injection-scanner under PostToolUse', (codexHooksJson.hooks.PostToolUse || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-issue-injection-scanner.js'))));
697
922
  ok('codex: hooks.json registers check-update under SessionStart', (codexHooksJson.hooks.SessionStart || []).some((g) => (g.hooks || []).some((h) => h.command.includes('trailhead-check-update.js'))));
698
923
 
924
+ // #202: the mod (hooks/mod/register.tsx + the "modules" key) is Claude Code only;
925
+ // the Codex projection must carry neither.
926
+ function listFilesRecursive(dir) {
927
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
928
+ const full = path.join(dir, e.name);
929
+ return e.isDirectory() ? listFilesRecursive(full) : [full];
930
+ });
931
+ }
932
+ ok('codex: hooks.json has no "modules" key (mod is Claude-only, #202)', !('modules' in codexHooksJson));
933
+ ok('codex: no register.tsx under the install dir (#202)', !listFilesRecursive(codexDir).some((f) => path.basename(f) === 'register.tsx'));
934
+ ok('codex: no .ts or .tsx file anywhere under the install dir (#202)', !listFilesRecursive(codexDir).some((f) => f.endsWith('.ts') || f.endsWith('.tsx')));
935
+
699
936
  ok('codex: skills/trailhead/hooks/trailhead-secret-guard.js exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-secret-guard.js')));
700
937
  ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-guard require target)',
701
938
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
702
939
  ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
703
940
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
941
+ ok('codex: skills/trailhead/hooks/lib/body-sources.js exists (secret-guard require target, #210)',
942
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'body-sources.js')));
943
+ ok('codex: skills/trailhead/hooks/lib/unmodelled.js exists (guards require target, #227)',
944
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'unmodelled.js')));
704
945
  ok('codex: skills/trailhead/hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
705
946
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'model-defaults-review.js')));
706
947
  ok('codex: skills/trailhead/hooks/lib/model-defaults.json exists (#186)',
@@ -796,6 +1037,25 @@ ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (()
796
1037
  return tomls.every((f) => !/^model = /m.test(fs.readFileSync(path.join(dir, f), 'utf8')));
797
1038
  })());
798
1039
 
1040
+ // #250: the reviewing block (single source scripts/agent-blocks.md) is projected
1041
+ // verbatim into the two reviewing agents' TOMLs and into none of the other six.
1042
+ {
1043
+ const sync250 = require(path.join(repoRoot, 'scripts', 'sync-agent-blocks.js'));
1044
+ const reviewingBlock250 = sync250.parseSource(fs.readFileSync(path.join(repoRoot, 'scripts', 'agent-blocks.md'), 'utf8')).reviewing;
1045
+ ok('#250: the source defines a reviewing block', typeof reviewingBlock250 === 'string' && reviewingBlock250.length > 0);
1046
+ const agentsDir250 = path.join(unsetCodexHomeDir, 'agents');
1047
+ const reviewingNames250 = new Set(['trailhead-verify', 'trailhead-review']); // the code-review agent projects as trailhead-review.toml
1048
+ const tomls250 = fs.readdirSync(agentsDir250).filter((f) => /^trailhead-.*\.toml$/.test(f));
1049
+ ok('#250: all 8 TOMLs are checked', tomls250.length === 8);
1050
+ for (const f of tomls250) {
1051
+ const name = f.replace(/\.toml$/, '');
1052
+ const text = fs.readFileSync(path.join(agentsDir250, f), 'utf8');
1053
+ const has = typeof reviewingBlock250 === 'string' && reviewingBlock250.length > 0 && text.includes(reviewingBlock250);
1054
+ ok(`#250: ${f} ${reviewingNames250.has(name) ? 'carries' : 'does not carry'} the reviewing block verbatim`,
1055
+ has === reviewingNames250.has(name));
1056
+ }
1057
+ }
1058
+
799
1059
  // --- codex migration (#46): install over old prompt shims sweeps them --------
800
1060
  const migCodexDir = mktmp();
801
1061
  fs.mkdirSync(path.join(migCodexDir, 'prompts'), { recursive: true });
@@ -829,7 +1089,7 @@ ok('codex uninstall: cluster trailhead-view gone', !fs.existsSync(path.join(code
829
1089
  ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
830
1090
  const h = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
831
1091
  const str = JSON.stringify(h);
832
- return !str.includes('trailhead-commit-guard.js') && !str.includes('trailhead-secret-guard.js') &&
1092
+ return !str.includes('trailhead-bash-guards.js') && !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(str) &&
833
1093
  !str.includes('trailhead-issue-injection-scanner.js') && !str.includes('trailhead-check-update.js');
834
1094
  })());
835
1095
 
@@ -858,17 +1118,31 @@ ok('claude: trailhead-work/../_shared/ticket-language.md resolves', fs.existsSyn
858
1118
 
859
1119
  ok('claude: commands/trailhead/work.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'work.md')));
860
1120
  ok('claude: commands/trailhead/audit.md projected', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'audit.md')));
1121
+ ok('claude: commands/trailhead/panel.md projected (the mod answers it)', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'panel.md')));
861
1122
  ok('claude: commands/trailhead/auto.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'auto.md')));
862
1123
  ok('claude: hooks/trailhead-commit-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-commit-guard.js')));
863
1124
  ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require target)',
864
1125
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
865
1126
  ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
866
1127
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
1128
+ ok('claude: hooks/lib/body-sources.js exists (secret-guard require target, #210)',
1129
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'body-sources.js')));
1130
+ ok('claude: hooks/lib/unmodelled.js exists (guards require target, #227)',
1131
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'unmodelled.js')));
867
1132
  ok('claude: hooks/lib/model-defaults-review.js exists (check-update require target, #186)',
868
1133
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults-review.js')));
869
1134
  ok('claude: hooks/lib/model-defaults.json exists (#186)',
870
1135
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'model-defaults.json')));
871
1136
 
1137
+ // #202: the mod is not delivered by the npm installer yet.
1138
+ ok('claude: no hooks/mod/ dir in the npm install (#202)', !fs.existsSync(path.join(claudeDir, 'hooks', 'mod')));
1139
+
1140
+ // #257: the hook logic ships as a .cjs beside the sh wrapper.
1141
+ const claudeCommitMsgCjs = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg.cjs');
1142
+ ok('claude: commit-msg .cjs logic projected', fs.existsSync(claudeCommitMsgCjs));
1143
+ ok('claude: commit-msg .cjs logic is byte-identical to source',
1144
+ fs.readFileSync(claudeCommitMsgCjs, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg.cjs'), 'utf8'));
1145
+
872
1146
  // #185: the commit-msg hook sync script ships wholesale with templates/ too.
873
1147
  const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
874
1148
  ok('claude: commit-msg-sync script projected', fs.existsSync(claudeCommitMsgSync));
@@ -881,6 +1155,17 @@ const claudeAuditWalk = path.join(claudeDir, 'trailhead', 'templates', 'trailhea
881
1155
  ok('claude: audit-walk script projected', fs.existsSync(claudeAuditWalk));
882
1156
  ok('claude: audit-walk script is byte-identical to source',
883
1157
  fs.readFileSync(claudeAuditWalk, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-audit-walk.js'), 'utf8'));
1158
+ // #253: the Resolve bookkeeping helpers are installed, and (npm layout: templates at
1159
+ // <config>/trailhead/templates, hooks at <config>/hooks) find the secret guard.
1160
+ for (const helper of ['trailhead-issue-body.js', 'trailhead-dashboard.js']) {
1161
+ const installed = path.join(claudeDir, 'trailhead', 'templates', helper);
1162
+ ok(`claude: ${helper} installed`, fs.existsSync(installed));
1163
+ ok(`claude: ${helper} is byte-identical to source`,
1164
+ fs.readFileSync(installed, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', helper), 'utf8'));
1165
+ ok(`claude: ${helper} keeps its executable bit`, (fs.statSync(installed).mode & 0o111) !== 0);
1166
+ }
1167
+ ok('claude: the installed issue-body helper finds the secret guard',
1168
+ require(path.join(claudeDir, 'trailhead', 'templates', 'trailhead-issue-body.js')).loadGuard() !== null);
884
1169
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
885
1170
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
886
1171
  // payload and assert it does not crash with a missing-module error.
@@ -1018,7 +1303,15 @@ for (const nm of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-v
1018
1303
  const settingsPath = path.join(claudeDir, 'settings.json');
1019
1304
  ok('claude: settings.json exists', fs.existsSync(settingsPath));
1020
1305
  const settingsContent = fs.readFileSync(settingsPath, 'utf8');
1021
- ok('claude: settings.json references commit-guard', settingsContent.includes('trailhead-commit-guard.js'));
1306
+ ok('claude: settings.json registers the merged Bash guards', settingsContent.includes('trailhead-bash-guards.js'));
1307
+ ok('claude: settings.json registers none of the per-guard scripts on their own',
1308
+ !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(settingsContent));
1309
+ ok('claude: exactly one PreToolUse/Bash trailhead-bash-guards registration', (() => {
1310
+ const s = JSON.parse(settingsContent);
1311
+ return (s.hooks.PreToolUse || []).flatMap((g) => g.hooks || []).filter((h) => h.command.includes('trailhead-bash-guards.js')).length === 1;
1312
+ })());
1313
+ ok('claude: hooks/trailhead-bash-guards.js and its five modules exist', ['bash-guards', 'commit-guard', 'secret-guard', 'install-guard', 'search-guard', 'body-guard']
1314
+ .every((n) => fs.existsSync(path.join(claudeDir, 'hooks', `trailhead-${n}.js`))));
1022
1315
  // mockup-link-guard: Claude-only PreToolUse(AskUserQuestion) backstop for the
1023
1316
  // Prototype "link in the same message as the approval ask" rule.
1024
1317
  ok('claude: hooks/trailhead-mockup-link-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-mockup-link-guard.js')));
@@ -1180,6 +1473,9 @@ ok('source: teamwork.md fallback resolves the hooks dir via rev-parse --git-path
1180
1473
  teamworkSource.includes('git -C <working-root> rev-parse --git-path hooks'));
1181
1474
  ok('source: teamwork.md fallback classifies by the trailhead header line',
1182
1475
  teamworkSource.includes('// trailhead commit-msg hook (git).'));
1476
+ ok('source: teamwork.md fallback knows the sh wrapper header and the .cjs logic file (#257)',
1477
+ teamworkSource.includes('# trailhead commit-msg hook (git).') && teamworkSource.includes('trailhead-commit-msg.cjs'));
1478
+ ok('source: substrate-commands.md names the .cjs logic file (#257)', substrateCommandsSource.includes('trailhead-commit-msg.cjs'));
1183
1479
  ok('source: teamwork.md fallback checks the executable bit (test -x)',
1184
1480
  teamworkSource.includes('test -x'));
1185
1481
  ok('source: teamwork.md fallback reads only the first 5 lines',
@@ -1342,6 +1638,78 @@ const missingBodyGuardPkg = brokenPkg((p) => fs.rmSync(path.join(p, 'plugins', '
1342
1638
  ok('(h) pre-#169 search-guard migration: the stale entry is gone', !afterHooksJson.includes('trailhead-search-guard.js'));
1343
1639
  }
1344
1640
 
1641
+ // (#207) Migration to the merged Bash guard hook. An install that registered
1642
+ // each guard on its own ends with only the merged entry; install and uninstall
1643
+ // are idempotent; uninstall removes the merged entry and every legacy one.
1644
+ const LEGACY_GUARD_RE = /trailhead-(commit|secret|install|search|body)-guard\.js/;
1645
+ const bashGuardCommands = (registry) => (registry.hooks.PreToolUse || [])
1646
+ .flatMap((g) => (g.matcher === 'Bash' ? g.hooks || [] : []))
1647
+ .map((h) => h.command)
1648
+ .filter((c) => c.includes('trailhead-bash-guards.js') || LEGACY_GUARD_RE.test(c));
1649
+ {
1650
+ const mDir = mktmp();
1651
+ runInstaller([`--claude`, `--dir=${mDir}`]);
1652
+ const settingsFile = path.join(mDir, 'settings.json');
1653
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
1654
+ const hooksDirM = path.join(mDir, 'hooks');
1655
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.map((g) => ({
1656
+ ...g,
1657
+ hooks: g.hooks.filter((h) => !h.command.includes('trailhead-bash-guards.js')),
1658
+ })).filter((g) => g.hooks.length);
1659
+ const legacyGroup = { matcher: 'Bash', hooks: ['commit', 'secret', 'install', 'search', 'body']
1660
+ .map((n) => ({ type: 'command', command: `node "${path.join(hooksDirM, `trailhead-${n}-guard.js`)}"` })) };
1661
+ settings.hooks.PreToolUse.push(legacyGroup);
1662
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
1663
+ const out = runPkg(repoRoot, [`--claude`, `--dir=${mDir}`]);
1664
+ ok('(#207) claude migration: exit 0', out.status === 0);
1665
+ const after = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
1666
+ const cmds = bashGuardCommands(after);
1667
+ ok('(#207) claude migration: only the merged entry remains', cmds.length === 1 && cmds[0].includes('trailhead-bash-guards.js'));
1668
+ const once = fs.readFileSync(settingsFile, 'utf8');
1669
+ runInstaller([`--claude`, `--dir=${mDir}`]);
1670
+ ok('(#207) claude install twice leaves the same settings.json', fs.readFileSync(settingsFile, 'utf8') === once);
1671
+ runInstaller([`--claude`, `--dir=${mDir}`, '--uninstall']);
1672
+ const gone = fs.readFileSync(settingsFile, 'utf8');
1673
+ ok('(#207) claude uninstall strips the merged entry', !gone.includes('trailhead-bash-guards.js'));
1674
+ runInstaller([`--claude`, `--dir=${mDir}`, '--uninstall']);
1675
+ ok('(#207) claude uninstall twice leaves the same settings.json', fs.readFileSync(settingsFile, 'utf8') === gone);
1676
+ }
1677
+ {
1678
+ const mDir = mktmp();
1679
+ const hooksJsonFile = path.join(mDir, 'hooks.json');
1680
+ const scriptsDir = path.join(mDir, 'skills', 'trailhead', 'hooks');
1681
+ runInstaller([`--codex`, `--dir=${mDir}`]);
1682
+ const reg = JSON.parse(fs.readFileSync(hooksJsonFile, 'utf8'));
1683
+ reg.hooks.PreToolUse = reg.hooks.PreToolUse.map((g) => ({
1684
+ ...g,
1685
+ hooks: g.hooks.filter((h) => !h.command.includes('trailhead-bash-guards.js')),
1686
+ })).filter((g) => g.hooks.length);
1687
+ reg.hooks.PreToolUse.push({ matcher: 'Bash', hooks: ['commit', 'secret', 'install', 'search', 'body']
1688
+ .map((n) => ({ type: 'command', command: `node "${path.join(scriptsDir, `trailhead-${n}-guard.js`)}"` })) });
1689
+ fs.writeFileSync(hooksJsonFile, JSON.stringify(reg, null, 2));
1690
+ const out = runPkg(repoRoot, [`--codex`, `--dir=${mDir}`]);
1691
+ ok('(#207) codex migration: exit 0', out.status === 0);
1692
+ const cmds = bashGuardCommands(JSON.parse(fs.readFileSync(hooksJsonFile, 'utf8')));
1693
+ ok('(#207) codex migration: only the merged entry remains', cmds.length === 1 &&
1694
+ cmds[0].endsWith(' --guards=commit,secret,install,body'));
1695
+ const once = fs.readFileSync(hooksJsonFile, 'utf8');
1696
+ runInstaller([`--codex`, `--dir=${mDir}`]);
1697
+ ok('(#207) codex install twice leaves the same hooks.json', fs.readFileSync(hooksJsonFile, 'utf8') === once);
1698
+ // A legacy registration reappearing next to the merged one is removed on uninstall too.
1699
+ const withLegacy = JSON.parse(once);
1700
+ withLegacy.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [
1701
+ { type: 'command', command: `node "${path.join(scriptsDir, 'trailhead-search-guard.js')}"` },
1702
+ { type: 'command', command: `node "${path.join(scriptsDir, 'trailhead-commit-guard.js')}"` },
1703
+ ] });
1704
+ fs.writeFileSync(hooksJsonFile, JSON.stringify(withLegacy, null, 2));
1705
+ runInstaller([`--codex`, `--dir=${mDir}`, '--uninstall']);
1706
+ const gone = fs.readFileSync(hooksJsonFile, 'utf8');
1707
+ ok('(#207) codex uninstall strips the merged entry and every legacy one',
1708
+ !gone.includes('trailhead-bash-guards.js') && !LEGACY_GUARD_RE.test(gone));
1709
+ runInstaller([`--codex`, `--dir=${mDir}`, '--uninstall']);
1710
+ ok('(#207) codex uninstall twice leaves the same hooks.json', fs.readFileSync(hooksJsonFile, 'utf8') === gone);
1711
+ }
1712
+
1345
1713
  // (i) an unresolved env var on a registered target warns instead of failing.
1346
1714
  {
1347
1715
  const vDir = mktmp();
@@ -1481,6 +1849,12 @@ ok('source: manage SKILL.md routes audit to references/auditing.md', /\*\*`audit
1481
1849
  const dispatcherSrc190 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead', 'SKILL.md'), 'utf8');
1482
1850
  ok('source: dispatcher argument-hint lists audit', /argument-hint: "[^"]*\|audit\|[^"]*"/.test(dispatcherSrc190));
1483
1851
  ok('source: dispatcher manage-cluster routing list includes audit', /manage cluster is built[^\n]*`prune`, and `audit`/.test(dispatcherSrc190));
1852
+ // #208: panel is a verb the Claude Code mod answers; the dispatcher lists it but routes it to no skill.
1853
+ ok('source: dispatcher argument-hint lists panel', /argument-hint: "[^"]*\|panel\|[^"]*"/.test(dispatcherSrc190));
1854
+ ok('source: dispatcher says panel is mod-only and routes it to no skill', /`panel`\*\* verb routes to no skill[^\n]*Claude Code[^\n]*mod/.test(dispatcherSrc190));
1855
+ const panelCmdSrc208 = fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'commands', 'panel.md'), 'utf8');
1856
+ ok('source: commands/panel.md invokes no skill', !/Skill tool|skill name|\$trailhead|trailhead-(view|work|chart|capture|manage)/.test(panelCmdSrc208));
1857
+ ok('source: commands/panel.md tells the model the panel is unavailable in one line', /2\.1\.287\+[^\n]*not available/.test(panelCmdSrc208));
1484
1858
 
1485
1859
  const auditingPath190 = path.join(sourceSkillsDir, 'trailhead-manage', 'references', 'auditing.md');
1486
1860
  const auditingSrc = fs.existsSync(auditingPath190) ? fs.readFileSync(auditingPath190, 'utf8') : '';
@@ -1684,6 +2058,129 @@ for (const doc of ['README.md', 'README.it.md', siteManageDoc]) {
1684
2058
  }
1685
2059
  }
1686
2060
 
2061
+ // #202: the plugin's own hooks.json declares exactly one mod module, and it exists.
2062
+ const pluginHooksJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'hooks', 'hooks.json'), 'utf8'));
2063
+ ok('source: hooks.json declares exactly one module (#202)', Array.isArray(pluginHooksJson.modules) && pluginHooksJson.modules.length === 1);
2064
+ ok('source: the declared module file exists, relative to hooks.json (#202)',
2065
+ fs.existsSync(path.resolve(repoRoot, 'plugins', 'trailhead', 'hooks', pluginHooksJson.modules[0])));
2066
+ ok('source: hooks.json keeps its command hooks next to the module (#202)',
2067
+ ['PreToolUse', 'PostToolUse', 'SessionStart'].every((ev) => (pluginHooksJson.hooks?.[ev] || []).length > 0));
2068
+
2069
+ // --- #259: `trailhead:paused` label marks a paused ticket (pause adds, resume/claim drop) ---
2070
+ const firstUseLine = (substrateCommandsSource.match(/^1\. Create any missing labels.*$/m) || [''])[0];
2071
+ ok('source: first-use setup lists twenty labels including trailhead:paused and trailhead:uat',
2072
+ /all twenty:/.test(firstUseLine) && firstUseLine.includes('`trailhead:paused`') && firstUseLine.includes('`trailhead:uat`'));
2073
+ ok('source: no stale "eighteen" or "nineteen" label count remains in the shared skills',
2074
+ !/eighteen|nineteen/.test(substrateCommandsSource) && !/eighteen|nineteen/.test(substrateSource));
2075
+ ok('source: substrate-commands.md ensures trailhead:paused idempotently',
2076
+ /gh label create trailhead:paused --color FBCA04 --description "Paused: a PAUSED checkpoint holds its next step" 2>\/dev\/null \|\| true/.test(substrateCommandsSource));
2077
+ ok('source: substrate.md documents trailhead:paused as set by pause, cleared by resume or claim, off the frontier (#263)',
2078
+ /\*\*`trailhead:paused`\*\*[\s\S]{0,500}pause[\s\S]{0,200}resume[\s\S]{0,200}claim[\s\S]{0,400}excluded from the frontier/.test(substrateSource));
2079
+ const pauseBullet = (teamworkSource.match(/^- \*\*Pause\*\*.*$/m) || [''])[0];
2080
+ const resumeBullet = (teamworkSource.match(/^- \*\*Resume\*\*.*$/m) || [''])[0];
2081
+ ok('source: teamwork.md pause adds trailhead:paused (created idempotently) after the PAUSED comment',
2082
+ /post a \*\*`PAUSED`\*\* checkpoint comment[\s\S]{0,1200}add the `trailhead:paused` label[\s\S]{0,120}creating it idempotently first[\s\S]{0,200}after the `PAUSED` comment/.test(pauseBullet));
2083
+ ok('source: teamwork.md resume removes trailhead:paused only after the claim is confirmed',
2084
+ /gh issue edit <n> --remove-label trailhead:paused/.test(resumeBullet) &&
2085
+ /only after the claim is confirmed[\s\S]{0,300}a refused resume leaves the ticket visibly paused/.test(resumeBullet));
2086
+ ok('source: work SKILL.md claim step drops trailhead:paused from a claimed paused ticket',
2087
+ /\*\*Claim\*\* it:[\s\S]{0,1200}carries `trailhead:paused`[\s\S]{0,120}drops it at the claim/.test(workSkillSource));
2088
+ ok('source: work SKILL.md quick isolation bullet drops trailhead:paused at the claim',
2089
+ /^- \*\*Same isolation setup as `work`\.\*\*.*claimed ticket that carries `trailhead:paused` drops it at the claim/m.test(workSkillSource));
2090
+
2091
+ // --- #261: `trailhead:uat` label marks a pending UAT follow-up (marker, not a state) ---
2092
+ {
2093
+ ok('source: substrate-commands.md ensures trailhead:uat idempotently',
2094
+ /gh label create trailhead:uat --color [0-9A-Fa-f]{6} --description "Pending UAT: an acceptance test still to run" 2>\/dev\/null \|\| true/.test(substrateCommandsSource));
2095
+ {
2096
+ const helperSource = fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-uat-backfill.js'), 'utf8');
2097
+ const docColor = (substrateCommandsSource.match(/gh label create trailhead:uat --color ([0-9A-Fa-f]{6})/) || [])[1];
2098
+ const helperColor = (helperSource.match(/const COLOR = '([0-9A-Fa-f]{6})'/) || [])[1];
2099
+ ok('source: the backfill helper creates trailhead:uat with the same colour as substrate-commands.md',
2100
+ docColor !== undefined && docColor === helperColor);
2101
+ }
2102
+ ok('source: substrate-commands.md has no create-then-backfill chain and invokes the backfill helper',
2103
+ /trailhead-uat-backfill\.js/.test(substrateCommandsSource) && !/trailhead:uat[^\n]*&&[^\n]*backfill/.test(substrateCommandsSource));
2104
+ ok('source: first-use setup runs the backfill helper after the labels',
2105
+ /^1\. Create any missing labels[\s\S]{0,1500}?trailhead-uat-backfill\.js/m.test(substrateCommandsSource));
2106
+ ok('source: substrate.md documents trailhead:uat as a marker label right after the state labels, not a state, not a frontier filter',
2107
+ /\*\*`trailhead:uat`\*\*[\s\S]{0,700}marker[\s\S]{0,400}not a state[\s\S]{0,400}never a frontier filter/.test(substrateSource)
2108
+ && substrateSource.indexOf('**`trailhead:uat`**') > substrateSource.indexOf('**`trailhead:paused`**')
2109
+ && substrateSource.indexOf('**`trailhead:uat`**') < substrateSource.indexOf('**Frontier**'));
2110
+ ok('source: acceptance-testing.md option (b) opens the follow-up with trailhead:uat in the gh issue create --label list, label ensured first',
2111
+ /open a follow-up ticket before that close[\s\S]{0,900}--label[^\n]{0,200}`?trailhead:uat/.test(acceptanceTestingSource)
2112
+ && /ensur\w* (the )?`?trailhead:uat`? (label )?idempotently/.test(acceptanceTestingSource));
2113
+ ok('source: ticket-engines.md UAT follow-ups section names the label',
2114
+ /UAT follow-up[\s\S]{0,1500}`trailhead:uat`/.test(ticketEnginesSource.split('\n').filter((l) => /^A ticket whose body carries/.test(l)).join('\n')));
2115
+ ok('source: work SKILL.md work-start runs the backfill helper once per clone, marker gated',
2116
+ /trailhead-uat-backfill\.js[^\n]*--marker[^\n]*\.trailhead\/uat-backfill/.test(workSkillSource));
2117
+ ok('source: work SKILL.md passes the backfill marker as an absolute path under the working root',
2118
+ /trailhead-uat-backfill\.js[^\n]*--marker <working-root>\/\.trailhead\/uat-backfill/.test(workSkillSource));
2119
+ ok('source: .gitignore ignores .trailhead/uat-backfill',
2120
+ /^\.trailhead\/uat-backfill$/m.test(fs.readFileSync(path.join(repoRoot, '.gitignore'), 'utf8')));
2121
+ ok('source: work SKILL.md mentions the uat-backfill marker as gitignored per-clone state',
2122
+ /`\.trailhead\/uat-backfill`[^\n]*(gitignored|never committed)/.test(workSkillSource));
2123
+ const producerSources = { 'acceptance-testing.md': acceptanceTestingSource, 'site acceptance-testing.md': siteAcceptanceSource };
2124
+ for (const [name, src] of Object.entries(producerSources)) {
2125
+ ok(`source: ${name} writes the pointer as UAT of: [<parent name>](<parent url>)`,
2126
+ src.includes('UAT of: [<parent name>](<parent url>)') && !/UAT of: <parent name>\(link\)|UAT of: #<n>/.test(src));
2127
+ }
2128
+ }
2129
+
2130
+ // --- #263: a paused ticket is off the frontier on every surface ---
2131
+ {
2132
+ const viewSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-view', 'SKILL.md'), 'utf8');
2133
+ const siteDocs = path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs');
2134
+ const siteViewSource = fs.readFileSync(path.join(siteDocs, 'commands', 'view.md'), 'utf8');
2135
+ const siteWorkSource = fs.readFileSync(path.join(siteDocs, 'commands', 'work.md'), 'utf8');
2136
+ const changelogSource = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
2137
+ const searchExprs = (src) => [...src.matchAll(/--search\s+"([^"]*no:assignee[^"]*)"/g)].map((m) => m[1]);
2138
+ const base = 'no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused';
2139
+ const wbExcl = `${base} -label:trailhead:whiteboard`;
2140
+ const tally = (exprs) => exprs.reduce((acc, e) => ({ ...acc, [e]: (acc[e] || 0) + 1 }), {});
2141
+ const sameTally = (got, want) => JSON.stringify(Object.entries(got).sort()) === JSON.stringify(Object.entries(want).sort());
2142
+
2143
+ ok('source: substrate-commands.md frontier queries each exclude paused (two repo-wide, four label-scoped or whiteboard or count)',
2144
+ sameTally(tally(searchExprs(substrateCommandsSource)), { [wbExcl]: 2, [base]: 4 }));
2145
+ ok('source: multi-map.md scoped frontier query excludes paused', sameTally(tally(searchExprs(multiMapSource)), { [base]: 1 }));
2146
+ ok('source: trailhead-view SKILL.md whiteboard frontier query excludes paused', sameTally(tally(searchExprs(viewSkillSource)), { [base]: 1 }));
2147
+ ok('source: the cookbook frontier comment says not paused',
2148
+ /# the frontier: open, unassigned, not blocked, not unverified, not paused, one query/.test(substrateCommandsSource));
2149
+
2150
+ ok('source: substrate.md Frontier definition excludes trailhead:paused',
2151
+ /\*\*Frontier\*\* = open, unassigned tickets that are \*\*not\*\* `trailhead:blocked`, \*\*not\*\* `trailhead:unverified` and \*\*not\*\* `trailhead:paused`/.test(substrateSource));
2152
+ const pausedBullet = (substrateSource.match(/^- \*\*`trailhead:paused`\*\*.*$/m) || [''])[0];
2153
+ ok('source: substrate.md paused bullet: excluded from the frontier, taken back with /trailhead:resume',
2154
+ /excluded from the frontier/.test(pausedBullet) && /\/trailhead:resume/.test(pausedBullet));
2155
+ ok('source: the old "does not change the frontier query" wording is gone',
2156
+ !/does not change the frontier query/.test(substrateSource));
2157
+
2158
+ const paused = /^- \*\*In progress\*\*[^\n]*\n- \*\*Paused\*\*[^\n]*\/trailhead:resume <n>`[^\n]*\n- \*\*Blocked\*\*/gm;
2159
+ ok('source: the map and whiteboard renders both list a Paused group between In progress and Blocked',
2160
+ (viewSkillSource.match(paused) || []).length === 2);
2161
+ ok('source: both render Frontier bullets say not paused',
2162
+ (viewSkillSource.match(/^- \*\*Frontier\*\*:[^\n]*not[^\n]*`trailhead:paused`/gm) || []).length === 2);
2163
+
2164
+ ok('source: multi-map.md park & switch resumes with /trailhead:resume only',
2165
+ /Resume later with `\/trailhead:resume`/.test(multiMapSource) && !/just `\/trailhead:work` once that map is active/.test(multiMapSource));
2166
+
2167
+ ok('source: work SKILL.md: bare work takes the paused-excluding frontier, a named work or quick still works a paused ticket',
2168
+ /Otherwise take a frontier ticket[\s\S]{0,600}never a `trailhead:paused` ticket/.test(workSkillSource) &&
2169
+ /\*\*named\*\* ticket may also be a `trailhead:paused` one, which `work <n>` \/ `quick <n>` takes back/.test(workSkillSource) &&
2170
+ /carries `trailhead:paused` drops it at the claim/.test(workSkillSource));
2171
+
2172
+ ok('source: auto.md resumes owned paused tickets in step 2 and no longer says they come back on the frontier',
2173
+ /\*\*each\*\* ticket you own[\s\S]{0,200}`trailhead:paused`/.test(autoSource) &&
2174
+ !/paused\/stuck tickets are back on the frontier/.test(autoSource) && !/paused[^.\n]{0,40}back on the frontier/.test(autoSource));
2175
+ ok('source: site work.md says a paused ticket is off the frontier until resumed',
2176
+ /off the frontier until (it is )?resumed/.test(siteWorkSource));
2177
+ ok('source: site view.md describes the Paused group off the frontier', /Paused/.test(siteViewSource) && /not paused|off the frontier/.test(siteViewSource));
2178
+ const conceptsSource = fs.readFileSync(path.join(siteDocs, 'concepts.md'), 'utf8');
2179
+ ok('source: concepts.md, README.md and README.it.md frontier definitions mention paused',
2180
+ /not paused/.test(conceptsSource) && /not paused/.test(readmeSource) && /in pausa/.test(readmeItSource));
2181
+ ok('source: CHANGELOG Unreleased > Changed has a #263 entry', /### Changed[\s\S]*?\(#263\)/.test(changelogSource.split('## 0.14.0')[0]));
2182
+ }
2183
+
1687
2184
  for (const d of tmpDirs) {
1688
2185
  fs.rmSync(d, { recursive: true, force: true });
1689
2186
  }