@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
@@ -1,6 +1,7 @@
1
1
  // Pure view logic of the trailhead mod: parse the work state file, build the
2
2
  // status line and the pipeline stages. No engine calls here, so it is easy to test.
3
- import type { WorkState } from '../../types'
3
+ import type { AutoRun, RunCounts, WorkState } from '../../types'
4
+ import { RESUME_HINT_STATUS, runLabel, runParts } from './auto-run-view'
4
5
 
5
6
  export const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'] as const
6
7
  export const STAGES = ['discuss', 'plan', 'execute', 'verify'] as const
@@ -11,6 +12,7 @@ export type Stage = { name: StageName; status: StageStatus }
11
12
  export type Pipeline = { stages: readonly Stage[]; isPaused: boolean }
12
13
 
13
14
  const ELLIPSIS = '…'
15
+ export const ELSEWHERE = 'in another session'
14
16
 
15
17
  // Cuts by code point, so a surrogate pair (an emoji in an issue title) is never split.
16
18
  export const fit = (text: string, room: number): string => {
@@ -66,17 +68,48 @@ const stableJson = (value: unknown): string => {
66
68
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`
67
69
  }
68
70
 
71
+ // The state belongs to this session (a state with no session counts as its own, like the orphan rule).
72
+ const isOwn = (state: WorkState | null, session: string): boolean =>
73
+ state !== null && (state.session === null || state.session === session)
74
+
75
+ // The ticket this session owns across a work-state file shared with other sessions. A state it wrote
76
+ // sets it; one another session wrote leaves it; a missing file clears it only when the last state read
77
+ // was this session's (its own Resolve), so another session's cleanup never drops the mark.
78
+ export const nextOwnTicket = (
79
+ prev: number | null,
80
+ lastState: WorkState | null,
81
+ nextState: WorkState | null,
82
+ session: string,
83
+ ): number | null => {
84
+ if (nextState !== null) return isOwn(nextState, session) ? nextState.ticket : prev
85
+ return isOwn(lastState, session) ? null : prev
86
+ }
87
+
69
88
  export const sameValue = (a: unknown, b: unknown): boolean => stableJson(a) === stableJson(b)
70
89
 
71
- export const statusLine = (state: WorkState | null, isOrphaned: boolean): string | undefined => {
72
- if (state === null || state.ticket === null) return undefined
90
+ // What the autonomous run adds to the status: the run found beside the main checkout, its scope counts, and
91
+ // whether the resume hint shows. `elsewhere`: the foreign state's session is not known gone, so the
92
+ // status words it neutrally instead of offering to resume it.
93
+ export type RunInfo = { run: AutoRun | null; counts: RunCounts | null; hint: boolean; elsewhere?: boolean }
94
+
95
+ export const statusLine = (state: WorkState | null, isOrphaned: boolean, info?: RunInfo): string | undefined => {
96
+ const hint = info?.hint === true ? RESUME_HINT_STATUS : null
97
+ if (state === null || state.ticket === null) {
98
+ // Nessuno stato proprio: il run (slot senza sessione) o il suggerimento di ripresa, se ce ne sono.
99
+ if (hint !== null) return hint
100
+ return info?.run == null ? undefined : runLabel(info.run, info.counts)
101
+ }
73
102
  const where = state.map === null ? 'whiteboard' : `map #${state.map}`
74
103
  const ticket = state.title === null ? `#${state.ticket}` : `#${state.ticket} ${state.title}`
75
104
  // No own "trailhead" prefix: the engine already prefixes a plugin's status with its name.
76
105
  const parts = [where, ticket]
77
106
  if (state.step !== null) parts.push(state.step)
78
- if (state.auto) parts.push(`auto ${state.autoDone} done`)
79
- if (isOrphaned) parts.push('orphaned, resume?')
107
+ if (state.auto) {
108
+ const [first, ...rest] = runParts(info?.run ?? { done: state.autoDone }, info?.counts ?? null)
109
+ parts.push(`auto ${first}`, ...rest)
110
+ }
111
+ if (isOrphaned) parts.push(info?.elsewhere === true ? ELSEWHERE : 'orphaned, resume?')
112
+ if (hint !== null) parts.push(hint)
80
113
  return parts.join(' · ')
81
114
  }
82
115
 
@@ -0,0 +1,17 @@
1
+ // Foglia senza import, condivisa da work-state-locate e dal run autonomo: il parser di
2
+ // `git worktree list --porcelain` e il tetto dei fallimenti di fila.
3
+
4
+ // Quanti fallimenti di fila tengono l'ultimo risultato noto prima di azzerarlo.
5
+ export const MAX_FAILURES = 3
6
+
7
+ // Path and full branch ref of each checkout in `git worktree list --porcelain` (null: detached or bare).
8
+ export const parseWorktreeBranches = (porcelain: string): { path: string; branch: string | null }[] =>
9
+ porcelain
10
+ .split(/\n\s*\n/)
11
+ .map(block => block.split('\n'))
12
+ .filter(lines => lines.some(line => line.startsWith('worktree /')))
13
+ .map(lines => {
14
+ const path = (lines.find(line => line.startsWith('worktree ')) as string).slice('worktree '.length).replace(/(.)\/+$/, '$1')
15
+ const branch = lines.find(line => line.startsWith('branch '))
16
+ return { path, branch: branch === undefined ? null : branch.slice('branch '.length) }
17
+ })
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // trailhead-bash-guards.js: PreToolUse(Bash) hook.
3
- // One process for the five Bash guards (commit, secret, install, search, body)
4
- // instead of five: each Bash call pays a single node start-up. The guards keep
3
+ // One process for the six Bash guards (commit, secret, install, search, body, labels)
4
+ // instead of six: each Bash call pays a single node start-up. The guards keep
5
5
  // their own modules and verdicts; this runner only calls each one's pure
6
- // evaluate(cmd) in a fixed order (commit, secret, install, search, body).
6
+ // evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels).
7
7
  // - First block wins: its payload goes to stdout and the hook exits 2; later
8
8
  // guards do not run. An earlier advisory keeps its stderr line.
9
9
  // - No block: advisories (secret-guard's "could not scan") surface on stderr
@@ -11,9 +11,9 @@
11
11
  // - Failure posture is unchanged per guard: a throwing guard counts as allow
12
12
  // and the others still run; a guard module that fails to load is skipped
13
13
  // with a one-line stderr warning.
14
- // - --guards=<name,...> picks the guards (default: all five). The Codex install
15
- // ships no search-guard and registers --guards=commit,secret,install,body.
16
- // A missing, empty, malformed or unknown-name list falls back to all five, so
14
+ // - --guards=<name,...> picks the guards (default: all six). The Codex install
15
+ // ships no search-guard and registers --guards=commit,secret,install,body,labels.
16
+ // A missing, empty, malformed or unknown-name list falls back to all six, so
17
17
  // a config typo never disables a guard.
18
18
  // - TRAILHEAD_MOD_GUARDS=<name,...>@<epoch-ms>: the mod (which has ported those
19
19
  // guards) stamps the guards it already ran; a stamp no older than
@@ -22,7 +22,7 @@
22
22
  // Crash-safe: unparseable input or any internal error -> exit 0.
23
23
  // Self-contained, no dependencies. Pure functions are exported for tests.
24
24
 
25
- const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body'];
25
+ const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels'];
26
26
  const MOD_GUARDS_FRESH_MS = 5000;
27
27
  const MOD_GUARDS_RE = /^([a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*)@(\d+)$/;
28
28
 
@@ -37,7 +37,7 @@ function parseModGuards(value, now = Date.now()) {
37
37
  }
38
38
 
39
39
  // Guards selected by --guards=<name,...>, in canonical order. Anything but a
40
- // well-formed list of known names means all five.
40
+ // well-formed list of known names means all six.
41
41
  function parseGuardsArg(argv) {
42
42
  const arg = argv.find((a) => a.startsWith('--guards='));
43
43
  if (!arg) return GUARD_ORDER;
@@ -17,8 +17,8 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
17
17
  const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected, name); passed++; };
18
18
 
19
19
  // Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
20
- function runHook(command, { env = {}, args = [], hook = HOOK } = {}) {
21
- const input = JSON.stringify({ tool_input: { command } });
20
+ function runHook(command, { env = {}, args = [], hook = HOOK, cwd } = {}) {
21
+ const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
22
22
  const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
23
23
  return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
24
24
  }
@@ -30,9 +30,10 @@ const BAD_SECRET = 'gh issue comment 5 --body "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012
30
30
  const BAD_INSTALL = 'npm install left-pad';
31
31
  const BAD_SEARCH = 'cd x && grep y z';
32
32
  const BAD_BODY = 'gh issue edit 5 --body ""';
33
+ const BAD_LABELS = 'gh issue create --title x --body y --label trailhead:bug';
33
34
 
34
35
  // --- unit: constants ---
35
- eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body']);
36
+ eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels']);
36
37
  ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
37
38
 
38
39
  // --- unit: parseModGuards ---
@@ -48,10 +49,10 @@ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit
48
49
  eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
49
50
 
50
51
  // --- unit: parseGuardsArg ---
51
- eq('no --guards -> all five', parseGuardsArg([]), GUARD_ORDER);
52
+ eq('no --guards -> all six', parseGuardsArg([]), GUARD_ORDER);
52
53
  eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
53
- eq('empty --guards -> all five', parseGuardsArg(['--guards=']), GUARD_ORDER);
54
- eq('malformed --guards -> all five', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
54
+ eq('empty --guards -> all six', parseGuardsArg(['--guards=']), GUARD_ORDER);
55
+ eq('malformed --guards -> all six', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
55
56
  eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
56
57
 
57
58
  // --- unit: runGuards with fake evaluators ---
@@ -65,7 +66,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
65
66
  evaluators: {
66
67
  commit: spy('commit', () => { throw new Error('boom'); }),
67
68
  secret: spy('secret', allow), install: spy('install', blocker('I')),
68
- search: spy('search', blocker('S')), body: spy('body', allow),
69
+ search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow),
69
70
  },
70
71
  });
71
72
  eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
@@ -75,7 +76,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
75
76
  {
76
77
  const r = runGuards('x', {
77
78
  now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
78
- evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow },
79
+ evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow },
79
80
  });
