@marcomigozzi/trailhead 0.8.1 → 0.10.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 (45) hide show
  1. package/README.it.md +20 -17
  2. package/README.md +20 -17
  3. package/bin/lib/codex-projection.js +14 -6
  4. package/bin/lib/codex-projection.test.js +46 -2
  5. package/bin/trailhead.js +21 -10
  6. package/bin/trailhead.test.js +326 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/commands/prune.md +6 -0
  10. package/plugins/trailhead/commands/quick.md +1 -1
  11. package/plugins/trailhead/hooks/hooks.json +4 -0
  12. package/plugins/trailhead/hooks/lib/gh-subcommand.js +102 -0
  13. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +99 -0
  14. package/plugins/trailhead/hooks/trailhead-body-guard.js +221 -0
  15. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +222 -0
  16. package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.js +61 -35
  17. package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.test.js +76 -0
  18. package/plugins/trailhead/hooks/trailhead-secret-guard.js +4 -15
  19. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +31 -0
  20. package/plugins/trailhead/skills/_shared/choices.md +5 -1
  21. package/plugins/trailhead/skills/_shared/configuration-reference.md +23 -16
  22. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  23. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  24. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  25. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  26. package/plugins/trailhead/skills/_shared/session-handoff.md +9 -7
  27. package/plugins/trailhead/skills/_shared/substrate-commands.md +41 -5
  28. package/plugins/trailhead/skills/_shared/substrate.md +10 -6
  29. package/plugins/trailhead/skills/_shared/teamwork.md +19 -12
  30. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +13 -4
  31. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  32. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  33. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  34. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  35. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  36. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  37. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +3 -1
  38. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +12 -2
  39. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  40. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  41. package/plugins/trailhead/skills/trailhead-manage/references/pruning.md +48 -0
  42. package/plugins/trailhead/skills/trailhead-view/SKILL.md +4 -2
  43. package/plugins/trailhead/skills/trailhead-work/SKILL.md +18 -16
  44. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -2
  45. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +12 -9
package/bin/trailhead.js CHANGED
@@ -213,14 +213,15 @@ function claudePaths(configDir) {
213
213
  settings: path.join(configDir, 'settings.json'),
214
214
  };
215
215
  }
216
- const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-secret-read-guard.js', 'trailhead-check-update.js'];
216
+ const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-secret-read-guard.js', 'trailhead-body-guard.js', 'trailhead-check-update.js'];
217
217
  // Runtime libs the hook scripts require (trailhead-commit-guard.js does
218
218
  // require('./lib/commit-message-check.js'); trailhead-search-guard.js and
219
219
  // trailhead-secret-read-guard.js both do require('./lib/shell-scan.js'), #135
220
- // follow-up). Copied by name, never a recursive sweep: the Claude hooks/lib
221
- // dir is shared with other plugins, so we must not clobber a co-tenant's lib
222
- // nor ship our own *.test.js files.
223
- const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js'];
220
+ // follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
221
+ // require('./lib/gh-subcommand.js'), #153 follow-up). Copied by name, never a
222
+ // recursive sweep: the Claude hooks/lib dir is shared with other plugins, so
223
+ // we must not clobber a co-tenant's lib nor ship our own *.test.js files.
224
+ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js'];
224
225
 
225
226
  // Copy the hook scripts and their runtime libs into a destination hooks dir
226
227
  // (Claude's ~/.claude/hooks or Codex's skills/trailhead/hooks). Shared by both
@@ -231,7 +232,7 @@ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js'];
231
232
  // live too, matching skills/commands. Never symlink the whole hooks/ dir: it is
232
233
  // shared with other plugins, so only the curated allowlist is touched. rmrf the
233
234
  // dest first so a reinstall over an existing file or symlink never hits EEXIST.
