@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
package/bin/trailhead.js CHANGED
@@ -243,19 +243,19 @@ function claudePaths(configDir) {
243
243
  settings: path.join(configDir, 'settings.json'),
244
244
  };
245
245
  }
246
- // The five Bash guard modules. They are shipped, but only require()d by the
246
+ // The six Bash guard modules. They are shipped, but only require()d by the
247
247
  // merged trailhead-bash-guards.js (one process per Bash call), never registered
248
248
  // on their own. Legacy installs registered each one; the installers strip those.
249
- const BASH_GUARD_MODULES = ['trailhead-commit-guard.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-body-guard.js'];
249
+ const BASH_GUARD_MODULES = ['trailhead-commit-guard.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-body-guard.js', 'trailhead-labels-guard.js'];
250
250
  // Every hook script copied into the hooks dir (shipped runtime files).
251
251
  const HOOK_FILES = [...BASH_GUARD_MODULES, 'trailhead-bash-guards.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-read-guard.js', 'trailhead-mockup-link-guard.js', 'trailhead-mockup-link-stop.js', 'trailhead-check-update.js'];
252
252
  // The registered entrypoints: the shipped files that must appear in
253
253
  // settings.json / hooks.json (everything but the require()d guard modules).
254
254
  const ENTRYPOINT_FILES = HOOK_FILES.filter((f) => !BASH_GUARD_MODULES.includes(f));
255
255
  // Runtime libs the hook scripts require (trailhead-commit-guard.js does
256
- // require('./lib/commit-message-check.js'); trailhead-search-guard.js and
257
- // trailhead-secret-read-guard.js both do require('./lib/shell-scan.js'), #135
258
- // follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
256
+ // require('./lib/commit-message-check.js'); several guards (grep `lib/shell-scan`:
257
+ // search, secret-read, secret, install, lib/unmodelled.js) do
258
+ // require('./lib/shell-scan.js'), #135 follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
259
259
  // require('./lib/gh-subcommand.js'), #153 follow-up; trailhead-secret-guard.js
260
260
  // also does require('./lib/body-sources.js'), #210; trailhead-check-update.js
261
261
  // does require('./lib/model-defaults-review.js'), which in turn reads the
@@ -436,7 +436,7 @@ function installClaude(configDir, { useSymlink }) {
436
436
 
437
437
  const s = readJSON(P.settings);
438
438
  // Migration: an earlier install registered each Bash guard on its own; the
439
- // merged entrypoint below replaces all five.
439
+ // merged entrypoint below replaces all six.
440
440
  stripLegacyBashGuards(s);
441
441
  const added = [
442
442
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-bash-guards.js')),
@@ -118,6 +118,12 @@ 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));
@@ -129,6 +135,24 @@ ok('codex: work-state helper projected', fs.existsSync(codexWorkState));
129
135
  ok('codex: work-state helper is byte-identical to source',
130
136
  fs.readFileSync(codexWorkState, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-work-state.js'), 'utf8'));
131
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);
132
156
  // Il sync del label guard (migrazione all'if a livello di job) viaggia con templates/.
133
157
  const codexLabelGuardSync = path.join(codexDir, 'skills', 'trailhead', 'templates', 'trailhead-label-guard-sync.js');
134
158
  ok('codex: label-guard-sync script is byte-identical to source', fs.existsSync(codexLabelGuardSync) &&
@@ -140,6 +164,18 @@ ok('codex: audit-walk script is byte-identical to source',
140
164
  fs.readFileSync(codexAuditWalk, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-audit-walk.js'), 'utf8'));
141
165
  ok('codex: audit-walk script keeps its executable bit', (fs.statSync(codexAuditWalk).mode & 0o111) !== 0);
142
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
+
143
179
  ok('codex: no commands dir', !fs.existsSync(path.join(codexDir, 'commands')));
144
180
  ok('codex: no settings.json file', !fs.existsSync(path.join(codexDir, 'settings.json')));
145
181
  // #46: per-verb discoverability now projects one Codex SKILL per verb
@@ -210,6 +246,28 @@ for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-v
210
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));
211
247
  }
212
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
+
213
271
  // --- #146: single-sourced ticket-language directive (anti-drift) -------------
214
272
  // The standing ticket-language rule (write Issue prose + commit bodies in
215
273
  // config.ticket.language, decoupled from the chat language) lives in ONE place,
@@ -287,6 +345,27 @@ ok('source: capture.md offers /trailhead:quick as the lighter alternative, after
287
345
  ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
288
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));
289
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
+
290
369
  // --- #179: `quick <n>` on a map ticket is never "off the map" ----------------
291
370
  // Source-level invariant: `quick <n>` on an EXISTING ticket does not leave the
292
371
  // map (it keeps its trailhead:map-<n> label and sub-issue edge, and dependent-
@@ -543,6 +622,30 @@ ok('source: trailhead-work SKILL.md work step 1 points to the named-ticket activ
543
622
  ok('source: trailhead-work SKILL.md quick isolation bullet points to the named-ticket active-map rule',
544
623
  namedTicketMapPointer.test(quickIsolationBullet));
545
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
+
546
649
  const inboxSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'inbox.md'), 'utf8');