80
81
  eq('a fresh stamp skips the listed guard', r.code, 0);
81
82
  }
@@ -83,7 +84,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
83
84
  const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
84
85
  const r = runGuards('x', {
85
86
  now: NOW, env: {},
86
- evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow },
87
+ evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow },
87
88
  });
88
89
  eq('advisories exit 0', r.code, 0);
89
90
  eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
@@ -96,9 +97,10 @@ const { evaluate: evSecret } = require('./trailhead-secret-guard.js');
96
97
  const { evaluate: evInstall } = require('./trailhead-install-guard.js');
97
98
  const { evaluate: evSearch } = require('./trailhead-search-guard.js');
98
99
  const { evaluate: evBody } = require('./trailhead-body-guard.js');
100
+ const { evaluate: evLabels } = require('./trailhead-labels-guard.js');
99
101
  for (const [name, cmd, ev] of [
100
102
  ['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
101
- ['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody],
103
+ ['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody], ['labels', BAD_LABELS, evLabels],
102
104
  ]) {
103
105
  const r = runHook(cmd);
104
106
  const want = ev(cmd);
@@ -169,19 +171,19 @@ for (const [name, cmd, ev] of [
169
171
  const hook = path.join(tmp, 'trailhead-bash-guards.js');
170
172
  fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
171
173
 
172
- const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body'] });
174
+ const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels'] });
173
175
  eq('--guards without search never loads search-guard', codex.code, 0);
174
176
  eq('--guards without search: no warning', codex.err, '');
175
- const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body'] });
177
+ const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels'] });
176
178
  eq('--guards subset still blocks with its guards', still.code, 2);
177
179
  const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
178
180
  eq('unselected guards do not run', only.code, 0);
179
181
 
180
182
  for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
181
183
  const r = runHook(BAD_INSTALL, { hook, args: [bad] });
182
- eq(`${bad}: falls back to all five (install blocks)`, r.code, 2);
184
+ eq(`${bad}: falls back to all six (install blocks)`, r.code, 2);
183
185
  const clean = runHook('git status', { hook, args: [bad] });
184
- ok(`${bad}: all five are selected (the missing search module is reported)`, /search/.test(clean.err));
186
+ ok(`${bad}: all six are selected (the missing search module is reported)`, /search/.test(clean.err));
185
187
  }
186
188
 
187
189
  const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
@@ -212,12 +214,16 @@ for (const [name, cmd, ev] of [
212
214
  }
213
215
 
214
216
  // --- every existing guard suite, re-run through the merged hook ---
215
- for (const name of ['commit', 'secret', 'install', 'search', 'body']) {
217
+ // Run from a scratch dir outside any trailhead repo: those suites open unlabelled issues, which
218
+ // the labels guard (rightly) blocks inside a trailhead repo.
219
+ const SUITE_CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-suites-'));
220
+ for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels']) {
216
221
  const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
217
- const r = spawnSync('node', [file], { env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
222
+ const r = spawnSync('node', [file], { cwd: SUITE_CWD, env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
218
223
  if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
219
224
  eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
220
225
  }
226
+ fs.rmSync(SUITE_CWD, { recursive: true, force: true });
221
227
 
222
228
  // --- registration: one Bash entry, one command (the merged hook) ---
223
229
  {
@@ -8,14 +8,28 @@
8
8
  // package age, download counts, repo metadata) and checkpoint the human on a
9
9
  // suspicious result (block-and-ask). A vetted install proceeds by re-running the
10
10
  // command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
11
- // Only a NAMED install is gated: lockfile/manifest installs (bare `npm install`,
12
- // `npm ci`, `pip install -r`, `cargo build`, `go mod download`, `bundle install`)
13
- // and local paths / URLs / VCS refs carry no typed name and pass through.
11
+ // Only a NAMED install is gated: installs with no typed name (bare `npm install`,
12
+ // `npm ci`, `install --frozen-lockfile` / `--immutable` with no package, `pip install -r`,
13
+ // `cargo build`, `go mod download`, `bundle install`) and local paths / URLs /
14
+ // VCS refs carry no typed name and pass through. A file-like name (`foo.txt`,
15
+ // `x.tgz`) passes only when it is a path (`./x`, `sub/x`, `/x`, `~/x`) or exists
16
+ // on disk (relative to the command's cwd); otherwise it is a typed name and is gated.
17
+ // The on-disk check resolves against the hook's cwd; it does not follow a
18
+ // preceding `cd` in the same command. Command wrappers (`sudo`, `time`, `env`,
19
+ // `nohup`, `xargs`, `timeout`, `nice`, `stdbuf`, `setsid`, `ionice`, `doas`,
20
+ // `command`, `exec`) are peeled, with their own flags, so `sudo npm install foo`
21
+ // is gated like the bare form. Parsing is quote-aware (shared
22
+ // lib/shell-scan.js): text inside quotes or a heredoc (`echo "x; npm i foo"`) is
23
+ // never a command, and redirections (`2>&1`, `> log`) are never package names.
14
24
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
15
25
  // Crash-safe: any error → exit 0 (never wedge the user's workflow).
16
- // Self-contained, no dependencies. Pure functions (incl. evaluate) exported for tests
26
+ // Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
17
27
  // and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
18
28
 
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
32
+
19
33
  const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
20
34
 
21
35
  // Per-ecosystem: the binaries that own it, and the install verbs that fetch a
@@ -37,22 +51,129 @@ const VALUE_FLAGS = new Set([
37
51
  '-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
38
52
  '-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
39
53
  '--no-binary', '--only-binary',
54
+ '--filter', '-F', // pnpm workspace selector: its value is a project, not a package
40
55
  ]);
41
56
 
42
- function basename(p) {
43
- const s = String(p).split('/');
44
- return s[s.length - 1];
57
+ // Command wrappers that run the rest of the line as a command, mapped to the
58
+ // flags that consume a SEPARATE-TOKEN value (so it is not read as the command).
59
+ // Attached forms (`-uroot`, `--user=root`, GNU xargs `-i`/`-e`/`-l`) need no entry.
60
+ const WRAPPERS = {
61
+ sudo: new Set([
62
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
63
+ '-U', '--other-user', '-r', '--role', '-t', '--type', '-D', '--chdir', '-R', '--chroot',
64
+ '-T', '--command-timeout',
65
+ ]),
66
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0']), // -S handled apart
67
+ time: new Set(['-f', '--format', '-o', '--output']),
68
+ nohup: new Set(),
69
+ xargs: new Set([
70
+ '-n', '--max-args', '-I', '-a', '--arg-file', '-d', '--delimiter', '-E', '-L', '--max-lines',
71
+ '-P', '--max-procs', '-s', '--max-chars',
72
+ ]),
73
+ timeout: new Set(['-s', '--signal', '-k', '--kill-after']), // + one DURATION operand
74
+ nice: new Set(['-n', '--adjustment']),
75
+ stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
76
+ setsid: new Set(),
77
+ ionice: new Set(['-c', '--class', '-n', '--classdata']),
78
+ doas: new Set(['-a', '-C', '-u']),
79
+ command: new Set(), // -v / -V only look a name up: see isLookupFlag
80
+ exec: new Set(['-a']),
81
+ };
82
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
83
+
84
+ // How a wrapper flag consumes its value. Returns { next } (value is the NEXT
85
+ // token), { attached } (value is inside this token) or {} (flagless). A short
86
+ // cluster is read like getopt: the first value-taking letter ends it, so `-nu root`
87
+ // takes the next token and `-uroot` carries its value attached.
88
+ function flagValue(f, valueFlags) {
89
+ if (f.startsWith('--')) {
90
+ const eq = f.indexOf('=');
91
+ if (eq !== -1) return { attached: f.slice(eq + 1) };
92
+ return valueFlags.has(f) ? { next: true } : {};
93
+ }
94
+ for (let k = 1; k < f.length; k++) {
95
+ if (!valueFlags.has('-' + f[k])) continue;
96
+ return k === f.length - 1 ? { next: true } : { attached: f.slice(k + 1) };
97
+ }
98
+ return {};
99
+ }
100
+
101
+ // `env -S` / `--split-string`: its payload IS the command line, not a flag value.
102
+ function isSplitFlag(f) {
103
+ if (f.startsWith('--')) return f === '--split-string' || f.startsWith('--split-string=');
104
+ for (let k = 1; k < f.length; k++) {
105
+ if (f[k] === 'S') return true;
106
+ if ('uCa'.includes(f[k])) return false; // value letter first: S is part of its value
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // `command -v` / `-V` (also inside a cluster like `-pv`) only describe a name and
112
+ // execute nothing, so nothing follows that can be an install.
113
+ function isLookupFlag(f) {
114
+ return !f.startsWith('--') && /[vV]/.test(f);
115
+ }
116
+
117
+ // The text of an `env -S` payload token. A double-quoted payload (outer quotes
118
+ // stripped) also loses its bash escapes (`\$`, `\``, `\"`, `\\`); single-quoted
119
+ // text is literal. Local on purpose: the shared stripQuotes is left as is.
120
+ function payloadText(payload) {
121
+ const p = payload || '';
122
+ const stripped = stripQuotes(p);
123
+ const dq = p.length >= 2 && p.startsWith('"') && p.endsWith('"');
124
+ return dq ? stripped.replace(/\\([$`"\\])/g, '$1') : stripped;
125
+ }
126
+
127
+ // The real command, past leading `NAME=value` assignments and any (nested) wrapper
128
+ // with its flags. Returns { toks, i }: toks may be rewritten when an `env -S`
129
+ // payload is re-tokenized; i is toks.length when nothing is left.
130
+ function skipWrappers(input) {
131
+ let toks = input;
132
+ let i = 0;
133
+ for (;;) {
134
+ while (i < toks.length && ENV_ASSIGN.test(toks[i])) i++;
135
+ const wrapper = basename(toks[i] || '');
136
+ const valueFlags = WRAPPERS[wrapper];
137
+ if (!valueFlags) return { toks, i };
138
+ i++; // past the wrapper
139
+ while (i < toks.length && toks[i].startsWith('-')) {
140
+ const f = toks[i++];
141
+ if (f === '--') break; // end of the wrapper's options
142
+ if (wrapper === 'env' && isSplitFlag(f)) {
143
+ const eq = f.indexOf('=');
144
+ const sIdx = f.indexOf('S');
145
+ let payload;
146
+ if (f.startsWith('--')) payload = eq !== -1 ? f.slice(eq + 1) : toks[i++];
147
+ else payload = sIdx === f.length - 1 ? toks[i++] : f.slice(sIdx + 1);
148
+ toks = [...tokenize(payloadText(payload)), ...toks.slice(i)];
149
+ i = 0; // the payload and the rest now form the command line
150
+ break;
151
+ }
152
+ if (wrapper === 'command' && isLookupFlag(f)) return { toks, i: toks.length };
153
+ if (flagValue(f, valueFlags).next) i++; // consume its separate-token value
154
+ }
155
+ if (wrapper === 'timeout' && i < toks.length) i++; // the DURATION operand
156
+ }
157
+ }
158
+
159
+ // A file-like name is local only when it is a path or exists on disk. A leading
160
+ // `@` is a scoped npm name, never a path.
161
+ function isLocalFile(t, opts) {
162
+ if (t.startsWith('@')) return false;
163
+ if (t.includes('/')) return true;
164
+ const exists = (opts && opts.exists) || fs.existsSync;
165
+ return exists(path.resolve((opts && opts.cwd) || process.cwd(), t));
45
166
  }
46
167
 
47
168
  // A positional that is NOT a public-registry name: local path, url, vcs ref, or a
48
169
  // file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
49
170
  // module, so for go only a leading . / ~ disqualifies.
50
- function isLocalOrUrl(t, ecosystem) {
171
+ function isLocalOrUrl(t, ecosystem, opts) {
51
172
  if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
52
173
  if (ecosystem === 'go') return false; // a bare module path is the name; gate it
53
174
  if (t.includes('://')) return true;
54
175
  if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
55
- if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return true;
176
+ if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return isLocalFile(t, opts);
56
177
  return false;
57
178
  }
58
179
 
@@ -70,12 +191,29 @@ function bareName(t) {
70
191
  return n;
71
192
  }
72
193
 
194
+ // Positional package names among the arguments after the verb (redirections
195
+ // already removed). Quotes are stripped so `"lodash"` is the name `lodash`.
196
+ function collectPackages(args, ecosystem, opts) {
197
+ const packages = [];
198
+ for (let j = 0; j < args.length; j++) {
199
+ const t = args[j];
200
+ if (t.startsWith('-')) {
201
+ if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
202
+ continue;
203
+ }
204
+ const name = stripQuotes(t);
205
+ if (!name || name === '&' || isLocalOrUrl(name, ecosystem, opts)) continue;
206
+ const bare = bareName(name);
207
+ if (bare) packages.push(bare);
208
+ }
209
+ return packages;
210
+ }
211
+
73
212
  // Detect a public-registry install that NAMES at least one package, in a single
74
- // shell segment. Returns { ecosystem, packages: [names] } or null.
75
- function detectSegment(cmd) {
76
- const toks = String(cmd).trim().split(/\s+/).filter(Boolean);
77
- let i = 0;
78
- while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
213
+ // shell statement. Returns { ecosystem, packages: [names] } or null.
214
+ function detectSegment(stmt, opts) {
215
+ const { toks, i: start } = skipWrappers(tokenize(stmt));
216
+ let i = start; // env prefix + sudo/time/env/nohup/xargs
79
217
  if (i >= toks.length) return null;
80
218
 
81
219
  const bin = basename(toks[i]);
@@ -102,27 +240,21 @@ function detectSegment(cmd) {
102
240
  if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
103
241
  i++;
104
242
 
105
- const packages = [];
106
- for (let j = i; j < toks.length; j++) {
107
- const t = toks[j];
108
- if (t.startsWith('-')) {
109
- if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
110
- continue;
111
- }
112
- if (isLocalOrUrl(t, ecosystem)) continue;
113
- packages.push(bareName(t));
114
- }
243
+ const args = stripRedirections(toks.slice(i));
244
+ // A lockfile install (`--frozen-lockfile`, `--immutable`) carries no positional,
245
+ // so it yields no package here; a flag never excuses a name typed beside it.
246
+ const packages = collectPackages(args, ecosystem, opts);
115
247
  if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
116
248
  return { ecosystem, packages };
117
249
  }
118
250
 
119
251
  // Agents chain commands (`cd app && npm install foo`), so check every shell
120
- // segment, not just the first. Split on the common separators (&& || ; | and
121
- // newlines); a registry name never contains one, so a stray split is harmless.
122
- // Returns the first segment's hit, or null.
123
- function detectInstall(cmd) {
124
- for (const seg of String(cmd).split(/&&|\|\||;|\n|\|/)) {
125
- const hit = detectSegment(seg);
252
+ // statement, not just the first. splitStatements is quote- and heredoc-aware, so
253
+ // a separator or install text inside quotes / a heredoc body is not a statement.
254
+ // Returns the first statement's hit, or null.
255
+ function detectInstall(cmd, opts) {
256
+ for (const stmt of splitStatements(cmd)) {
257
+ const hit = detectSegment(stmt, opts);
126
258
  if (hit) return hit;
127
259
  }
128
260
  return null;
@@ -147,10 +279,10 @@ function blockVerdict(hit) {
147
279
 
148
280
  // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
149
281
  // exits or writes; any internal error falls through to allow.
150
- function evaluate(cmd) {
282
+ function evaluate(cmd, opts) {
151
283
  try {
152
284
  if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
153
- const hit = detectInstall(cmd);
285
+ const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
154
286
  if (hit) return blockVerdict(hit);
155
287
  } catch {
156
288
  // fall through to allow: a guard must never wedge the workflow
@@ -160,12 +292,15 @@ function evaluate(cmd) {
160
292
 
161
293
  function run(data) {
162
294
  let cmd = '';
295
+ let cwd;
163
296
  try {
164
- cmd = (JSON.parse(data).tool_input || {}).command || '';
297
+ const input = JSON.parse(data);
298
+ cmd = (input.tool_input || {}).command || '';
299
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
165
300
  } catch {
166
301
  process.exit(0); // unparseable input: nothing to gate
167
302
  }
168
- const v = evaluate(cmd);
303
+ const v = evaluate(cmd, { cwd });
169
304
  if (v.kind === 'block') {
170
305
  process.stdout.write(JSON.stringify(v.payload));
171
306
  process.exit(2);