234
- function copyHookScripts(destHooksDir, { useSymlink = false } = {}) {
235
+ function copyHookScripts(destHooksDir, { useSymlink = false, exclude = [] } = {}) {
235
236
  ensure(destHooksDir);
236
237
  const put = (src, dest) => {
237
238
  rmrf(dest);
@@ -242,7 +243,7 @@ function copyHookScripts(destHooksDir, { useSymlink = false } = {}) {
242
243
  fs.chmodSync(dest, 0o755);
243
244
  }
244
245
  };
245
- for (const f of HOOK_FILES) put(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
246
+ for (const f of HOOK_FILES.filter((f) => !exclude.includes(f))) put(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
246
247
  if (HOOK_LIB_FILES.length) {
247
248
  const libDest = path.join(destHooksDir, 'lib');
248
249
  ensure(libDest);
@@ -329,6 +330,7 @@ function uninstallClaude(configDir) {
329
330
  stripHook(s, 'PreToolUse', 'trailhead-install-guard.js');
330
331
  stripHook(s, 'PreToolUse', 'trailhead-search-guard.js');
331
332
  stripHook(s, 'PreToolUse', 'trailhead-secret-read-guard.js');
333
+ stripHook(s, 'PreToolUse', 'trailhead-body-guard.js');
332
334
  stripHook(s, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
333
335
  stripHook(s, 'SessionStart', 'trailhead-check-update.js');
334
336
  writeJSON(P.settings, s);
@@ -375,6 +377,7 @@ function installClaude(configDir, { useSymlink }) {
375
377
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
376
378
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-install-guard.js')),
377
379
  addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-search-guard.js')),
380
+ addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-body-guard.js')),
378
381
  addHook(s, 'PreToolUse', 'Read|Bash', hookCmd('trailhead-secret-read-guard.js')),
379
382
  addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
380
383
  addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
@@ -572,9 +575,12 @@ function installCodex(configDir, { useSymlink }) {
572
575
  const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
573
576
  if (pkgVersion) fs.writeFileSync(L.versionFile, pkgVersion + '\n');
574
577
 
575
- // Hooks: copy the 4 guard scripts into the skill dir and register them in
576
- // ~/.codex/hooks.json (same shape as Claude's settings.json hooks block).
577
- copyHookScripts(L.hooksScriptsDir, { useSymlink });
578
+ // Hooks: copy the guard scripts this host actually uses into the skill dir
579
+ // and register them in ~/.codex/hooks.json (same shape as Claude's
580
+ // settings.json hooks block). search-guard is excluded: it is
581
+ // Claude-Code-specific (see codexHookEntries), so it is neither copied nor
582
+ // registered here.
583
+ copyHookScripts(L.hooksScriptsDir, { useSymlink, exclude: ['trailhead-search-guard.js'] });
578
584
  const h = readJSON(L.hooksJson);
579
585
  for (const e of codexHookEntries(L.hooksScriptsDir)) addHook(h, e.event, e.matcher, e.command);
580
586
  writeJSON(L.hooksJson, h);
@@ -679,8 +685,13 @@ function uninstallCodex(configDir) {
679
685
  stripHook(h, 'PreToolUse', 'trailhead-commit-guard.js');
680
686
  stripHook(h, 'PreToolUse', 'trailhead-secret-guard.js');
681
687
  stripHook(h, 'PreToolUse', 'trailhead-install-guard.js');
688
+ // search-guard is no longer projected to Codex (#169: it is
689
+ // Claude-Code-specific), so it is never registered by a current install;
690
+ // this strip is retained only to clean up a stale entry left by a
691
+ // pre-#169 install that did register it.
682
692
  stripHook(h, 'PreToolUse', 'trailhead-search-guard.js');
683
693
  stripHook(h, 'PreToolUse', 'trailhead-secret-read-guard.js');
694
+ stripHook(h, 'PreToolUse', 'trailhead-body-guard.js');
684
695
  stripHook(h, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
685
696
  stripHook(h, 'SessionStart', 'trailhead-check-update.js');
686
697
  writeJSON(L.hooksJson, h);
@@ -134,6 +134,315 @@ for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-v
134
134
  ok(`source: ${cl}/SKILL.md does NOT duplicate the core-list signature line`, !fs.readFileSync(path.join(sourceSkillsDir, cl, 'SKILL.md'), 'utf8').includes(loadFirstSignature));
135
135
  }
136
136
 
137
+ // --- #146: single-sourced ticket-language directive (anti-drift) -------------
138
+ // The standing ticket-language rule (write Issue prose + commit bodies in
139
+ // config.ticket.language, decoupled from the chat language) lives in ONE place,
140
+ // _shared/ticket-language.md; it is listed in the load contract's six-file core
141
+ // and REFERENCED (never restated) by the engine references. Mirrors the #139
142
+ // load-first invariant so the directive can't silently drift.
143
+ ok('codex: _shared/ticket-language.md projected', fs.existsSync(path.join(codexDir, 'skills', '_shared', 'ticket-language.md')));
144
+ for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
145
+ ok(`codex: ${cl}/../_shared/ticket-language.md resolves as a sibling`, fs.existsSync(path.join(codexDir, 'skills', cl, '..', '_shared', 'ticket-language.md')));
146
+ }
147
+ // The single source states the rule's substance: the canonical config key AND
148
+ // the decoupling clause (the heart of the rule), not merely a heading.
149
+ const ticketLangSourcePath = path.join(sourceSkillsDir, '_shared', 'ticket-language.md');
150
+ const ticketLangSource = fs.readFileSync(ticketLangSourcePath, 'utf8');
151
+ const ticketLangKey = '`config.ticket.language`';
152
+ const ticketLangDecoupling = '**independent of the language the agent converses in**';
153
+ ok('source: ticket-language.md states the rule (config.ticket.language + decoupling clause)',
154
+ ticketLangSource.includes(ticketLangKey) && ticketLangSource.includes(ticketLangDecoupling));
155
+ // The load contract lists it in the six-file core (item 2), by path reference.
156
+ const loadFirstSource = fs.readFileSync(loadFirstSourcePath, 'utf8');
157
+ ok('source: load-first.md references ../_shared/ticket-language.md', loadFirstSource.includes('../_shared/ticket-language.md'));
158
+ // The engine references point AT the single source (path reference), not restate it.
159
+ const ticketLangAutoSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md'), 'utf8');
160
+ const ticketLangCaptureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
161
+ ok('source: auto.md references ticket-language.md', ticketLangAutoSource.includes('ticket-language.md'));
162
+ ok('source: capture.md references ticket-language.md', ticketLangCaptureSource.includes('ticket-language.md'));
163
+ // Source-level anti-drift: the substantive decoupling clause lives in EXACTLY
164
+ // one file across the whole skills tree, _shared/ticket-language.md. A global
165
+ // single-occurrence scan (not an enumerated per-file list) so any future doc
166
+ // that restates the verbatim directive is caught, including reachable
167
+ // shared-core references beyond the load contract and the two engine references
168
+ // (e.g. configuration-reference.md, choices.md). The legitimate paraphrases in
169
+ // configuration-reference.md/capture.md reword the rule and do not match this
170
+ // bolded verbatim clause, so they don't trip the scan.
171
+ const mdFilesUnder = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
172
+ const full = path.join(dir, e.name);
173
+ return e.isDirectory() ? mdFilesUnder(full) : (e.name.endsWith('.md') ? [full] : []);
174
+ });
175
+ const ticketLangClauseFiles = mdFilesUnder(sourceSkillsDir).filter((f) => fs.readFileSync(f, 'utf8').includes(ticketLangDecoupling));
176
+ ok('source: ticket-language clause appears in exactly one skills-tree file', ticketLangClauseFiles.length === 1);
177
+ ok('source: that one file is _shared/ticket-language.md', ticketLangClauseFiles.length === 1 && path.basename(ticketLangClauseFiles[0]) === 'ticket-language.md');
178
+
179
+ // --- #147: chart's close anchors the next step to /trailhead:work -----------
180
+ // Source-level invariant: charting.md renders a codified next-step block at the
181
+ // chart/adopt close, led by /clear, anchored to /trailhead:work (the map-frontier
182
+ // verb) with /trailhead:quick only as the off-map alternative, single-sourced
183
+ // once in Mode 1 step 6 with Mode 1-bis reaching it via "As Mode 1, steps 4-6".
184
+ const chartingSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'charting.md'), 'utf8');
185
+ ok('source: charting.md close carries a codified next-step block', /Next-step block \(the close of chart/.test(chartingSource));
186
+ // Structural: the rendered blockquote must LEAD with a standalone /clear bullet
187
+ // (guards the "never demote /clear to a trailing parenthetical" regression #147 exists for).
188
+ ok('source: charting.md next-step block leads with a standalone /clear bullet', /> \*\*Prossimo passo:\*\*\n> - `\/clear`/.test(chartingSource));
189
+ // Structural: /trailhead:work is the primary bullet, carrying the number (name in prose).
190
+ ok('source: charting.md next-step block makes /trailhead:work the primary move with the number', /`\/trailhead:work <numero>` per lavorare \*\*<nome/.test(chartingSource));
191
+ // Structural: /trailhead:quick is demoted to the last "in alternativa ... fuori mappa" bullet.
192
+ ok('source: charting.md next-step block demotes /trailhead:quick to the off-map alternative bullet', /> - in alternativa `\/trailhead:quick[^\n]*fuori mappa/.test(chartingSource));
193
+ ok('source: charting.md next-step block presents several independent tickets as a set to choose from', chartingSource.includes('set to choose from'));
194
+ ok('source: charting.md next-step block frames quick as the off-map alternative', chartingSource.includes('only as the off-map alternative'));
195
+ ok('source: charting.md single-sources the block via the "As Mode 1, steps 4-6" reference', /As Mode 1, steps 4[–-]6/.test(chartingSource));
196
+ const chartSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'SKILL.md'), 'utf8');
197
+ ok('source: trailhead-chart SKILL.md config-offer points at the next-step block', chartSkillSource.includes('next-step block') && chartSkillSource.includes('/trailhead:work'));
198
+
199
+ // --- #149: capture confirmation anchors the next step to /trailhead:work -----
200
+ // Source-level invariant, mirroring #147 for chart: capture.md's confirmation-line
201
+ // rule names /trailhead:work as the anchor (full engine, keeps the map record) and
202
+ // demotes /trailhead:quick to the off-map / no-split alternative, offered alongside,
203
+ // never in its place. Localized + ordered assertions (not broad substrings): the anchor
204
+ // clause precedes the quick-demotion clause within the Confirmation line. Targets the
205
+ // capture confirmation line specifically, NOT a blanket /trailhead:quick ban
206
+ // (session-handoff.md legitimately offers /trailhead:quick <n> for the next loose ticket).
207
+ const captureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
208
+ ok('source: capture.md confirmation anchors the next step to /trailhead:work', /[Aa]nchor the next step to `\/trailhead:work <n>`, always/.test(captureSource));
209
+ ok('source: capture.md demotes /trailhead:quick to only the no-split alternative, after the work anchor',
210
+ /[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is \*\*only\*\* the no-split alternative/.test(captureSource));
211
+ ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
212
+ ok('source: capture.md keeps /trailhead:work the anchor even for a whiteboard capture', /whiteboard[\s\S]{0,200}the anchor stays `\/trailhead:work <n>`/.test(captureSource));
213
+
214
+ // --- #179: `quick <n>` on a map ticket is never "off the map" ----------------
215
+ // Source-level invariant: `quick <n>` on an EXISTING ticket does not leave the
216
+ // map (it keeps its trailhead:map-<n> label and sub-issue edge, and dependent-
217
+ // unblocking still runs); "off the map" describes only `quick "<text>"`, which
218
+ // opens a NEW whiteboard ticket. capture.md, session-handoff.md, and
219
+ // trailhead-work/SKILL.md must no longer carry the old "that same work done
220
+ // off the map" phrasing, nor describe `quick <n>` itself as off the map.
221
+ const sessionHandoffSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'session-handoff.md'), 'utf8');
222
+ const workSkillSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'SKILL.md'), 'utf8');
223
+ const offMapQuickN = /`?quick <n>`?[^.\n]{0,80}off the map/i;
224
+ // The explicit "never describe ... as off the map" clause legitimately puts
225
+ // `quick <n>` near "off the map" in the same sentence, so strip it before
226
+ // running the negative proximity check below.
227
+ const neverDescribeClause = /[Nn]ever describe `quick <n>`[^\n]*?off the map\.?/;
228
+ const sessionHandoffMinusClause = sessionHandoffSource.replace(neverDescribeClause, '');
229
+ ok('source: capture.md drops the old "that same work done off the map" phrasing', !captureSource.includes('that same work done off the map'));
230
+ ok('source: session-handoff.md drops the old "that same work done off the map" phrasing', !sessionHandoffSource.includes('that same work done off the map'));
231
+ ok('source: trailhead-work SKILL.md drops the old "that same work done off the map" phrasing', !workSkillSourceFor179.includes('that same work done off the map'));
232
+ ok('source: capture.md never describes quick <n> as off the map', !offMapQuickN.test(captureSource));
233
+ ok('source: session-handoff.md never describes quick <n> as off the map (outside the explicit never-clause)', !offMapQuickN.test(sessionHandoffMinusClause));
234
+ ok('source: trailhead-work SKILL.md never describes quick <n> as off the map', !offMapQuickN.test(workSkillSourceFor179));
235
+ ok('source: session-handoff.md explicitly says never to describe quick <n> on a map ticket as off the map',
236
+ neverDescribeClause.test(sessionHandoffSource));
237
+ ok('source: capture.md says a map ticket stays on its map', /a map ticket stays on its map/.test(captureSource));
238
+ const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
239
+ ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
240
+
241
+ // --- #151: eager edge drop at close (superseded / out-of-scope only) ---------
242
+ // Source-level invariant: closing a ticket as trailhead:superseded or
243
+ // trailhead:out-of-scope drops its native sub-issue edge at close (reusing the
244
+ // 404-tolerant DELETE from #150), while a genuinely-resolved close leaves the
245
+ // edge in place (it feeds GitHub's native progress bar; /trailhead:prune is its
246
+ // only shedder). Asserts the canonical rule, every operative close site, and a
247
+ // NEGATIVE invariant on the resolved-close path.
248
+ const substrateCommandsSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'substrate-commands.md'), 'utf8');
249
+ ok('source: substrate-commands.md documents the eager edge drop at close (superseded / out-of-scope only)',
250
+ /[Ee]ager edge drop at close \(superseded \/ out-of-scope only\)/.test(substrateCommandsSource));
251
+ ok('source: substrate-commands.md fixes the label -> DELETE -> close ordering for the eager drop',
252
+ /apply the state label first, then DELETE the edge, then `gh issue close`/.test(substrateCommandsSource));
253
+ ok('source: substrate-commands.md eager drop excludes resolved closes (progress bar / prune)',
254
+ // Proximity-scoped so each conjunct is load-bearing on the NEW paragraph's own
255
+ // text, not on the pre-existing "progress bar" mentions elsewhere in the file.
256
+ /Only these two closes shed eagerly[\s\S]{0,300}progress bar/.test(substrateCommandsSource) &&
257
+ /Resolved edges stay[\s\S]{0,160}`\/trailhead:prune`'s job/.test(substrateCommandsSource));
258
+
259
+ const substrateSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'substrate.md'), 'utf8');
260
+ ok('source: substrate.md out-of-scope label notes the edge is dropped at close',
261
+ /`trailhead:out-of-scope`[\s\S]{0,260}native sub-issue edge is dropped at close/.test(substrateSource));
262
+ ok('source: substrate.md superseded label notes the edge is dropped at close',
263
+ /`trailhead:superseded`[\s\S]{0,260}native sub-issue edge is dropped at close/.test(substrateSource));
264
+
265
+ const teamworkSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'teamwork.md'), 'utf8');
266
+ ok('source: teamwork.md supersede close drops the native sub-issue edge',
267
+ /Supersede the original[\s\S]{0,400}drop its native sub-issue edge from the map/.test(teamworkSource));
268
+
269
+ const workSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'SKILL.md'), 'utf8');
270
+ ok('source: trailhead-work SKILL.md out-of-scope close drops the native sub-issue edge',
271
+ /out of scope\*\* \(label, then \*\*drop its native sub-issue edge\*\*/.test(workSkillSource));
272
+
273
+ const inboxSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'inbox.md'), 'utf8');
274
+ ok('source: inbox.md out-of-scope close drops the native sub-issue edge',
275
+ /`trailhead:out-of-scope`, \*\*drop its native sub-issue edge\*\*/.test(inboxSource));
276
+ ok('source: inbox.md dropped-fog close drops any native sub-issue edge',
277
+ /drop any native sub-issue edge to reclaim a slot/.test(inboxSource));
278
+
279
+ // NEGATIVE invariant: the resolved-close paths (build/bug Resolve) must NOT
280
+ // carry an eager edge-drop instruction; resolved edges stay for the progress bar.
281
+ const ticketEnginesSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
282
+ ok('source: ticket-engines.md resolved-close path does NOT drop the native sub-issue edge',
283
+ !/drop (its|any) native sub-issue edge/.test(ticketEnginesSource));
284
+
285
+ // --- #178: drive-mode ask (AI-driven vs human-driven) at every UAT start -----
286
+ // Single-sourced in techniques/acceptance-testing.md as the "Drive mode" bullet;
287
+ // every other call-site (ticket-engines.md, auto.md, choices.md) references it
288
+ // by name ("drive-mode ask") rather than restating its clauses. Extract the
289
+ // bullet's own span (its marker line through the end of its indented ` - `
290
+ // sub-bullets) and pin each clause inside that span, mirroring the #139/#146
291
+ // source-level anti-drift style.
292
+ function extractBulletSpan(source, markerLine) {
293
+ const lines = source.split('\n');
294
+ const startIdx = lines.findIndex((l) => l.includes(markerLine));
295
+ if (startIdx === -1) return '';
296
+ let endIdx = startIdx + 1;
297
+ while (endIdx < lines.length && lines[endIdx].startsWith(' - ')) endIdx++;
298
+ return lines.slice(startIdx, endIdx).join('\n');
299
+ }
300
+ const driveModeMarker = '- **Drive mode: ask who drives the UAT.**';
301
+ const acceptanceTestingSourcePath = path.join(sourceSkillsDir, '_shared', 'techniques', 'acceptance-testing.md');
302
+ const acceptanceTestingSource = fs.readFileSync(acceptanceTestingSourcePath, 'utf8');
303
+ const driveModeSpan = extractBulletSpan(acceptanceTestingSource, driveModeMarker);
304
+ ok('source: acceptance-testing.md carries the Drive-mode bullet', driveModeSpan.length > 0);
305
+ for (const phrase of [
306
+ '**before any step runs**',
307
+ '**wait for an explicit answer**',
308
+ '**silence or an ambiguous reply re-asks**',
309
+ '**One-path skip**',
310
+ '**state the reason in one line**',
311
+ 'VERIFY',
312
+ 'only for the steps it cannot perform',
313
+ 'runs unchanged',
314
+ '**in both modes**',
315
+ 'acceptance.browser: off',
316
+ 'without the confirm gate',
317
+ ]) {
318
+ ok(`source: acceptance-testing.md Drive-mode span pins "${phrase}"`, driveModeSpan.includes(phrase));
319
+ }
320
+ // The three start paths.
321
+ ok('source: Drive-mode span names build/bug Verify as a start path', driveModeSpan.includes('Verify'));
322
+ ok("source: Drive-mode span names the Pending-UAT guard's option (a) as a start path", driveModeSpan.includes('Pending-UAT guard'));
323
+ ok('source: Drive-mode span names a UAT follow-up (UAT of:) as a start path', driveModeSpan.includes('UAT of:'));
324
+ // The three performability classes.
325
+ ok('source: Drive-mode span names the physical-device/hardware performability class', driveModeSpan.includes('a physical device or hardware'));
326
+ ok('source: Drive-mode span names the credentials/accounts performability class', driveModeSpan.includes('credentials or accounts it does not hold'));
327
+ ok('source: Drive-mode span names the human sensory-judgement performability class', driveModeSpan.includes('a human sensory judgement'));
328
+
329
+ // The "Pending UAT must stay tracked" bullet references the ask for BOTH (a)
330
+ // run-now and (b) the follow-up (grouped: two mentions in the same bullet).
331
+ const pendingUatBulletMarker = '**Pending UAT must stay tracked: a closed ticket is not tracking.**';
332
+ const pendingUatLine = acceptanceTestingSource.split('\n').find((l) => l.includes(pendingUatBulletMarker)) || '';
333
+ ok('source: Pending-UAT bullet exists', pendingUatLine.length > 0);
334
+ ok('source: Pending-UAT bullet references the drive-mode ask for both (a) and the follow-up',
335
+ (pendingUatLine.match(/drive-mode ask/g) || []).length >= 2);
336
+
337
+ // Single-source anti-drift: the bullet's own signature phrase appears in
338
+ // exactly one file across the whole skills tree (mirrors #146).
339
+ const driveModeSignature = 'ask who drives the UAT';
340
+ const driveModeSignatureFiles = mdFilesUnder(sourceSkillsDir).filter((f) => fs.readFileSync(f, 'utf8').includes(driveModeSignature));
341
+ ok('source: "ask who drives the UAT" signature appears in exactly one skills-tree file', driveModeSignatureFiles.length === 1);
342
+ ok('source: that one file is techniques/acceptance-testing.md', driveModeSignatureFiles.length === 1 && driveModeSignatureFiles[0] === acceptanceTestingSourcePath);
343
+
344
+ // The Codex install projects acceptance-testing.md with the Drive-mode bullet.
345
+ ok('codex: skills/_shared/techniques/acceptance-testing.md is projected with the Drive-mode bullet',
346
+ fs.readFileSync(path.join(codexDir, 'skills', '_shared', 'techniques', 'acceptance-testing.md'), 'utf8').includes(driveModeMarker));
347
+
348
+ // --- #178 (2/4): route UAT follow-ups by pointer, engines through the ask ----
349
+ // A new type-independent section, keyed on the `UAT of:` pointer rather than
350
+ // the ticket's type label, must sit before the first per-type `## `decision``
351
+ // heading (work/quick/auto all dispatch through this file).
352
+ function extractSection(source, headingMarker) {
353
+ const start = source.indexOf(headingMarker);
354
+ if (start === -1) return '';
355
+ const rest = source.slice(start + headingMarker.length);
356
+ const nextHeadingIdx = rest.search(/\n## /);
357
+ return headingMarker + (nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx));
358
+ }
359
+ const uatFollowupHeadingMarker = '## UAT follow-ups (any type)';
360
+ const decisionHeadingMarker = '## `decision`';
361
+ ok('source: ticket-engines.md has the "UAT follow-ups (any type)" section', ticketEnginesSource.includes(uatFollowupHeadingMarker));
362
+ ok('source: the UAT follow-ups section precedes the first `decision` heading',
363
+ ticketEnginesSource.indexOf(uatFollowupHeadingMarker) !== -1 &&
364
+ ticketEnginesSource.indexOf(uatFollowupHeadingMarker) < ticketEnginesSource.indexOf(decisionHeadingMarker));
365
+ const uatFollowupSection = extractSection(ticketEnginesSource, uatFollowupHeadingMarker);
366
+ for (const phrase of ['UAT of:', 'whatever its type label', 'trailhead:seed', '`VERIFY` comment on the follow-up', 'acceptance-testing.md']) {
367
+ ok(`source: the UAT follow-ups section contains "${phrase}"`, uatFollowupSection.includes(phrase));
368
+ }
369
+
370
+ // Both the build and bug engines' Verify step, and both Resolve steps'
371
+ // Pending-UAT guard, must route through the drive-mode ask by name (the
372
+ // single source stays acceptance-testing.md; these reference it).
373
+ const verifyStepLines = ticketEnginesSource.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
374
+ ok('source: ticket-engines.md carries both the build and bug Verify step lines', verifyStepLines.length === 2);
375
+ ok('source: both Verify step lines reference the drive-mode ask', verifyStepLines.length === 2 && verifyStepLines.every((l) => l.includes('drive-mode ask')));
376
+
377
+ const pendingUatGuardLines = ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)'));
378
+ ok('source: ticket-engines.md carries both build and bug Pending-UAT guard lines', pendingUatGuardLines.length === 2);
379
+ ok('source: both Pending-UAT guard lines reference the drive-mode ask', pendingUatGuardLines.length === 2 && pendingUatGuardLines.every((l) => l.includes('drive-mode ask')));
380
+
381
+ // auto.md mentions the ask, AND (plan-review addition) a single localized span
382
+ // states its outcomes TOGETHER: takes it without the confirm gate, picks
383
+ // AI-driven when any step is agent-performable, never fakes a human-only step,
384
+ // and sets the ticket aside listing them in the run-end summary.
385
+ const autoSourcePath = path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md');
386
+ const autoSource = fs.readFileSync(autoSourcePath, 'utf8');
387
+ ok('source: auto.md mentions the drive-mode ask', autoSource.includes('drive-mode ask'));
388
+ ok('source: auto.md states the drive-mode-ask outcomes together (grouped, not scattered)',
389
+ /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));
390
+
391
+ // choices.md carries the call-site row for the drive-mode ask.
392
+ const choicesSourcePath = path.join(sourceSkillsDir, '_shared', 'choices.md');
393
+ const choicesSource = fs.readFileSync(choicesSourcePath, 'utf8');
394
+ ok('source: choices.md call-site table carries the drive-mode ask row', /\|[^\n]*[Dd]rive-mode ask[^\n]*\|/.test(choicesSource));
395
+
396
+ // --- #178 (3/4): acceptance.browser off keeps AI-driven UAT via commands ----
397
+ const configReferencePath = path.join(sourceSkillsDir, '_shared', 'configuration-reference.md');
398
+ const configReferenceSource = fs.readFileSync(configReferencePath, 'utf8');
399
+ ok('source: configuration-reference.md acceptance.browser explanation clarifies off still runs AI-driven checks via commands',
400
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,1000}`off`[\s\S]{0,300}AI-driven[\s\S]{0,300}commands/.test(configReferenceSource));
401
+ ok('source: configuration-reference.md guided-setup menu no longer labels the off option "Guided UAT only"',
402
+ !configReferenceSource.includes('Guided UAT only'));
403
+ ok('source: configuration-reference.md guided-setup menu relabels the off option "No browser"',
404
+ /Acceptance testing[\s\S]{0,200}No browser/.test(configReferenceSource));
405
+
406
+ // --- #178 (4/4): mirror the UAT drive-mode ask in READMEs and /docs ---------
407
+ const readmeSource = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
408
+ const readmeItSource = fs.readFileSync(path.join(repoRoot, 'README.it.md'), 'utf8');
409
+ const ticketTypesSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'ticket-types.md'), 'utf8');
410
+ const workflowSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'workflow.md'), 'utf8');
411
+ const configurationMdxSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'configuration.mdx'), 'utf8');
412
+
413
+ ok('README.md build row frames acceptance testing as asking who drives', /🔨 `build`[\s\S]{0,400}who drives/.test(readmeSource));
414
+ ok('README.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(readmeSource));
415
+ ok('README.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeSource));
416
+
417
+ ok('README.it.md build row frames acceptance testing as asking who drives (chi guida)', /🔨 `build`[\s\S]{0,400}chi guida/.test(readmeItSource));
418
+ ok('README.it.md worked example frames the login-flow acceptance as AI-driven', /chi guida[\s\S]{0,100}AI-driven/.test(readmeItSource));
419
+ ok('README.it.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeItSource));
420
+
421
+ ok('site ticket-types.md build row frames acceptance testing as asking who drives', /`build`[\s\S]{0,400}who drives/.test(ticketTypesSource));
422
+ ok('site workflow.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(workflowSource));
423
+ ok('site configuration.mdx acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(configurationMdxSource));
424
+
425
+ // --- #178 fix: keep the drive-mode ask ahead of URL resolution --------------
426
+ // Code review found the acceptance.browser clause made the drive-mode ask
427
+ // CONDITIONAL on URL resolution ("fall back to the drive-mode ask ... only
428
+ // when none can be determined"), contradicting the single source
429
+ // (acceptance-testing.md: the ask fires before any step runs, always) and
430
+ // conflating it with the URL-not-found fallback (ask for the URL, or guided
431
+ // UAT). The clause must instead state the ask always comes first and never
432
+ // gate it on URL detection.
433
+ ok('source: configuration-reference.md acceptance.browser clause does NOT make the drive-mode ask conditional on URL resolution',
434
+ !configReferenceSource.includes('fall back to the drive-mode ask'));
435
+ ok('source: configuration-reference.md acceptance.browser clause states the drive-mode ask always comes first',
436
+ /\*\*acceptance\.browser\.\*\*[\s\S]{0,200}drive-mode ask[\s\S]{0,120}always comes first/.test(configReferenceSource));
437
+
438
+ // The run-end summary's enumerated set-aside category list is missing the new
439
+ // human-only-UAT-steps class (added by #178); it must be listed there too,
440
+ // matching the wording already used for it at line 29 ("human-only steps").
441
+ const runEndSummarySection = extractSection(autoSource, '## Run-end summary');
442
+ ok('source: auto.md Run-end summary section exists', runEndSummarySection.length > 0);
443
+ ok('source: auto.md Run-end summary "what remains" list includes human-only UAT steps',
444
+ /what remains[\s\S]{0,400}human-only UAT steps/.test(runEndSummarySection));
445
+
137
446
  // --- codex hooks (#29) --------------------------------------------------------
138
447
  const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
139
448
  ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
@@ -148,6 +457,16 @@ ok('codex: hooks.json registers check-update under SessionStart', (codexHooksJso
148
457
  ok('codex: skills/trailhead/hooks/trailhead-secret-guard.js exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-secret-guard.js')));
149
458
  ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-guard require target)',
150
459
  fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
460
+ ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
461
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
462
+ // #169: search-guard is Claude-Code-specific (it only prevents arming
463
+ // Claude's Read() deny rule under bypass permissions, a mechanism Codex
464
+ // lacks), so it must neither be registered nor copied on a Codex install.
465
+ ok('codex: hooks.json does NOT register search-guard under any event', !codexHooksJsonStr.includes('trailhead-search-guard.js'));
466
+ ok('codex: skills/trailhead/hooks/trailhead-search-guard.js does NOT exist',
467
+ !fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-search-guard.js')));
468
+ ok('codex: skills/trailhead/hooks/lib/shell-scan.js still exists (secret-read-guard require target)',
469
+ fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'shell-scan.js')));
151
470
 
152
471
  const codexConfigTomlPath = path.join(codexDir, 'config.toml');
153
472
  ok('codex: config.toml exists', fs.existsSync(codexConfigTomlPath));
@@ -271,11 +590,18 @@ ok('claude: trailhead-work/../_shared/load-first.md resolves', fs.existsSync(pat
271
590
  for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
272
591
  ok(`claude: ${cl} SKILL.md references ../_shared/load-first.md`, fs.readFileSync(path.join(claudeDir, 'skills', cl, 'SKILL.md'), 'utf8').includes('../_shared/load-first.md'));
273
592
  }
593
+
594
+ // #146: single-sourced ticket-language directive, projected on Claude too.
595
+ ok('claude: skills/_shared/ticket-language.md exists', fs.existsSync(path.join(claudeDir, 'skills', '_shared', 'ticket-language.md')));
596
+ ok('claude: trailhead-work/../_shared/ticket-language.md resolves', fs.existsSync(path.join(claudeDir, 'skills', 'trailhead-work', '..', '_shared', 'ticket-language.md')));
597
+
274
598
  ok('claude: commands/trailhead/work.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'work.md')));
275
599
  ok('claude: commands/trailhead/auto.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'auto.md')));
276
600
  ok('claude: hooks/trailhead-commit-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-commit-guard.js')));
277
601
  ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require target)',
278
602
  fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
603
+ ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
604
+ fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
279
605
  // Regression: the commit-guard does require('./lib/commit-message-check.js'), so
280
606
  // it only loads if the lib was copied alongside it. Run it with a benign Bash
281
607
  // payload and assert it does not crash with a missing-module error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcomigozzi/trailhead",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
5
5
  "bin": {
6
6
  "trailhead": "bin/trailhead.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trailhead",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
5
5
  "author": {
6
6
  "name": "Marco Migozzi",
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: Prune a map's closed and orphan sub-issue edges to reclaim slots under GitHub's 100-cap (references kept)
3
+ argument-hint: "[map]"
4
+ ---
5
+
6
+ Invoke the `trailhead` skill for the **prune** action: call the Skill tool with skill name `trailhead-manage` and arguments `prune $ARGUMENTS`, then **read the skill's `references/pruning.md` and follow it**: that file holds the prune protocol (resolve the target map, read the sub-issue count, reconcile the map's native edges bidirectionally, preview and confirm, then apply, keeping every reference). Do not re-implement the behaviour here. The skill is the single source of truth.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Work one ticket whole, off the map (whiteboard); grills only if needed, never splits
2
+ description: Work one ticket whole, never splits: a new whiteboard ticket from text, or an existing one; grills only if needed
3
3
  argument-hint: "[ticket | \"text\"]"
4
4
  ---
5
5
 
@@ -19,6 +19,10 @@
19
19
  {
20
20
  "type": "command",
21
21
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-search-guard.js\""
22
+ },
23
+ {
24
+ "type": "command",
25
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/trailhead-body-guard.js\""
22
26
  }
23
27
  ]
24
28
  },
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // gh-subcommand.js: shared gh-subcommand detector, extracted from
3
+ // trailhead-body-guard.js's ghBodyWrite and trailhead-secret-guard.js's
4
+ // ghIssueWrite (#153 follow-up), so both guards share ONE token walk instead
5
+ // of drifting copies.
6
+ //
7
+ // Bug fixed here (#153): the original inline walk only treated `-R`/`--repo`
8
+ // as a two-token (value-taking) global gh flag. Any OTHER two-token global
9
+ // flag (e.g. `gh --hostname h issue edit 5 --body ""`) desynced the walk:
10
+ // after skipping `--hostname`, the index landed on its value `h`, which is
11
+ // not a `-`-prefixed flag so the loop stopped there, and `h` was mistaken for
12
+ // the subcommand. The write became invisible to both guards (a missed
13
+ // empty-body block in body-guard, an unscanned secret in secret-guard).
14
+ //
15
+ // Follow-up fix (#153): a first pass tolerated a stray value token after any
16
+ // unknown flag UNLESS that token was itself a flag or a subcommand keyword
17
+ // (issue/pr/api). That guard reintroduced the exact same desync whenever the
18
+ // flag's VALUE literally equalled a subcommand keyword, e.g.
19
+ // `gh --hostname api issue edit 5` mis-resolved `sub` to `api` (the flag
20
+ // value), hiding the real `issue edit` write again.
21
+ //
22
+ // Fix: drop the keyword-based guard entirely and model flag arity instead.
23
+ // gh's only value-taking global flag before a subcommand is -R/--repo; a
24
+ // small, explicit set of global flags are boolean (take no value). Every
25
+ // OTHER flag token is treated as value-taking, so its value can never be
26
+ // mistaken for the subcommand - including when that value happens to equal
27
+ // a subcommand keyword like `issue`/`pr`/`api`.
28
+ //
29
+ // Second follow-up fix (#153): treating every unknown flag as value-taking
30
+ // reopened the MIRROR case - an unknown BOOLEAN flag directly before the
31
+ // subcommand (e.g. `gh --foo issue edit 5`) now gets treated as value-taking
32
+ // and swallows the subcommand itself as if it were the flag's value, landing
33
+ // `sub` on the verb (`edit`) instead of `issue`. The two failure directions
34
+ // (a flag's VALUE colliding with a subcommand keyword, vs. an unknown
35
+ // BOOLEAN flag consuming the subcommand) cannot both be resolved by a
36
+ // positional walk alone: `gh --X <tok> issue edit` and `gh --X issue edit`
37
+ // are structurally ambiguous without knowing --X's arity in advance, and gh
38
+ // itself rejects unknown flags so neither shape occurs with the real `gh`
39
+ // binary. But the guard defends against any `gh`-named binary, so it must
40
+ // not depend on that.
41
+ //
42
+ // Design principle: keep the value-taking primary walk (it is correct for
43
+ // every KNOWN two-token flag, e.g. -R/--repo, and for the common case of an
44
+ // unknown two-token flag), then ADD a fail-safe fallback that scans forward
45
+ // for a recognized write-subcommand keyword whenever the walk did not land
46
+ // on one. Erring toward DETECTION (scanning/considering a write) is the safe
47
+ // direction for a security guard; under-detection (missing a real write) is
48
+ // the dangerous one. The downstream verb gate in each guard still prevents a
49
+ // stray keyword token in a genuinely non-write command from being
50
+ // misclassified as a write.
51
+ //
52
+ // Self-contained. No requires.
53
+
54
+ // gh's boolean global flags (take NO value). gh's only value-taking global
55
+ // flag before a subcommand is -R/--repo; every other flag token here is
56
+ // treated as value-taking, so an unknown two-token global flag's value can
57
+ // never be mistaken for the subcommand (#153) - including when that value
58
+ // happens to equal a subcommand keyword like `issue`/`pr`/`api`.
59
+ const BOOLEAN_GLOBAL_FLAGS = new Set(['-h', '--help', '--version']);
60
+
61
+ // The subcommands the guards care about; used by the fail-safe fallback
62
+ // below to recover from an unknown boolean flag swallowing the subcommand.
63
+ const WRITE_SUBCOMMANDS = new Set(['issue', 'pr', 'api']);
64
+
65
+ // Parse a raw shell command string and locate the gh subcommand/verb.
66
+ // Returns { sub, verb } (verb may be undefined) or null when this is not a
67
+ // gh invocation at all.
68
+ function parseGhSubcommand(cmd) {
69
+ const toks = String(cmd).trim().split(/\s+/);
70
+ let i = 0;
71
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
72
+ if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
73
+ const start = i + 1; // first token after `gh`
74
+ i = start;
75
+ // Primary positional walk. -R/--repo (and any non-boolean, non-glued flag)
76
+ // is treated as value-taking: consume one following non-flag token so an
77
+ // unknown two-token global flag's value cannot shift the subcommand (#153).
78
+ while (i < toks.length && toks[i].startsWith('-')) {
79
+ const flag = toks[i];
80
+ i++; // the flag token itself
81
+ if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
82
+ if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
83
+ }
84
+ let sub = toks[i];
85
+ let verb = toks[i + 1];
86
+ // Fail-safe fallback: if the walk did NOT land on a recognized write
87
+ // subcommand, an unknown *boolean* flag before the subcommand may have
88
+ // consumed the subcommand itself as if it were the flag's value (the mirror
89
+ // of the value-collision case). Scan forward for the first recognized
90
+ // subcommand keyword and use it. Over-detection is the safe direction for a
91
+ // guard; under-detection (missing a real write) is the dangerous one (#153).
92
+ // The downstream guards still gate on the verb, so a stray keyword token in a
93
+ // non-write command does not become a spurious write.
94
+ if (!WRITE_SUBCOMMANDS.has(sub)) {
95
+ for (let j = start; j < toks.length; j++) {
96
+ if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[j + 1]; break; }
97
+ }
98
+ }
99
+ return { sub, verb };
100
+ }
101
+
102
+ module.exports = { parseGhSubcommand, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };