@marcomigozzi/trailhead 0.17.0 → 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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -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 }
@@ -71,7 +73,7 @@ let remembered: RepoPin | null = null
71
73
  // the caller falls back.
72
74
  export async function toplevelOf($: EngineInterface): Promise<string | null> {
73
75
  try {
74
- 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 })
75
77
  const top = out.stdout.trim()
76
78
  return out.exitCode === 0 && top.startsWith('/') ? top : null
77
79
  } catch {
@@ -95,9 +97,9 @@ async function gitRepo($: EngineInterface): Promise<{ repo: RepoRead; toplevel:
95
97
  try {
96
98
  const cwd = await $.session.cwd()
97
99
  const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 5_000 })
98
- const top = await run(['git', 'rev-parse', '--show-toplevel'])
100
+ const top = await run(gitArgv('rev-parse', '--show-toplevel'))
99
101
  if (repoFromGit(top, null) === null) return null
100
- const repo = repoFromGit(top, await run(['git', 'remote', 'get-url', 'origin']))
102
+ const repo = repoFromGit(top, await run(gitArgv('remote', 'get-url', 'origin')))
101
103
  return repo === null ? null : { repo, toplevel: repo.root }
102
104
  } catch {
103
105
  return null
@@ -139,10 +141,107 @@ async function isServed($: EngineInterface, host: string, cwd: string): Promise<
139
141
  }
140
142
  }
141
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
+
142
237
  // Checks the working copy now and pins what it finds; a failed read keeps the last pin. The host's
143
238
  // `session.repo()` can stay null for a repo created mid-session, so git in the cwd answers then (#299). A session
144
239
  // start begins from nothing: the pin of an earlier session is not this one's.
145
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
+ }
146
245
  const stored = await read($, repoPin)
147
246
  const current = init.isStart === true ? null : (stored ?? remembered)
148
247
  let repo: RepoRead | null = null
@@ -159,7 +258,9 @@ export async function refreshPin($: EngineInterface, init: { isStart?: boolean;
159
258
  } catch {
160
259
  // Unreadable repo: keep the last pin.
161
260
  }
162
- 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)
163
264
  remembered = pin