547
650
  ok('source: inbox.md out-of-scope close drops the native sub-issue edge',
548
651
  /`trailhead:out-of-scope`, \*\*drop its native sub-issue edge\*\*/.test(inboxSource));
@@ -580,7 +683,7 @@ for (const phrase of [
580
683
  '**wait for an explicit answer**',
581
684
  '**silence or an ambiguous reply re-asks**',
582
685
  '**One-path skip**',
583
- '**state the reason in one line**',
686
+ '**states the reason in one line**',
584
687
  'VERIFY',
585
688
  'only for the steps it cannot perform',
586
689
  'runs unchanged',
@@ -654,12 +757,12 @@ ok('source: both Pending-UAT guard lines reference the drive-mode ask', pendingU
654
757
  // auto.md mentions the ask, AND (plan-review addition) a single localized span
655
758
  // states its outcomes TOGETHER: takes it without the confirm gate, picks
656
759
  // AI-driven when any step is agent-performable, never fakes a human-only step,
657
- // and sets the ticket aside listing them in the run-end summary.
760
+ // and routes the human-only steps to a follow-up opened before the parent closes (#281).
658
761
  const autoSourcePath = path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md');
659
762
  const autoSource = fs.readFileSync(autoSourcePath, 'utf8');
660
763
  ok('source: auto.md mentions the drive-mode ask', autoSource.includes('drive-mode ask'));
661
764
  ok('source: auto.md states the drive-mode-ask outcomes together (grouped, not scattered)',
662
- /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));
765
+ /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 fakes[\s\S]{0,1500}follow-up/.test(autoSource));
663
766
 
664
767
  // choices.md carries the call-site row for the drive-mode ask.
665
768
  const choicesSourcePath = path.join(sourceSkillsDir, '_shared', 'choices.md');
@@ -713,13 +816,95 @@ ok('source: configuration-reference.md acceptance.browser clause does NOT make t
713
816
  ok('source: configuration-reference.md acceptance.browser clause states the drive-mode ask always comes first',
714
817
  /\*\*acceptance\.browser\.\*\*[\s\S]{0,200}drive-mode ask[\s\S]{0,120}always comes first/.test(configReferenceSource));
715
818
 
716
- // The run-end summary's enumerated set-aside category list is missing the new
717
- // human-only-UAT-steps class (added by #178); it must be listed there too,
718
- // matching the wording already used for it at line 29 ("human-only steps").
819
+ // Human-only UAT steps are no longer a set-aside reason (#281): the run-end summary
820
+ // lists the UAT follow-ups opened this run instead.
719
821
  const runEndSummarySection = extractSection(autoSource, '## Run-end summary');
720
822
  ok('source: auto.md Run-end summary section exists', runEndSummarySection.length > 0);
721
- ok('source: auto.md Run-end summary "what remains" list includes human-only UAT steps',
722
- /what remains[\s\S]{0,400}human-only UAT steps/.test(runEndSummarySection));
823
+ ok('source: auto.md Run-end summary "what remains" lists the UAT follow-ups opened this run',
824
+ /what remains[\s\S]{0,900}UAT follow-ups opened this run/.test(runEndSummarySection));
825
+
826
+ // --- #254: skip option at the drive-mode ask (a waiver, not a deferral) -----
827
+ // The waiver's operational contract is single-sourced in the Drive-mode bullet
828
+ // (acceptance-testing.md); the other call-sites carry only the routing /
829
+ // closure consequence and name the bullet.
830
+ const driveModeMarkerLine = driveModeSpan.split('\n')[0] || '';
831
+ ok('source: Drive-mode marker line names the skip option alongside AI-driven / human-driven',
832
+ driveModeMarkerLine.includes('AI-driven') && driveModeMarkerLine.includes('human-driven') && driveModeMarkerLine.includes('skip (waive acceptance)'));
833
+ const skipSubBullet = driveModeSpan.split('\n').find((l) => l.startsWith(' - **Skip (waive acceptance).**')) || '';
834
+ ok('source: Drive-mode span carries the "Skip (waive acceptance)" sub-bullet', skipSubBullet.length > 0);
835
+ for (const phrase of [
836
+ 'waived by the user (unconfirmed)',
837
+ 'never a pass',
838
+ 'the Pending-UAT guard does not fire',
839
+ 'no follow-up',
840
+ 'The delegate option never picks skip',
841
+ '`auto` never picks skip',
842
+ 'UAT of:',
843
+ 'closes as waived',
844
+ 'resolution comment',
845
+ 'all three start paths',
846
+ ]) {
847
+ ok(`source: Skip sub-bullet pins "${phrase}"`, skipSubBullet.includes(phrase));
848
+ }
849
+ const oneAskLine = driveModeSpan.split('\n').find((l) => l.startsWith(' - **One-path skip**')) || '';
850
+ ok('source: one-path skip no longer suppresses the ask outright (drops only the AI-driven option)',
851
+ oneAskLine.includes('drops only the AI-driven option') && oneAskLine.includes('human-driven or skip') && !oneAskLine.includes('skip the ask'));
852
+ ok('source: one-path skip still states the reason in one line, naming the blocking class',
853
+ oneAskLine.includes('**states the reason in one line**') && oneAskLine.includes('blocking class') && !oneAskLine.includes('prefaced by'));
854
+ ok('source: on the reduced ask (no performable step) the delegate option resolves to human-driven, never skip',
855
+ /delegate option resolves to human-driven \(never skip\)/.test(oneAskLine));
856
+ ok('source: Pending-UAT bullet distinguishes a waiver from a deferral', pendingUatLine.includes('a waiver is not a deferral'));
857
+ ok('source: Pending-UAT bullet pins the waiver exception (picked skip, recorded as unconfirmed, nothing opened)',
858
+ /The one exception is an explicit waiver[^.]*\. The human picked skip at the drive-mode ask[^.]*unconfirmed in `VERIFY`[^.]*nothing is opened/.test(pendingUatLine));
859
+ const waiverResolveRe = /resolv(e|ing) on a confirmed pass, or on an explicit waiver if the user skips at the ask/;
860
+ ok('source: Pending-UAT bullet option (a) resolves on a confirmed pass or an explicit waiver',
861
+ waiverResolveRe.test(pendingUatLine) && !pendingUatLine.includes('only on a confirmed pass'));
862
+ ok('source: both ticket-engines Resolve lines let option (a) resolve on a confirmed pass or an explicit waiver',
863
+ guardLines254Early().length === 2 && guardLines254Early().every((l) => waiverResolveRe.test(l) && !l.includes('only on a confirmed pass')));
864
+ ok('source: with-no-ticket preflight-then-ask phrase still occurs exactly twice in ticket-engines.md',
865
+ ticketEnginesSource.split('with no ticket (the stale-base preflight first, then the UAT setup, then the drive-mode ask').length - 1 === 2);
866
+ ok('source: both Verify steps let the user skip, recorded as waived',
867
+ verifyStepLines.length === 2 && verifyStepLines.every((l) => /or skip \(waive acceptance\), recorded as waived/.test(l)));
868
+ ok('source: techniques.md index row and auto.md lead-in name skip',
869
+ /ask who drives \(AI or human, or skip\)/.test(fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'techniques.md'), 'utf8')) &&
870
+ autoSource.includes('drive-mode ask (AI-driven, human-driven, or skip)'));
871
+
872
+ function guardLines254Early() { return ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)')); }
873
+ const guardLines254 = ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)'));
874
+ ok('source: both Pending-UAT guard lines say the guard does not fire when acceptance was explicitly waived',
875
+ guardLines254.length === 2 && guardLines254.every((l) => l.includes('explicitly waived')));
876
+ ok('source: both Resolve steps state the waiver in the resolution comment',
877
+ guardLines254.length === 2 && guardLines254.every((l) => /resolution comment states the waiver/.test(l)));
878
+ ok('source: the UAT follow-ups section closes on a confirmed pass or an explicit waiver',
879
+ uatFollowupSection.includes('confirmed pass or an explicit waiver') && uatFollowupSection.includes('closes as waived'));
880
+ const driveRowLine = choicesSource.split('\n').find((l) => /^\|[^\n]*[Dd]rive-mode ask/.test(l)) || '';
881
+ ok('source: choices.md drive-mode row names skip and states delegate never picks it',
882
+ driveRowLine.includes('skip') && /delegate never picks skip/.test(driveRowLine) && driveRowLine.includes('reduced ask') && driveRowLine.includes('human-driven'));
883
+ ok('source: configuration-reference.md acceptance.browser paragraph names skip alongside AI-driven / human-driven',
884
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,200}human-driven,? or skip/.test(configReferenceSource));
885
+ ok('source: auto.md states auto never picks skip', /never picks skip[^\n]*waiving acceptance/.test(autoSource));
886
+
887
+ // Docs mirror: README, README.it, site (waiver recorded as unconfirmed; no unconditional "never closes").
888
+ ok('README.md acceptance testing section names the waiver (skip) recorded as unconfirmed',
889
+ /\*\*Skip is a waiver, not a deferral\*\*[^\n]{0,80}`waived by the user \(unconfirmed\)`, never as a pass/.test(readmeSource));
890
+ ok('README.md no-performable-step case asks human-driven or skip', /human-driven\*\* or \*\*skip\*\*/.test(readmeSource));
891
+ ok('README.md point 6 allows an explicit waiver', /never closes on a UAT nobody ran unless you explicitly waive it/.test(readmeSource));
892
+ const siteAcceptanceSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'acceptance-testing.md'), 'utf8');
893
+ ok('docs: auto never-picks-skip antecedent is the auto run, in all three docs',
894
+ /the parent ticket resolves; `\/trailhead:auto` never picks skip/.test(readmeSource) && /the parent ticket resolves; `\/trailhead:auto` never picks skip/.test(siteAcceptanceSource) && /il ticket genitore si risolve; `\/trailhead:auto` non sceglie mai skip/.test(readmeItSource));
895
+ ok('README.md build row and acceptance.browser row name skip',
896
+ /🔨 `build`[\s\S]{0,500}or skip/.test(readmeSource) && /`acceptance\.browser`[\s\S]{0,300}or skip/.test(readmeSource));
897
+ ok('README.it.md acceptance testing section names the waiver (skip) recorded as unconfirmed',
898
+ /\*\*Skip è una rinuncia, non un rinvio\*\*[^\n]{0,160}`waived by the user \(unconfirmed\)`, cioè non confermata/.test(readmeItSource));
899
+ ok('README.it.md no-performable-step case asks human-driven o skip', /human-driven\*\* o \*\*skip\*\*/.test(readmeItSource));
900
+ 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));
901
+ ok('README.it.md build row and acceptance.browser row name skip',
902
+ /🔨 `build`[\s\S]{0,500}o skip/.test(readmeItSource) && /`acceptance\.browser`[\s\S]{0,300}o skip/.test(readmeItSource));
903
+ 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));
904
+ ok('site acceptance-testing.md no-performable-step case asks human-driven or skip', /human-driven\*\* or \*\*skip\*\*/.test(siteAcceptanceSource));
905
+ 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));
906
+ ok('site ticket-types.md build row and configuration.mdx acceptance.browser row name skip',
907
+ /`build`[\s\S]{0,500}or skip/.test(ticketTypesSource) && /`acceptance\.browser`[\s\S]{0,300}or skip/.test(configurationMdxSource));
723
908
 
