@marcomigozzi/trailhead 0.16.1 → 0.18.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 (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  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 +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -5,6 +5,8 @@ import { atom, read, update } from 'claude-code'
5
5
  import type { EngineInterface } from 'claude-code'
6
6
 
7
7
  import type { RepoPin } from '../../types'
8
+ import { gitArgv } from './git-env'
9
+ import { parseWorktreeBranches } from './worktree-porcelain'
8
10
  import { sameValue } from './work-state-view'
9
11
 
10
12
  type RepoRead = { root: string; remote: string | null }
@@ -46,13 +48,16 @@ export const nextPin = (current: RepoPin | null, repo: RepoRead | null, toplevel
46
48
  }
47
49
 
48
50
  // The pinned checkout as the trailing word of a verb's args: `--in=` plus base64url of the root (alphabet
49
- // [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word. The verb
50
- // decodes it with templates/trailhead-checkout-in.js and moves there first (#312).
51
+ // [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word, then `.` and
52
+ // base64url of `host/owner/name` (`.` is outside the alphabet, so it separates unambiguously). The verb decodes it
53
+ // with templates/trailhead-checkout-in.js and moves there first; when the root is gone, the repo part lets the
54
+ // helper find the checkout by its remote (#312, #329).
51
55
  // No Buffer in the mod's runtime: UTF-8 bytes through btoa, then the url-safe alphabet.
52
- export const checkoutArg = (pin: RepoPin): string => {
53
- const binary = Array.from(new TextEncoder().encode(pin.root), byte => String.fromCharCode(byte)).join('')
54
- return `--in=${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
56
+ const base64url = (text: string): string => {
57
+ const binary = Array.from(new TextEncoder().encode(text), byte => String.fromCharCode(byte)).join('')
58
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
55
59
  }
60
+ export const checkoutArg = (pin: RepoPin): string => `--in=${base64url(pin.root)}.${base64url(pinKey(pin))}`
56
61
 
57
62
  // A repo-relative path under the pinned root, absolute whatever the session cwd is.
58
63
  export const pinPath = (pin: RepoPin, relative: string): string => `${pin.root.replace(/\/+$/, '')}/${relative}`
@@ -68,7 +73,7 @@ let remembered: RepoPin | null = null
68
73
  // the caller falls back.
69
74
  export async function toplevelOf($: EngineInterface): Promise<string | null> {
70
75
  try {
71
- const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd(), timeoutMs: 5_000 })
76
+ const out = await $.process.run(gitArgv('rev-parse', '--show-toplevel'), { cwd: await $.session.cwd(), timeoutMs: 5_000 })
72
77
  const top = out.stdout.trim()
73
78
  return out.exitCode === 0 && top.startsWith('/') ? top : null
74
79
  } catch {
@@ -92,9 +97,9 @@ async function gitRepo($: EngineInterface): Promise<{ repo: RepoRead; toplevel:
92
97
  try {
93
98
  const cwd = await $.session.cwd()
94
99
  const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 5_000 })
95
- const top = await run(['git', 'rev-parse', '--show-toplevel'])
100
+ const top = await run(gitArgv('rev-parse', '--show-toplevel'))
96
101
  if (repoFromGit(top, null) === null) return null
97
- const repo = repoFromGit(top, await run(['git', 'remote', 'get-url', 'origin']))
102
+ const repo = repoFromGit(top, await run(gitArgv('remote', 'get-url', 'origin')))
98
103
  return repo === null ? null : { repo, toplevel: repo.root }
99
104
  } catch {
100
105
  return null
@@ -136,10 +141,107 @@ async function isServed($: EngineInterface, host: string, cwd: string): Promise<
136
141
  }
137
142
  }
138
143
 
144
+ // The pinned checkout deleted under the session (a finished worktree, a removed clone): the pin moves to a live
145
+ // checkout of the same repo (#347). Preferred in order: the main working tree, another worktree, the sibling
146
+ // clone itself. Candidates are the repo's root as the host knows it and the folders beside the dead root; every
147
+ // probe is `git -C <path>` from `/`, never a spawn in the dead directory. null when nothing is left.
148
+ const MAX_SIBLINGS = 100
149
+ const parentOf = (path: string): string => path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
150
+
151
+ async function checkoutOf($: EngineInterface, path: string, key: string): Promise<string | null> {
152
+ try {
153
+ const run = (args: string[]) => $.process.run(gitArgv('-C', path, ...args), { cwd: '/', timeoutMs: 5_000 })
154
+ const top = await run(['rev-parse', '--show-toplevel'])
155
+ if (top.exitCode !== 0 || !top.stdout.trim().startsWith('/')) return null
156
+ const origin = await run(['remote', 'get-url', 'origin'])
157
+ const id = remoteId(origin.exitCode === 0 ? origin.stdout : null)
158
+ return id !== null && `${id.host}/${id.slug}` === key ? top.stdout.trim() : null
159
+ } catch {
160
+ return null
161
+ }
162
+ }
163
+
164
+ async function exists($: EngineInterface, path: string): Promise<boolean> {
165
+ try {
166
+ return await $.fs.exists(path)
167
+ } catch {
168
+ return true // unreadable: do not move the pin on a guess
169
+ }
170
+ }
171
+
172
+ async function siblingsOf($: EngineInterface, dir: string): Promise<string[]> {
173
+ try {
174
+ const entries = await $.fs.list(dir)
175
+ return entries.filter(e => e.kind === 'dir' && !e.name.startsWith('.')).slice(0, MAX_SIBLINGS).map(e => `${dir.replace(/\/+$/, '')}/${e.name}`)
176
+ } catch {
177
+ return []
178
+ }
179
+ }
180
+
181
+ async function liveCheckout($: EngineInterface, pin: RepoPin): Promise<string | null> {
182
+ const key = pinKey(pin)
183
+ const seeds: string[] = []
184
+ try {
185
+ const host = await $.session.repo()
186
+ if (host !== null && host.root !== pin.root) seeds.push(host.root)
187
+ } catch {
188
+ // The host read failed: the neighbours still answer.
189
+ }
190
+ seeds.push(...(await siblingsOf($, parentOf(pin.root))))
191
+ for (const seed of seeds) {
192
+ if (seed === pin.root || !(await exists($, seed))) continue
193
+ const top = await checkoutOf($, seed, key)
194
+ if (top === null) continue
195
+ // The seed's own worktree list names the main tree first, then the others.
196
+ const listed = await $.process.run(gitArgv('-C', top, 'worktree', 'list', '--porcelain'), { cwd: '/', timeoutMs: 5_000 }).catch(() => null)
197
+ const paths = listed !== null && listed.exitCode === 0 ? parseWorktreeBranches(listed.stdout).map(w => w.path) : []
198
+ for (const path of [...paths, top]) {
199
+ if (path === pin.root || !(await exists($, path))) continue
200
+ const checkout = path === top ? top : await checkoutOf($, path, key)
201
+ if (checkout !== null) return checkout
202
+ }
203
+ }
204
+ return null
205
+ }
206
+
207
+ // The pin as it stands, or the live checkout of its repo when its root is gone. Both outcomes are cached per
208
+ // repo and dead root, since the host's memoized repo keeps naming the dead root and every refresh asks again: a hit
209
+ // is revalidated by one exists check, a miss is not searched again within RELOCATE_RETRY_MS (a search spawns git
210
+ // per sibling). Reset at a session start.
211
+ const RELOCATE_RETRY_MS = 60_000
212
+ const missed = new Map<string, number>()
213
+ const relocated = new Map<string, string>()
214
+ const relocationKey = (pin: RepoPin): string => `${pinKey(pin)}|${pin.root}`
215
+
216
+ export async function livePin($: EngineInterface, pin: RepoPin): Promise<RepoPin> {
217
+ if (await exists($, pin.root)) return pin
218
+ const key = relocationKey(pin)
219
+ const known = relocated.get(key)
220
+ if (known !== undefined) {
221
+ if (await exists($, known)) return { ...pin, root: known }
222
+ relocated.delete(key)
223
+ }
224
+ const now = await $.clock.now().catch(() => null)
225
+ const last = missed.get(key)
226
+ if (now !== null && last !== undefined && now - last < RELOCATE_RETRY_MS) return pin
227
+ const root = await liveCheckout($, pin).catch(() => null)
228
+ if (root === null) {
229
+ if (now !== null) missed.set(key, now)
230
+ return pin
231
+ }
232
+ missed.delete(key)
233
+ relocated.set(key, root)
234
+ return { ...pin, root }
235
+ }
236
+
139
237
  // Checks the working copy now and pins what it finds; a failed read keeps the last pin. The host's
140
238
  // `session.repo()` can stay null for a repo created mid-session, so git in the cwd answers then (#299). A session
141
239
  // start begins from nothing: the pin of an earlier session is not this one's.
142
240
  export async function refreshPin($: EngineInterface, init: { isStart?: boolean; onMove?: (pin: RepoPin | null) => Promise<void> } = {}): Promise<RepoPin | null> {
241
+ if (init.isStart === true) {
242
+ missed.clear()
243
+ relocated.clear()
244
+ }
143
245
  const stored = await read($, repoPin)
144
246
  const current = init.isStart === true ? null : (stored ?? remembered)
145
247
  let repo: RepoRead | null = null
@@ -156,7 +258,9 @@ export async function refreshPin($: EngineInterface, init: { isStart?: boolean;
156
258
  } catch {
157
259
  // Unreadable repo: keep the last pin.
158
260
  }
159
- const pin = nextPin(current, repo, toplevel)
261
+ const next = nextPin(current, repo, toplevel)
262
+ // The pin, kept or freshly read (the host's memoized repo can keep naming a deleted root), may be gone.
263
+ const pin = next === null ? null : await livePin($, next)
160
264
  remembered = pin
161
265
  if (!sameValue(stored, pin)) {
162
266
  // Right before the atom changes, whoever moves it, so what was read for the old pin goes no later than the change.
@@ -3,6 +3,7 @@
3
3
  // may sit in the main checkout. Pure parts first, then the engine part that looks the state up.
4
4
  import type { EngineInterface } from 'claude-code'
5
5
 
6
+ import { gitArgv } from './git-env'
6
7
  import { isMissing, parseWorkState } from './work-state-view'
7
8
  import { MAX_FAILURES, parseWorktreeBranches } from './worktree-porcelain'
8
9
 
@@ -91,7 +92,7 @@ async function nowOf($: EngineInterface): Promise<number | null> {
91
92
  // null: the listing itself failed (not the same as an empty one). Path and branch of each checkout.
92
93
  export async function listWorktreeBranches($: EngineInterface, root: string): Promise<{ path: string; branch: string | null }[] | null> {
93
94
  try {
94
- const out = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root, timeoutMs: LOOKUP_TIMEOUT_MS })
95
+ const out = await $.process.run(gitArgv('worktree', 'list', '--porcelain'), { cwd: root, timeoutMs: LOOKUP_TIMEOUT_MS })
95
96
  return out.exitCode === 0 ? parseWorktreeBranches(out.stdout) : null
96
97
  } catch {
97
98
  return null
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // trailhead-bash-guards.js: PreToolUse(Bash) hook.
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
3
+ // One process for the seven Bash guards (commit, secret, install, search, body, labels, test)
4
+ // instead of seven: 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, labels).
6
+ // evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels, test).
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 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
14
+ // - --guards=<name,...> picks the guards (default: all seven). The Codex install
15
+ // ships no search-guard and registers --guards=commit,secret,install,body,labels,test.
16
+ // A missing, empty, malformed or unknown-name list falls back to all seven, 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', 'labels'];
25
+ const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test'];
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 six.
40
+ // well-formed list of known names means all seven.
41
41
  function parseGuardsArg(argv) {
42
42
  const arg = argv.find((a) => a.startsWith('--guards='));
43
43
  if (!arg) return GUARD_ORDER;
@@ -19,7 +19,7 @@ const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected
19
19
  // Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
20
20
  function runHook(command, { env = {}, args = [], hook = HOOK, cwd } = {}) {
21
21
  const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
22
- const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
22
+ const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, CLAUDE_CONFIG_DIR: NO_GLOBAL, ...env } });
23
23
  return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
24
24
  }
25
25
  const codeOf = (r) => (r.out ? JSON.parse(r.out).code : undefined);
@@ -31,9 +31,20 @@ 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
33
  const BAD_LABELS = 'gh issue create --title x --body y --label trailhead:bug';
34
+ const BAD_TEST = 'npm test 2>&1 | tail -20';
35
+
36
+ // A scratch trailhead repo whose config sets testing.full, and an empty global config dir so
37
+ // the developer's own config never leaks into the probes.
38
+ const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-test-guard-'));
39
+ const TEST_REPO = path.join(SCRATCH, 'repo');
40
+ fs.mkdirSync(path.join(TEST_REPO, '.git'), { recursive: true });
41
+ fs.mkdirSync(path.join(TEST_REPO, '.trailhead'));
42
+ fs.writeFileSync(path.join(TEST_REPO, '.trailhead', 'config.json'), JSON.stringify({ testing: { full: 'npm test' } }));
43
+ const NO_GLOBAL = path.join(SCRATCH, 'no-global');
44
+ fs.mkdirSync(NO_GLOBAL);
34
45
 
35
46
  // --- unit: constants ---
36
- eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels']);
47
+ eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test']);
37
48
  ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
38
49
 
39
50
  // --- unit: parseModGuards ---
@@ -49,10 +60,10 @@ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit
49
60
  eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
50
61
 
51
62
  // --- unit: parseGuardsArg ---
52
- eq('no --guards -> all six', parseGuardsArg([]), GUARD_ORDER);
63
+ eq('no --guards -> all seven', parseGuardsArg([]), GUARD_ORDER);
53
64
  eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
54
- eq('empty --guards -> all six', parseGuardsArg(['--guards=']), GUARD_ORDER);
55
- eq('malformed --guards -> all six', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
65
+ eq('empty --guards -> all seven', parseGuardsArg(['--guards=']), GUARD_ORDER);
66
+ eq('malformed --guards -> all seven', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
56
67
  eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
57
68
 
58
69
  // --- unit: runGuards with fake evaluators ---
@@ -66,7 +77,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
66
77
  evaluators: {
67
78
  commit: spy('commit', () => { throw new Error('boom'); }),
68
79
  secret: spy('secret', allow), install: spy('install', blocker('I')),
69
- search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow),
80
+ search: spy('search', blocker('S')), body: spy('body', allow), labels: spy('labels', allow), test: spy('test', allow),
70
81
  },
71
82
  });
72
83
  eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
@@ -76,7 +87,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
76
87
  {
77
88
  const r = runGuards('x', {
78
89
  now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
79
- evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow },
90
+ evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow, labels: allow, test: allow },
80
91
  });
81
92
  eq('a fresh stamp skips the listed guard', r.code, 0);
82
93
  }
@@ -84,7 +95,7 @@ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block',
84
95
  const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
85
96
  const r = runGuards('x', {
86
97
  now: NOW, env: {},
87
- evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow },
98
+ evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow, labels: allow, test: allow },
88
99
  });
89
100
  eq('advisories exit 0', r.code, 0);
90
101
  eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
@@ -98,11 +109,13 @@ const { evaluate: evInstall } = require('./trailhead-install-guard.js');
98
109
  const { evaluate: evSearch } = require('./trailhead-search-guard.js');
99
110
  const { evaluate: evBody } = require('./trailhead-body-guard.js');
100
111
  const { evaluate: evLabels } = require('./trailhead-labels-guard.js');
112
+ const { evaluate: evTest } = require('./trailhead-test-guard.js');
101
113
  for (const [name, cmd, ev] of [
102
114
  ['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
103
115
  ['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody], ['labels', BAD_LABELS, evLabels],
116
+ ['test', BAD_TEST, (c) => evTest(c, { cwd: TEST_REPO, env: { CLAUDE_CONFIG_DIR: NO_GLOBAL } })],
104
117
  ]) {
105
- const r = runHook(cmd);
118
+ const r = runHook(cmd, name === 'test' ? { cwd: TEST_REPO } : {});
106
119
  const want = ev(cmd);
107
120
  ok(`${name}: evaluator blocks the probe`, want.kind === 'block');
108
121
  eq(`${name}: exit 2`, r.code, 2);
@@ -171,19 +184,19 @@ for (const [name, cmd, ev] of [
171
184
  const hook = path.join(tmp, 'trailhead-bash-guards.js');
172
185
  fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
173
186
 
174
- const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels'] });
187
+ const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body,labels,test'] });
175
188
  eq('--guards without search never loads search-guard', codex.code, 0);
176
189
  eq('--guards without search: no warning', codex.err, '');
177
- const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels'] });
190
+ const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body,labels,test'] });
178
191
  eq('--guards subset still blocks with its guards', still.code, 2);
179
192
  const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
180
193
  eq('unselected guards do not run', only.code, 0);
181
194
 
182
195
  for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
183
196
  const r = runHook(BAD_INSTALL, { hook, args: [bad] });
184
- eq(`${bad}: falls back to all six (install blocks)`, r.code, 2);
197
+ eq(`${bad}: falls back to all seven (install blocks)`, r.code, 2);
185
198
  const clean = runHook('git status', { hook, args: [bad] });
186
- ok(`${bad}: all six are selected (the missing search module is reported)`, /search/.test(clean.err));
199
+ ok(`${bad}: all seven are selected (the missing search module is reported)`, /search/.test(clean.err));
187
200
  }
188
201
 
189
202
  const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
@@ -217,13 +230,14 @@ for (const [name, cmd, ev] of [
217
230
  // Run from a scratch dir outside any trailhead repo: those suites open unlabelled issues, which
218
231
  // the labels guard (rightly) blocks inside a trailhead repo.
219
232
  const SUITE_CWD = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-suites-'));
220
- for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels']) {
233
+ for (const name of ['commit', 'secret', 'install', 'search', 'body', 'labels', 'test']) {
221
234
  const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
222
235
  const r = spawnSync('node', [file], { cwd: SUITE_CWD, env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
223
236
  if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
224
237
  eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
225
238
  }
226
239
  fs.rmSync(SUITE_CWD, { recursive: true, force: true });
240
+ fs.rmSync(SCRATCH, { recursive: true, force: true });
227
241
 
228
242
  // --- registration: one Bash entry, one command (the merged hook) ---
229
243
  {
@@ -11,6 +11,8 @@ const fs = require('fs');
11
11
  const os = require('os');
12
12
  const path = require('path');
13
13
  const { execFileSync } = require('child_process');
14
+ // Un GIT_DIR ereditato batterebbe -C: le chiamate git non lo seguono (#349).
15
+ const { gitEnv } = require('./lib/trailhead-git-env.js');
14
16
 
15
17
  const REPO = 'ToRvaLDz/trailhead';
16
18
  const NPM_PKG = '@marcomigozzi/trailhead';
@@ -33,7 +35,7 @@ function readJSON(p) {
33
35
  }
34
36
  function run(cmd, args) {
35
37
  try {
36
- return execFileSync(cmd, args, { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
38
+ return execFileSync(cmd, args, { encoding: 'utf8', env: gitEnv(), timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
39
  } catch { return ''; }
38
40
  }
39
41
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
@@ -186,6 +188,21 @@ function labelGuardNotice(cwd) {
186
188
  }
187
189
  }
188
190
 
191
+ // Preflight dell'auth di gh (#328): un GH_TOKEN/GITHUB_TOKEN non valido che
192
+ // oscura il login gh funzionante fa fallire ogni chiamata gh; lo segnaliamo
193
+ // una volta, all'avvio (la lib salta resume e compact). Come reviewNotice: il
194
+ // require e' nel try, ogni errore -> null.
195
+ function authNotice(input) {
196
+ try {
197
+ return require('./lib/gh-auth-preflight.js').check({
198
+ env: process.env,
199
+ source: input && typeof input.source === 'string' ? input.source : undefined,
200
+ }) || null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
189
206
  // Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
190
207
  // gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
191
208
  // un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
@@ -212,11 +229,12 @@ function readInput(done) {
212
229
  function main(input) {
213
230
  const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
214
231
 
232
+ const auth = authNotice(input);
215
233
  const review = reviewNotice(cwd);
216
234
  const guard = labelGuardNotice(cwd);
217
235
  const update = updateNotice();
218
236
 
219
- const parts = [review, guard, update].filter(Boolean);
237
+ const parts = [auth, review, guard, update].filter(Boolean);
220
238
  if (!parts.length) return;
221
239
 
222
240
  process.stdout.write(JSON.stringify({
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Test dell'hook SessionStart trailhead-check-update.js (notice auth di gh).
3
+ // Run: node trailhead-check-update.test.js
4
+ // Un gh finto in una bin dir tmp (prima nel PATH) applica la precedenza reale.
5
+ const assert = require('assert');
6
+ const { execFileSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ let passed = 0;
12
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
13
+
14
+ const hookPath = path.join(__dirname, 'trailhead-check-update.js');
15
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-check-update-test-'));
16
+ const bin = path.join(tmp, 'bin');
17
+ fs.mkdirSync(bin);
18
+ const fakeGh = `#!${process.execPath}
19
+ const a = process.argv.slice(2);
20
+ if (a[0] !== 'api' || a[1] !== 'user') process.exit(1); // altre chiamate (update check): innocue
21
+ const t = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
22
+ const v = t ? t : (process.env.FAKE_GH_STORED || 'bad');
23
+ if (v === 'good') { console.log('octo'); process.exit(0); }
24
+ console.log('{"message":"Bad credentials","status":"401"}');
25
+ console.error('gh: Bad credentials (HTTP 401)');
26
+ process.exit(1);
27
+ `;
28
+ fs.writeFileSync(path.join(bin, 'gh'), fakeGh, { mode: 0o755 });
29
+
30
+ const repo = path.join(tmp, 'repo');
31
+ execFileSync('git', ['init', '-q', repo]);
32
+
33
+ function run(source, extraEnv) {
34
+ const env = { ...process.env, ...extraEnv };
35
+ delete env.GH_TOKEN; delete env.GITHUB_TOKEN;
36
+ Object.assign(env, extraEnv, {
37
+ PATH: `${bin}${path.delimiter}${process.env.PATH}`,
38
+ XDG_CACHE_HOME: path.join(tmp, 'cache'),
39
+ CLAUDE_CONFIG_DIR: path.join(tmp, 'cfg'),
40
+ });
41
+ const input = { cwd: repo };
42
+ if (source) input.source = source;
43
+ const out = execFileSync('node', [hookPath], { input: JSON.stringify(input), encoding: 'utf8', env });
44
+ return out ? JSON.parse(out).hookSpecificOutput.additionalContext : '';
45
+ }
46
+ const AUTH = 'GitHub rejects the';
47
+
48
+ for (const source of ['startup', 'clear', undefined]) {
49
+ const ctx = run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' });
50
+ ok(`source ${source}: auth notice surfaced`, ctx.includes(`${AUTH} GITHUB_TOKEN`) && ctx.includes('GITHUB_TOKEN= gh'));
51
+ ok(`source ${source}: token value not leaked`, !ctx.includes('bad'));
52
+ }
53
+ for (const source of ['resume', 'compact']) {
54
+ ok(`source ${source}: no auth notice`, !run(source, { GITHUB_TOKEN: 'bad', FAKE_GH_STORED: 'good' }).includes(AUTH));
55
+ }
56
+ ok('no token var: no auth notice', !run('startup', { FAKE_GH_STORED: 'good' }).includes(AUTH));
57
+ ok('valid token: no auth notice', !run('startup', { GITHUB_TOKEN: 'good' }).includes(AUTH));
58
+
59
+ fs.rmSync(tmp, { recursive: true, force: true });
60
+ console.log(`trailhead-check-update: ${passed} passed`);
@@ -10,7 +10,7 @@
10
10
  // prototype | build | bug | task, and exactly one of `trailhead:map-<n>` |
11
11
  // `trailhead:whiteboard` (never both, never two maps). Non-ticket trailhead issues
12
12
  // (`trailhead:map`, `trailhead:dashboard`, `trailhead:codebase`,
13
- // `trailhead:conventions`, and `trailhead:fog` / `trailhead:unverified`) are exempt.
13
+ // `trailhead:conventions`, and `trailhead:fog` / `trailhead:needs-info` / `trailhead:unverified`) are exempt.
14
14
  // State labels (blocked, seed, paused, ...) are allowed extras and never count
15
15
  // toward the shape. This rule applies in any repo.
16
16
  // - A create with NO `trailhead:*` label is blocked too, but only inside a trailhead
@@ -36,7 +36,7 @@ const { words, parseRedirects } = require('./lib/body-sources.js');
36
36
 
37
37
  const PLAIN_SENTINEL = /(?:^|\s)TRAILHEAD_PLAIN_ISSUE=1(?:\s|$)/;
38
38
  const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
39
- const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified'];
39
+ const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:needs-info', 'trailhead:unverified'];
40
40
  const MAP_RE = /^trailhead:map-\d+$/;
41
41
  const WHITEBOARD = 'trailhead:whiteboard';
42
42
  const TYPE_LIST = TYPES.map((t) => `trailhead:${t}`).join(' | ');
@@ -104,7 +104,7 @@ ok('a seed without type is blocked', blocked(create(['trailhead:ticket', 'trailh
104
104
  }
105
105
 
106
106
  // --- non-ticket trailhead issues are exempt ---
107
- for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified']) {
107
+ for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:needs-info', 'trailhead:unverified']) {
108
108
  ok(`${l} create passes`, !blocked(create([l]), TH));
109
109
  }
110
110
  ok('map issue with extra labels passes', !blocked(create(['trailhead:map', 'trailhead:paused']), TH));