164
265
  if (!sameValue(stored, pin)) {
165
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.-]+)?$/;
@@ -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));
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-test-guard.js: PreToolUse(Bash) hook.
3
+ // Several agent sessions share one machine, and each of them running the whole test
4
+ // suite at once starves it. `testing.full` (project `.trailhead/config.json`, or the
5
+ // global `<config dir>/trailhead/config.json`) names the full-suite command; the queue
6
+ // helper `trailhead-test-run.js` serialises those runs. This guard blocks a Bash call
7
+ // that runs the bare `testing.full` command outside the helper.
8
+ // - Off unless `testing.full` is a non-empty string (nested `{"testing":{"full":..}}`
9
+ // or flat `"testing.full"`, nested wins; the project value replaces the global one,
10
+ // the same rule as the helper). Unset, unreadable or invalid config -> allow.
11
+ // - `testing.full` and the command are both split into statements; each statement
12
+ // drops NAME=v prefixes, redirections and the wrappers an agent writes by mistake
13
+ // (`env`, `time`, `command`, `nice`, `timeout`). The command is blocked when it
14
+ // holds the statements of `testing.full` consecutively, every statement word-equal
15
+ // except the last, which may add only flag arguments (a token starting with `-`).
16
+ // A non-flag extra argument is a focused run (`npm test -- hooks/x.test.js`) and passes.
17
+ // - Compound matching compares statements, not the operators between them. A `cd` /
18
+ // `pushd` statement before the match means the working directory is unknown: allow.
19
+ // - The helper's own invocation (`node .../trailhead-test-run.js ...`) never matches.
20
+ // - Opaque forms (`bash -c`, `eval`) are not unwrapped: guards target accidental mistakes.
21
+ // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
22
+ // Crash-safe: any error -> allow. Self-contained except for lib/shell-scan.js. Pure
23
+ // functions are exported for tests and the merged trailhead-bash-guards.js; runs as a
24
+ // hook when executed directly.
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { splitStatements, tokenize, stripQuotes, stripRedirections, skipEnvPrefix } = require('./lib/shell-scan.js');
30
+
31
+ const HELPER = 'trailhead-test-run.js';
32
+ const ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
33
+ const DURATION_RE = /^\d+(?:\.\d+)?[smhd]?$/;
34
+ // timeout options that consume a value as the next token (when not glued).
35
+ const TIMEOUT_VALUE_OPTS = new Set(['-s', '--signal', '-k', '--kill-after']);
36
+
37
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
38
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
39
+
40
+ // --- config ----------------------------------------------------------------
41
+
42
+ function readJson(file) {
43
+ try {
44
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
45
+ return isObject(cfg) ? cfg : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // The testing.full entry of one parsed config: { found, value }. Nested wins over flat.
52
+ function pickFull(cfg) {
53
+ if (!cfg) return { found: false };
54
+ if (isObject(cfg.testing) && has(cfg.testing, 'full')) return { found: true, value: cfg.testing.full };
55
+ if (has(cfg, 'testing.full')) return { found: true, value: cfg['testing.full'] };
56
+ return { found: false };
57
+ }
58
+
59
+ // The git top level above `cwd` (first dir holding `.git`, dir or file), or null.
60
+ function gitTop(cwd) {
61
+ try {
62
+ let dir = path.resolve(cwd || process.cwd());
63
+ for (;;) {
64
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
65
+ const up = path.dirname(dir);
66
+ if (up === dir) return null;
67
+ dir = up;
68
+ }
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
75
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
76
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
77
+
78
+ function configDir(env) {
79
+ return codexHome() || (env && env.CLAUDE_CONFIG_DIR) || path.join((env && env.HOME) || os.homedir(), '.claude');
80
+ }
81
+
82
+ // The effective testing.full string, or null when the guard is off. The project value
83
+ // replaces the global one even when it is invalid (then the guard is off).
84
+ function readFull(cwd, env) {
85
+ const top = gitTop(cwd);
86
+ const layers = [
87
+ top ? readJson(path.join(top, '.trailhead', 'config.json')) : null,
88
+ readJson(path.join(configDir(env), 'trailhead', 'config.json')),
89
+ ];
90
+ for (const cfg of layers) {
91
+ const p = pickFull(cfg);
92
+ if (!p.found) continue;
93
+ return typeof p.value === 'string' && p.value.trim() !== '' ? p.value : null;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // --- statement normalisation -----------------------------------------------
99
+
100
+ // One statement as a word list without env prefixes, redirections and wrappers, or [].
101
+ function normalise(stmt) {
102
+ let toks = stripRedirections(tokenize(stmt)).map(stripQuotes);
103
+ for (let guard = 0; guard < 20; guard++) {
104
+ toks = toks.slice(skipEnvPrefix(toks));
105
+ if (toks.length === 0) return [];
106
+ const head = toks[0];
107
+ if (head === 'env') {
108
+ let i = 1;
109
+ while (i < toks.length) {
110
+ if (ASSIGN_RE.test(toks[i])) i++;
111
+ else if (toks[i] === '-u' || toks[i] === '-C') i += 2;
112
+ else if (toks[i].startsWith('-') && toks[i] !== '-') i++;
113
+ else break;
114
+ }
115
+ toks = toks.slice(i);
116
+ } else if (head === 'time') {
117
+ let i = 1;
118
+ while (i < toks.length && toks[i].startsWith('-')) i++;
119
+ toks = toks.slice(i);
120
+ } else if (head === 'command') {
121
+ let i = 1;
122
+ while (i < toks.length && toks[i].startsWith('-') && toks[i] !== '--') i++;
123
+ if (toks[i] === '--') i++;
124
+ toks = toks.slice(i);
125
+ } else if (head === 'nice') {
126
+ let i = 1;
127
+ if (toks[i] === '-n') i += 2;
128
+ else if (/^-n\d+$/.test(toks[i] || '') || /^-\d+$/.test(toks[i] || '')) i++;
129
+ toks = toks.slice(i);
130
+ } else if (head === 'timeout') {
131
+ let i = 1;
132
+ while (i < toks.length && toks[i].startsWith('-')) {
133
+ i += TIMEOUT_VALUE_OPTS.has(toks[i]) ? 2 : 1;
134
+ }
135
+ if (i < toks.length && DURATION_RE.test(toks[i])) i++;
136
+ toks = toks.slice(i);
137
+ } else {
138
+ return toks;
139
+ }
140
+ }
141
+ return toks;
142
+ }
143
+
144
+ const statementsOf = (text) => splitStatements(String(text)).map(normalise);
145
+
146
+ const isCd = (words) => words[0] === 'cd' || words[0] === 'pushd';
147
+ const isHelper = (words) => words.some((w) => path.basename(w) === HELPER);
148
+
149
+ // Does `words` run the `want` statement; the last one may add only flag arguments?
150
+ function matchesStatement(words, want, last) {
151
+ if (words.length < want.length) return false;
152
+ for (let i = 0; i < want.length; i++) if (words[i] !== want[i]) return false;
153
+ const extra = words.slice(want.length);
154
+ if (!last) return extra.length === 0;
155
+ return extra.every((w) => w.startsWith('-'));
156
+ }
157
+
158
+ // Does the command run the `full` statements, with a known working directory?
159
+ function runsFull(cmdStmts, fullStmts) {
160
+ for (let start = 0; start + fullStmts.length <= cmdStmts.length; start++) {
161
+ if (cmdStmts.slice(0, start).some(isCd)) return false; // unknown cwd from here on
162
+ const hit = fullStmts.every((want, k) => {
163
+ const words = cmdStmts[start + k];
164
+ return !isHelper(words) && matchesStatement(words, want, k === fullStmts.length - 1);
165
+ });
166
+ if (hit) return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ // --- reason ----------------------------------------------------------------
172
+
173
+ function helperPath(env) {
174
+ const candidates = [path.join(__dirname, '..', 'templates'), path.join(configDir(env), 'trailhead', 'templates')];
175
+ const dir = candidates.find((d) => fs.existsSync(path.join(d, HELPER)));
176
+ return dir ? path.join(dir, HELPER) : HELPER;
177
+ }
178
+
179
+ function block(full, env, root) {
180
+ const reason =
181
+ `trailhead: this Bash call runs the full test suite (\`${full}\`) directly. Several sessions doing that at once starve the machine. ` +
182
+ 'Run focused tests instead (pass only the test files you touched, or the `testing.targeted` command when it is set), ' +
183
+ `or queue the full run with the helper: \`node ${helperPath(env)} run --repo ${root || '<repo root>'}\`.`;
184
+ return { kind: 'block', payload: { decision: 'block', code: 'UNQUEUED_FULL_TEST_RUN', reason } };
185
+ }
186
+
187
+ // Pure verdict: null (allow), or { kind:'block', payload }. Never exits or writes; any
188
+ // internal error allows.
189
+ function evaluate(cmd, opts) {
190
+ try {
191
+ const cwd = opts && opts.cwd;
192
+ const env = (opts && opts.env) || process.env;
193
+ const full = readFull(cwd, env);
194
+ if (!full) return null;
195
+ const fullStmts = statementsOf(full);
196
+ if (fullStmts.length === 0 || fullStmts.some((s) => s.length === 0)) return null;
197
+ if (!runsFull(statementsOf(cmd), fullStmts)) return null;
198
+ return block(full, env, gitTop(cwd));
199
+ } catch {
200
+ return null; // a guard must never wedge the workflow
201
+ }
202
+ }
203
+
204
+ function run(data) {
205
+ let cmd = '';
206
+ let cwd;
207
+ try {
208
+ const input = JSON.parse(data);
209
+ cmd = (input.tool_input || {}).command || '';
210
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
211
+ } catch {
212
+ process.exit(0);
213
+ }
214
+ const v = evaluate(cmd, { cwd, env: process.env });
215
+ if (v && v.kind === 'block') {
216
+ process.stdout.write(JSON.stringify(v.payload));
217
+ process.exit(2);
218
+ }
219
+ process.exit(0);
220
+ }
221
+
222
+ if (require.main === module) {
223
+ let data = '';
224
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
225
+ process.stdin.on('data', (c) => (data += c));
226
+ process.stdin.on('end', () => {
227
+ clearTimeout(timer);
228
+ run(data);
229
+ });
230
+ }
231
+
232
+ module.exports = { configDir, evaluate, readFull, normalise, runsFull };