724
909
  // --- codex hooks (#29) --------------------------------------------------------
725
910
  const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
@@ -727,10 +912,10 @@ ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
727
912
  const codexHooksJson = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
728
913
  const codexHooksJsonStr = JSON.stringify(codexHooksJson);
729
914
  ok('codex: hooks.json registers the merged Bash guards under PreToolUse', (codexHooksJson.hooks.PreToolUse || []).some((g) => g.matcher === 'Bash' &&
730
- (g.hooks || []).some((h) => h.command.includes('trailhead-bash-guards.js') && h.command.endsWith(' --guards=commit,secret,install,body'))));
731
- ok('codex: hooks.json registers none of the per-guard scripts on their own', !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(codexHooksJsonStr));
732
- ok('codex: the five guard modules the merged hook requires are still shipped (no search)',
733
- ['commit', 'secret', 'install', 'body'].every((n) => fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', `trailhead-${n}-guard.js`))) &&
915
+ (g.hooks || []).some((h) => h.command.includes('trailhead-bash-guards.js') && h.command.endsWith(' --guards=commit,secret,install,body,labels'))));
916
+ ok('codex: hooks.json registers none of the per-guard scripts on their own', !/trailhead-(commit|secret|install|search|body|labels)-guard\.js/.test(codexHooksJsonStr));
917
+ ok('codex: the six guard modules the merged hook requires are still shipped (no search)',
918
+ ['commit', 'secret', 'install', 'body', 'labels'].every((n) => fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', `trailhead-${n}-guard.js`))) &&
734
919
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-bash-guards.js')));
735
920
  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'))));
736
921
  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'))));
@@ -851,6 +1036,25 @@ ok('codex: every trailhead-*.toml is pin-less when models.codex.* is unset', (()
851
1036
  return tomls.every((f) => !/^model = /m.test(fs.readFileSync(path.join(dir, f), 'utf8')));
852
1037
  })());
853
1038
 
1039
+ // #250: the reviewing block (single source scripts/agent-blocks.md) is projected
1040
+ // verbatim into the two reviewing agents' TOMLs and into none of the other six.
1041
+ {
1042
+ const sync250 = require(path.join(repoRoot, 'scripts', 'sync-agent-blocks.js'));
1043
+ const reviewingBlock250 = sync250.parseSource(fs.readFileSync(path.join(repoRoot, 'scripts', 'agent-blocks.md'), 'utf8')).reviewing;
1044
+ ok('#250: the source defines a reviewing block', typeof reviewingBlock250 === 'string' && reviewingBlock250.length > 0);
1045
+ const agentsDir250 = path.join(unsetCodexHomeDir, 'agents');
1046
+ const reviewingNames250 = new Set(['trailhead-verify', 'trailhead-review']); // the code-review agent projects as trailhead-review.toml
1047
+ const tomls250 = fs.readdirSync(agentsDir250).filter((f) => /^trailhead-.*\.toml$/.test(f));
1048
+ ok('#250: all 8 TOMLs are checked', tomls250.length === 8);
1049
+ for (const f of tomls250) {
1050
+ const name = f.replace(/\.toml$/, '');
1051
+ const text = fs.readFileSync(path.join(agentsDir250, f), 'utf8');
1052
+ const has = typeof reviewingBlock250 === 'string' && reviewingBlock250.length > 0 && text.includes(reviewingBlock250);
1053
+ ok(`#250: ${f} ${reviewingNames250.has(name) ? 'carries' : 'does not carry'} the reviewing block verbatim`,
1054
+ has === reviewingNames250.has(name));
1055
+ }
1056
+ }
1057
+
854
1058
  // --- codex migration (#46): install over old prompt shims sweeps them --------
855
1059
  const migCodexDir = mktmp();
856
1060
  fs.mkdirSync(path.join(migCodexDir, 'prompts'), { recursive: true });
@@ -884,7 +1088,7 @@ ok('codex uninstall: cluster trailhead-view gone', !fs.existsSync(path.join(code
884
1088
  ok('codex uninstall: hooks.json no longer contains trailhead commands', (() => {
885
1089
  const h = JSON.parse(fs.readFileSync(codexHooksJsonPath, 'utf8'));
886
1090
  const str = JSON.stringify(h);
887
- return !str.includes('trailhead-bash-guards.js') && !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(str) &&
1091
+ return !str.includes('trailhead-bash-guards.js') && !/trailhead-(commit|secret|install|search|body|labels)-guard\.js/.test(str) &&
888
1092
  !str.includes('trailhead-issue-injection-scanner.js') && !str.includes('trailhead-check-update.js');
889
1093
  })());
890
1094
 
@@ -932,6 +1136,12 @@ ok('claude: hooks/lib/model-defaults.json exists (#186)',
932
1136
  // #202: the mod is not delivered by the npm installer yet.
933
1137
  ok('claude: no hooks/mod/ dir in the npm install (#202)', !fs.existsSync(path.join(claudeDir, 'hooks', 'mod')));
934
1138
 
1139
+ // #257: the hook logic ships as a .cjs beside the sh wrapper.
1140
+ const claudeCommitMsgCjs = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg.cjs');
1141
+ ok('claude: commit-msg .cjs logic projected', fs.existsSync(claudeCommitMsgCjs));
1142
+ ok('claude: commit-msg .cjs logic is byte-identical to source',
1143
+ fs.readFileSync(claudeCommitMsgCjs, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-commit-msg.cjs'), 'utf8'));
1144
+
935
1145
  // #185: the commit-msg hook sync script ships wholesale with templates/ too.
936
1146
  const claudeCommitMsgSync = path.join(claudeDir, 'trailhead', 'templates', 'trailhead-commit-msg-sync.js');
937
1147
  ok('claude: commit-msg-sync script projected', fs.existsSync(claudeCommitMsgSync));
@@ -944,6 +1154,17 @@ const claudeAuditWalk = path.join(claudeDir, 'trailhead', 'templates', 'trailhea
944
1154
  ok('claude: audit-walk script projected', fs.existsSync(claudeAuditWalk));
945
1155
  ok('claude: audit-walk script is byte-identical to source',
946
1156
  fs.readFileSync(claudeAuditWalk, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-audit-walk.js'), 'utf8'));
1157
+ // #253: the Resolve bookkeeping helpers are installed, and (npm layout: templates at
1158
+ // <config>/trailhead/templates, hooks at <config>/hooks) find the secret guard.
1159
+ for (const helper of ['trailhead-issue-body.js', 'trailhead-dashboard.js']) {
1160
+ const installed = path.join(claudeDir, 'trailhead', 'templates', helper);
1161
+ ok(`claude: ${helper} installed`, fs.existsSync(installed));
1162
+ ok(`claude: ${helper} is byte-identical to source`,
1163
+ fs.readFileSync(installed, 'utf8') === fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', helper), 'utf8'));
1164
+ ok(`claude: ${helper} keeps its executable bit`, (fs.statSync(installed).mode & 0o111) !== 0);
1165
+ }
1166
+ ok('claude: the installed issue-body helper finds the secret guard',
1167
+ require(path.join(claudeDir, 'trailhead', 'templates', 'trailhead-issue-body.js')).loadGuard() !== null);
947
1168
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
948
1169
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
949
1170
  // payload and assert it does not crash with a missing-module error.
@@ -1083,12 +1304,12 @@ ok('claude: settings.json exists', fs.existsSync(settingsPath));
1083
1304
  const settingsContent = fs.readFileSync(settingsPath, 'utf8');
1084
1305
  ok('claude: settings.json registers the merged Bash guards', settingsContent.includes('trailhead-bash-guards.js'));
1085
1306
  ok('claude: settings.json registers none of the per-guard scripts on their own',
1086
- !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(settingsContent));
1307
+ !/trailhead-(commit|secret|install|search|body|labels)-guard\.js/.test(settingsContent));
1087
1308
  ok('claude: exactly one PreToolUse/Bash trailhead-bash-guards registration', (() => {
1088
1309
  const s = JSON.parse(settingsContent);
1089
1310
  return (s.hooks.PreToolUse || []).flatMap((g) => g.hooks || []).filter((h) => h.command.includes('trailhead-bash-guards.js')).length === 1;
1090
1311
  })());
1091
- ok('claude: hooks/trailhead-bash-guards.js and its five modules exist', ['bash-guards', 'commit-guard', 'secret-guard', 'install-guard', 'search-guard', 'body-guard']
1312
+ ok('claude: hooks/trailhead-bash-guards.js and its six modules exist', ['bash-guards', 'commit-guard', 'secret-guard', 'install-guard', 'search-guard', 'body-guard', 'labels-guard']
1092
1313
  .every((n) => fs.existsSync(path.join(claudeDir, 'hooks', `trailhead-${n}.js`))));
1093
1314
  // mockup-link-guard: Claude-only PreToolUse(AskUserQuestion) backstop for the
1094
1315
  // Prototype "link in the same message as the approval ask" rule.
@@ -1251,6 +1472,9 @@ ok('source: teamwork.md fallback resolves the hooks dir via rev-parse --git-path
1251
1472
  teamworkSource.includes('git -C <working-root> rev-parse --git-path hooks'));
1252
1473
  ok('source: teamwork.md fallback classifies by the trailhead header line',
1253
1474
  teamworkSource.includes('// trailhead commit-msg hook (git).'));
1475
+ ok('source: teamwork.md fallback knows the sh wrapper header and the .cjs logic file (#257)',
1476
+ teamworkSource.includes('# trailhead commit-msg hook (git).') && teamworkSource.includes('trailhead-commit-msg.cjs'));
1477
+ ok('source: substrate-commands.md names the .cjs logic file (#257)', substrateCommandsSource.includes('trailhead-commit-msg.cjs'));
1254
1478
  ok('source: teamwork.md fallback checks the executable bit (test -x)',
1255
1479
  teamworkSource.includes('test -x'));
1256
1480
  ok('source: teamwork.md fallback reads only the first 5 lines',
@@ -1466,7 +1690,7 @@ const bashGuardCommands = (registry) => (registry.hooks.PreToolUse || [])
1466
1690
  ok('(#207) codex migration: exit 0', out.status === 0);
1467
1691
  const cmds = bashGuardCommands(JSON.parse(fs.readFileSync(hooksJsonFile, 'utf8')));
1468
1692
  ok('(#207) codex migration: only the merged entry remains', cmds.length === 1 &&
1469
- cmds[0].endsWith(' --guards=commit,secret,install,body'));
1693
+ cmds[0].endsWith(' --guards=commit,secret,install,body,labels'));
1470
1694
  const once = fs.readFileSync(hooksJsonFile, 'utf8');
1471
1695
  runInstaller([`--codex`, `--dir=${mDir}`]);
1472
1696
  ok('(#207) codex install twice leaves the same hooks.json', fs.readFileSync(hooksJsonFile, 'utf8') === once);
@@ -1841,6 +2065,121 @@ ok('source: the declared module file exists, relative to hooks.json (#202)',
1841
2065
  ok('source: hooks.json keeps its command hooks next to the module (#202)',
1842
2066
  ['PreToolUse', 'PostToolUse', 'SessionStart'].every((ev) => (pluginHooksJson.hooks?.[ev] || []).length > 0));
1843
2067
 
2068
+ // --- #259: `trailhead:paused` label marks a paused ticket (pause adds, resume/claim drop) ---
2069
+ const firstUseLine = (substrateCommandsSource.match(/^1\. Create any missing labels.*$/m) || [''])[0];
2070
+ ok('source: first-use setup lists twenty-one labels including trailhead:paused, trailhead:uat and trailhead:needs-you',
2071
+ /all twenty-one:/.test(firstUseLine) && firstUseLine.includes('`trailhead:paused`') && firstUseLine.includes('`trailhead:uat`') && firstUseLine.includes('`trailhead:needs-you`'));
2072
+ ok('source: no stale "eighteen" or "nineteen" label count remains in the shared skills',
2073
+ !/eighteen|nineteen/.test(substrateCommandsSource) && !/eighteen|nineteen/.test(substrateSource));
2074
+ ok('source: substrate-commands.md ensures trailhead:paused idempotently',
2075
+ /gh label create trailhead:paused --color FBCA04 --description "Paused: a PAUSED checkpoint holds its next step" 2>\/dev\/null \|\| true/.test(substrateCommandsSource));
2076
+ ok('source: substrate.md documents trailhead:paused as set by pause, cleared by resume or claim, off the frontier (#263)',
2077
+ /\*\*`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));
2078
+ const pauseBullet = (teamworkSource.match(/^- \*\*Pause\*\*.*$/m) || [''])[0];
2079
+ const resumeBullet = (teamworkSource.match(/^- \*\*Resume\*\*.*$/m) || [''])[0];
2080
+ ok('source: teamwork.md pause adds trailhead:paused (created idempotently) after the PAUSED comment',
2081
+ /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));
2082
+ ok('source: teamwork.md resume removes trailhead:paused only after the claim is confirmed',
2083
+ /gh issue edit <n> --remove-label trailhead:paused/.test(resumeBullet) &&
2084
+ /only after the claim is confirmed[\s\S]{0,300}a refused resume leaves the ticket visibly paused/.test(resumeBullet));
2085
+ ok('source: work SKILL.md claim step drops trailhead:paused from a claimed paused ticket',
2086
+ /\*\*Claim\*\* it:[\s\S]{0,1200}carries `trailhead:paused`[\s\S]{0,120}drops it at the claim/.test(workSkillSource));
2087
+ ok('source: work SKILL.md quick isolation bullet drops trailhead:paused at the claim',
2088
+ /^- \*\*Same isolation setup as `work`\.\*\*.*claimed ticket that carries `trailhead:paused` drops it at the claim/m.test(workSkillSource));
2089
+
2090
+ // --- #261: `trailhead:uat` label marks a pending UAT follow-up (marker, not a state) ---
2091
+ {
2092
+ ok('source: substrate-commands.md ensures trailhead:uat idempotently',
2093
+ /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));
2094
+ {
2095
+ const helperSource = fs.readFileSync(path.join(repoRoot, 'plugins', 'trailhead', 'templates', 'trailhead-uat-backfill.js'), 'utf8');
2096
+ const docColor = (substrateCommandsSource.match(/gh label create trailhead:uat --color ([0-9A-Fa-f]{6})/) || [])[1];
2097
+ const helperColor = (helperSource.match(/const COLOR = '([0-9A-Fa-f]{6})'/) || [])[1];
2098
+ ok('source: the backfill helper creates trailhead:uat with the same colour as substrate-commands.md',
2099
+ docColor !== undefined && docColor === helperColor);
2100
+ }
2101
+ ok('source: substrate-commands.md has no create-then-backfill chain and invokes the backfill helper',
2102
+ /trailhead-uat-backfill\.js/.test(substrateCommandsSource) && !/trailhead:uat[^\n]*&&[^\n]*backfill/.test(substrateCommandsSource));
2103
+ ok('source: first-use setup runs the backfill helper after the labels',
2104
+ /^1\. Create any missing labels[\s\S]{0,1500}?trailhead-uat-backfill\.js/m.test(substrateCommandsSource));
2105
+ ok('source: substrate.md documents trailhead:uat as a marker label right after the state labels, not a state, not a frontier filter',
2106
+ /\*\*`trailhead:uat`\*\*[\s\S]{0,700}marker[\s\S]{0,400}not a state[\s\S]{0,400}never a frontier filter/.test(substrateSource)
2107
+ && substrateSource.indexOf('**`trailhead:uat`**') > substrateSource.indexOf('**`trailhead:paused`**')
2108
+ && substrateSource.indexOf('**`trailhead:uat`**') < substrateSource.indexOf('**Frontier**'));
2109
+ ok('source: acceptance-testing.md option (b) opens the follow-up with trailhead:uat in the gh issue create --label list, label ensured first',
2110
+ /open a follow-up ticket before that close[\s\S]{0,900}--label[^\n]{0,200}`?trailhead:uat/.test(acceptanceTestingSource)
2111
+ && /ensur\w* (the )?`?trailhead:uat`? (label )?idempotently/.test(acceptanceTestingSource));
2112
+ ok('source: ticket-engines.md UAT follow-ups section names the label',
2113
+ /UAT follow-up[\s\S]{0,1500}`trailhead:uat`/.test(ticketEnginesSource.split('\n').filter((l) => /^A ticket whose body carries/.test(l)).join('\n')));
2114
+ ok('source: work SKILL.md work-start runs the backfill helper once per clone, marker gated',
2115
+ /trailhead-uat-backfill\.js[^\n]*--marker[^\n]*\.trailhead\/uat-backfill/.test(workSkillSource));
2116
+ ok('source: work SKILL.md passes the backfill marker as an absolute path under the working root',
2117
+ /trailhead-uat-backfill\.js[^\n]*--marker <working-root>\/\.trailhead\/uat-backfill/.test(workSkillSource));
2118
+ ok('source: .gitignore ignores .trailhead/uat-backfill',
2119
+ /^\.trailhead\/uat-backfill$/m.test(fs.readFileSync(path.join(repoRoot, '.gitignore'), 'utf8')));
2120
+ ok('source: work SKILL.md mentions the uat-backfill marker as gitignored per-clone state',
2121
+ /`\.trailhead\/uat-backfill`[^\n]*(gitignored|never committed)/.test(workSkillSource));
2122
+ const producerSources = { 'acceptance-testing.md': acceptanceTestingSource, 'site acceptance-testing.md': siteAcceptanceSource };
2123
+ for (const [name, src] of Object.entries(producerSources)) {
2124
+ ok(`source: ${name} writes the pointer as UAT of: [<parent name>](<parent url>)`,
2125
+ src.includes('UAT of: [<parent name>](<parent url>)') && !/UAT of: <parent name>\(link\)|UAT of: #<n>/.test(src));
2126
+ }
2127
+ }
2128
+
2129
+ // --- #263: a paused ticket is off the frontier on every surface ---
2130
+ {
2131
+ const viewSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-view', 'SKILL.md'), 'utf8');
2132
+ const siteDocs = path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs');
2133
+ const siteViewSource = fs.readFileSync(path.join(siteDocs, 'commands', 'view.md'), 'utf8');
2134
+ const siteWorkSource = fs.readFileSync(path.join(siteDocs, 'commands', 'work.md'), 'utf8');
2135
+ const changelogSource = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
2136
+ const searchExprs = (src) => [...src.matchAll(/--search\s+"([^"]*no:assignee[^"]*)"/g)].map((m) => m[1]);
2137
+ const base = 'no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused';
2138
+ const wbExcl = `${base} -label:trailhead:whiteboard`;
2139
+ const tally = (exprs) => exprs.reduce((acc, e) => ({ ...acc, [e]: (acc[e] || 0) + 1 }), {});
2140
+ const sameTally = (got, want) => JSON.stringify(Object.entries(got).sort()) === JSON.stringify(Object.entries(want).sort());
2141
+
2142
+ ok('source: substrate-commands.md frontier queries each exclude paused (two repo-wide, four label-scoped or whiteboard or count)',
2143
+ sameTally(tally(searchExprs(substrateCommandsSource)), { [wbExcl]: 2, [base]: 4 }));
2144
+ ok('source: multi-map.md scoped frontier query excludes paused', sameTally(tally(searchExprs(multiMapSource)), { [base]: 1 }));
2145
+ ok('source: trailhead-view SKILL.md whiteboard frontier query excludes paused', sameTally(tally(searchExprs(viewSkillSource)), { [base]: 1 }));
2146
+ ok('source: the cookbook frontier comment says not paused',
2147
+ /# the frontier: open, unassigned, not blocked, not unverified, not paused, one query/.test(substrateCommandsSource));
2148
+
2149
+ ok('source: substrate.md Frontier definition excludes trailhead:paused',
2150
+ /\*\*Frontier\*\* = open, unassigned tickets that are \*\*not\*\* `trailhead:blocked`, \*\*not\*\* `trailhead:unverified` and \*\*not\*\* `trailhead:paused`/.test(substrateSource));
2151
+ const pausedBullet = (substrateSource.match(/^- \*\*`trailhead:paused`\*\*.*$/m) || [''])[0];
2152
+ ok('source: substrate.md paused bullet: excluded from the frontier, taken back with /trailhead:resume',
2153
+ /excluded from the frontier/.test(pausedBullet) && /\/trailhead:resume/.test(pausedBullet));
2154
+ ok('source: the old "does not change the frontier query" wording is gone',
2155
+ !/does not change the frontier query/.test(substrateSource));
2156
+
2157
+ const paused = /^- \*\*In progress\*\*[^\n]*\n- \*\*Paused\*\*[^\n]*\/trailhead:resume <n>`[^\n]*\n- \*\*Blocked\*\*/gm;
2158
+ ok('source: the map and whiteboard renders both list a Paused group between In progress and Blocked',
2159
+ (viewSkillSource.match(paused) || []).length === 2);
2160
+ ok('source: both render Frontier bullets say not paused',
2161
+ (viewSkillSource.match(/^- \*\*Frontier\*\*:[^\n]*not[^\n]*`trailhead:paused`/gm) || []).length === 2);
2162
+
2163
+ ok('source: multi-map.md park & switch resumes with /trailhead:resume only',
2164
+ /Resume later with `\/trailhead:resume`/.test(multiMapSource) && !/just `\/trailhead:work` once that map is active/.test(multiMapSource));
2165
+
2166
+ ok('source: work SKILL.md: bare work takes the paused-excluding frontier, a named work or quick still works a paused ticket',
2167
+ /Otherwise take a frontier ticket[\s\S]{0,600}never a `trailhead:paused` ticket/.test(workSkillSource) &&
2168
+ /\*\*named\*\* ticket may also be a `trailhead:paused` one, which `work <n>` \/ `quick <n>` takes back/.test(workSkillSource) &&
2169
+ /carries `trailhead:paused` drops it at the claim/.test(workSkillSource));
2170
+
2171
+ ok('source: auto.md resumes owned paused tickets in step 2 and no longer says they come back on the frontier',
2172
+ /\*\*each\*\* ticket you own[\s\S]{0,200}`trailhead:paused`/.test(autoSource) &&
2173
+ !/paused\/stuck tickets are back on the frontier/.test(autoSource) && !/paused[^.\n]{0,40}back on the frontier/.test(autoSource));
2174
+ ok('source: site work.md says a paused ticket is off the frontier until resumed',
2175
+ /off the frontier until (it is )?resumed/.test(siteWorkSource));
2176
+ ok('source: site view.md describes the Paused group off the frontier', /Paused/.test(siteViewSource) && /not paused|off the frontier/.test(siteViewSource));
2177
+ const conceptsSource = fs.readFileSync(path.join(siteDocs, 'concepts.md'), 'utf8');
2178
+ ok('source: concepts.md, README.md and README.it.md frontier definitions mention paused',
2179
+ /not paused/.test(conceptsSource) && /not paused/.test(readmeSource) && /in pausa/.test(readmeItSource));
2180
+ ok('source: CHANGELOG Unreleased > Changed has a #263 entry', /### Changed[\s\S]*?\(#263\)/.test(changelogSource.split('## 0.14.0')[0]));
2181
+ }
2182
+
1844
2183
  for (const d of tmpDirs) {
1845
2184
  fs.rmSync(d, { recursive: true, force: true });
1846
2185
  }