@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
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  // #299: a session started outside any repo, the repo and `origin` created mid-session. The host's
5
6
  // `session.repo()` keeps answering null (observed live on Claude Code 2.1.306), while git in the cwd
@@ -26,7 +27,7 @@ test('the pin follows a repo created mid-session although session.repo() still a
26
27
  // The host's memoized answer: null from the start, even once the repo exists.
27
28
  on('session.repo', () => ({ value: null }))
28
29
  on('process.run', (_$, e) => {
29
- const cmd = e.argv.join(' ')
30
+ const cmd = unscrub(e.argv).join(' ')
30
31
  if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
31
32
  if (cmd === 'git remote get-url origin') return isCreated ? out(`${REMOTE}\n`) : out('', 2)
32
33
  return out('', 1)
@@ -55,7 +56,7 @@ test('the git fallback spawns no more than it needs: nothing past a failed rev-p
55
56
  on('session.root', () => ({ value: CWD }))
56
57
  on('session.repo', () => ({ value: null }))
57
58
  on('process.run', (_$, e) => {
58
- const cmd = e.argv.join(' ')
59
+ const cmd = unscrub(e.argv).join(' ')
59
60
  spawned.push(cmd)
60
61
  if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
61
62
  if (cmd === 'git remote get-url origin') return out(`${REMOTE}\n`)
@@ -0,0 +1,175 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+ import { unscrub } from './unscrub'
3
+
4
+ // #347: the pinned checkout deleted under the session (a finished worktree). The pin must leave the dead
5
+ // root for a live checkout of the same repo instead of staying on a path nothing can run in.
6
+ const REMOTE = 'https://github.com/o/r.git'
7
+ const DEAD = '/work/r-t5'
8
+ const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
9
+
10
+ type Call = { argv: string[]; cwd: string | undefined }
11
+ type Rig = { alive: Set<string>; remotes: Record<string, string>; worktrees: string; pins: unknown[]; calls: Call[]; now: number }
12
+
13
+ const setup = (on: any, rig: Rig) => {
14
+ on('state.set', { plugin: 'trailhead', key: 'repoPin' }, (_$: unknown, e: { value: unknown }, next: any) => {
15
+ rig.pins.push(e.value)
16
+ return next(e)
17
+ })
18
+ on('fs.read', () => ({ deny: 'ENOENT: none' }))
19
+ on('fs.write', () => ({ value: undefined }))
20
+ on('fs.exists', (_$: unknown, e: { path: string }) => ({ value: rig.alive.has(e.path) }))
21
+ on('fs.list', (_$: unknown, e: { path: string }) => ({
22
+ value: e.path === '/work' ? [...rig.alive].filter(p => p.startsWith('/work/')).map(p => ({ name: p.slice(6), kind: 'dir', size: 0, mtimeMs: 0, isLink: false })) : [],
23
+ }))
24
+ on('clock.now', () => ({ value: rig.now }))
25
+ on('ui.status', () => ({ value: undefined }))
26
+ on('ui.log', () => ({ value: undefined }))
27
+ on('ui.panes', () => ({ value: [] }))
28
+ on('session.id', () => ({ value: 's1' }))
29
+ // The host's memoized answer keeps naming the dead root.
30
+ on('session.cwd', () => ({ value: DEAD }))
31
+ on('session.root', () => ({ value: DEAD }))
32
+ on('session.repo', () => ({ value: { root: DEAD, remote: REMOTE, internal: false } }))
33
+ on('process.run', (_$: unknown, e: { argv: string[]; init?: { cwd?: string } }) => {
34
+ rig.calls.push({ argv: [...unscrub(e.argv)], cwd: e.init?.cwd })
35
+ if (e.init?.cwd !== undefined && !rig.alive.has(e.init.cwd)) return { deny: 'spawn git ENOENT' }
36
+ const [, flag, path, ...rest] = unscrub(e.argv)
37
+ if (flag !== '-C' || path === undefined || !rig.alive.has(path)) {
38
+ // Probes outside -C run in the session cwd, which is gone: top-level reads fail.
39
+ return out('', 128)
40
+ }
41
+ if (rest.join(' ') === 'rev-parse --show-toplevel') return out(`${path}\n`)
42
+ if (rest.join(' ') === 'remote get-url origin') return rig.remotes[path] === undefined ? out('', 2) : out(`${rig.remotes[path]}\n`)
43
+ if (rest.join(' ') === 'worktree list --porcelain') return out(rig.worktrees)
44
+ return out('', 1)
45
+ })
46
+ on('session.start', (_$: unknown, e: { cwd: string }) => ({ cwd: e.cwd }))
47
+ on('turn.start', (_$: unknown, e: { turnId: string }) => ({ turnId: e.turnId }))
48
+ on('turn.complete', () => ({ text: '' }))
49
+ }
50
+
51
+ const complete = ($: any) => $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
52
+
53
+ test('a deleted worktree re-pins to the main working tree of the same repo', async ($, on) => {
54
+ const rig: Rig = {
55
+ alive: new Set(['/', '/work', DEAD]),
56
+ remotes: { '/work/r': REMOTE, '/work/other': 'https://github.com/o/other.git', '/work/r-t7': REMOTE },
57
+ worktrees: 'worktree /work/r\nHEAD abc\nbranch refs/heads/main\n\nworktree /work/r-t7\nHEAD def\nbranch refs/heads/t7\n',
58
+ pins: [],
59
+ calls: [],
60
+ now: 1_000,
61
+ }
62
+ setup(on, rig)
63
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
64
+ expect(rig.pins.at(-1)).toEqual({ root: DEAD, host: 'github.com', slug: 'o/r' })
65
+ rig.alive = new Set(['/', '/work', '/work/r', '/work/r-t7', '/work/other'])
66
+ await complete($)
67
+ expect(rig.pins.at(-1)).toEqual({ root: '/work/r', host: 'github.com', slug: 'o/r' })
68
+ })
69
+
70
+ test('with the main tree gone too, another worktree of the repo is taken', async ($, on) => {
71
+ const rig: Rig = {
72
+ alive: new Set(['/', '/work', DEAD]),
73
+ remotes: { '/work/r-t7': REMOTE },
74
+ worktrees: 'worktree /work/r\nHEAD abc\nbranch refs/heads/main\n\nworktree /work/r-t7\nHEAD def\nbranch refs/heads/t7\n',
75
+ pins: [],
76
+ calls: [],
77
+ now: 1_000,
78
+ }
79
+ setup(on, rig)
80
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
81
+ rig.alive = new Set(['/', '/work', '/work/r-t7'])
82
+ await complete($)
83
+ expect(rig.pins.at(-1)).toEqual({ root: '/work/r-t7', host: 'github.com', slug: 'o/r' })
84
+ })
85
+
86
+ test('no checkout of the repo left keeps the pin and probes only from a live directory', async ($, on) => {
87
+ const rig: Rig = { alive: new Set(['/', '/work', DEAD]), remotes: { '/work/other': 'https://github.com/o/other.git' }, worktrees: '', pins: [], calls: [], now: 1_000 }
88
+ setup(on, rig)
89
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
90
+ rig.alive = new Set(['/', '/work', '/work/other'])
91
+ await complete($)
92
+ expect(rig.pins.at(-1)).toEqual({ root: DEAD, host: 'github.com', slug: 'o/r' })
93
+ })
94
+
95
+ // Spawns of the relocation search: the only ones that use `git -C`.
96
+ const probes = (rig: Rig) => rig.calls.filter(c => c.argv[1] === '-C')
97
+ const WORKTREES = 'worktree /work/r\nHEAD abc\nbranch refs/heads/main\n'
98
+ const newRig = (alive: string[], remotes: Record<string, string>, worktrees = ''): Rig => ({ alive: new Set(['/', '/work', ...alive]), remotes, worktrees, pins: [], calls: [], now: 1_000 })
99
+
100
+ test('after the relocation, later refreshes cost the exists checks only, no git probe', async ($, on) => {
101
+ const rig = newRig([DEAD], { '/work/r': REMOTE }, WORKTREES)
102
+ setup(on, rig)
103
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
104
+ rig.alive.delete(DEAD)
105
+ rig.alive.add('/work/r')
106
+ await complete($)
107
+ expect(rig.pins.at(-1)).toEqual({ root: '/work/r', host: 'github.com', slug: 'o/r' })
108
+ expect(probes(rig).length).toBeGreaterThan(0)
109
+ const moves = rig.pins.length
110
+ rig.calls.length = 0
111
+ await complete($)
112
+ await complete($)
113
+ expect(probes(rig)).toEqual([])
114
+ expect(rig.pins).toHaveLength(moves)
115
+ })
116
+
117
+ test('a live pin costs no probe', async ($, on) => {
118
+ const rig = newRig([DEAD, '/work/r'], { '/work/r': REMOTE }, WORKTREES)
119
+ setup(on, rig)
120
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
121
+ await complete($)
122
+ expect(probes(rig)).toEqual([])
123
+ expect(rig.pins.at(-1)).toEqual({ root: DEAD, host: 'github.com', slug: 'o/r' })
124
+ })
125
+
126
+ test('a search that found nothing is not repeated within 60s, and retried after', async ($, on) => {
127
+ const rig = newRig([DEAD], { '/work/other': 'https://github.com/o/other.git' })
128
+ setup(on, rig)
129
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
130
+ rig.alive.delete(DEAD)
131
+ rig.alive.add('/work/other')
132
+ await complete($)
133
+ expect(probes(rig).length).toBeGreaterThan(0)
134
+ rig.calls.length = 0
135
+ rig.now += 59_000
136
+ await complete($)
137
+ expect(probes(rig)).toEqual([])
138
+ rig.now += 2_000
139
+ await complete($)
140
+ expect(probes(rig).length).toBeGreaterThan(0)
141
+ })
142
+
143
+ test('a same-remote sibling clone with no worktree link is the fallback', async ($, on) => {
144
+ const rig = newRig([DEAD], { '/work/r-clone': REMOTE })
145
+ setup(on, rig)
146
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
147
+ rig.alive.delete(DEAD)
148
+ rig.alive.add('/work/r-clone')
149
+ await complete($)
150
+ expect(rig.pins.at(-1)).toEqual({ root: '/work/r-clone', host: 'github.com', slug: 'o/r' })
151
+ })
152
+
153
+ test('every relocation probe runs from /', async ($, on) => {
154
+ const rig = newRig([DEAD], { '/work/r': REMOTE }, WORKTREES)
155
+ setup(on, rig)
156
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
157
+ rig.alive.delete(DEAD)
158
+ rig.alive.add('/work/r')
159
+ await complete($)
160
+ const found = probes(rig)
161
+ expect(found.length).toBeGreaterThan(0)
162
+ expect(found.every(c => c.cwd === '/')).toBe(true)
163
+ })
164
+
165
+ test('a session start forgets what an earlier session cached', async ($, on) => {
166
+ const rig = newRig([DEAD], { '/work/other': 'https://github.com/o/other.git' })
167
+ setup(on, rig)
168
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
169
+ rig.alive.delete(DEAD)
170
+ rig.alive.add('/work/other')
171
+ await complete($)
172
+ rig.calls.length = 0
173
+ await $.session.start({ cwd: DEAD, surface: 'terminal', isInteractive: true })
174
+ expect(probes(rig).length).toBeGreaterThan(0)
175
+ })
@@ -0,0 +1,8 @@
1
+ // Test helper: the mod runs git (and the gh of the map pane) as `env -u NAME ... <cmd>`
2
+ // (hooks/mod/git-env.ts); a process.run handler matches on the command itself.
3
+ export const unscrub = (argv: readonly string[]): string[] => {
4
+ if (argv[0] !== 'env') return [...argv]
5
+ let i = 1
6
+ while (argv[i] === '-u') i += 2
7
+ return argv.slice(i)
8
+ }
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  const SESSION = 'sess-A'
5
6
  const ID = 'trailhead:work-state'
@@ -48,8 +49,8 @@ const rig = (on: On) => {
48
49
  on('session.cwd', () => ({ value: '/repo' }))
49
50
  on('process.run', (_$, e) => {
50
51
  const out = (exitCode: number, stdout = '') => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
51
- if (e.argv[0] === 'git') return out(0, '/repo\n')
52
- return out(r.authHosts.includes(e.argv[4] ?? '') ? 0 : 1)
52
+ if (unscrub(e.argv)[0] === 'git') return out(0, '/repo\n')
53
+ return out(r.authHosts.includes(unscrub(e.argv)[4] ?? '') ? 0 : 1)
53
54
  })
54
55
  on('ui.status', () => ({ value: undefined }))
55
56
  on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
@@ -1,5 +1,6 @@
1
1
  import type { On } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
+ import { unscrub } from './unscrub'
3
4
 
4
5
  import { resetLocate } from '../hooks/mod/work-state-locate'
5
6
 
@@ -31,7 +32,7 @@ const rig = (on: On) => {
31
32
  on('session.cwd', () => ({ value: r.cwd }))
32
33
  on('session.id', () => ({ value: SESSION }))
33
34
  on('process.run', (_$, e) => {
34
- const line = e.argv.join(' ')
35
+ const line = unscrub(e.argv).join(' ')
35
36
  r.runs.push(line)
36
37
  const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
37
38
  if (line === 'git rev-parse --show-toplevel') return out(`${r.cwd}\n`)