@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
@@ -278,6 +278,12 @@ test('groupTickets lists a needs-you ticket once, under Needs you, ascending, wh
278
278
  expect(groupTickets([t(1, 'frontier', { isNeedsYou: false })]).needsYou).toEqual([])
279
279
  })
280
280
 
281
+ test('failureReason blames the folder, not gh, when the checkout is gone', () => {
282
+ const error = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })
283
+ expect(failureReason({ error, missingRoot: '/work/r-t5' })).toBe('checkout not found: /work/r-t5')
284
+ expect(failureReason({ error })).toBe('gh not installed')
285
+ })
286
+
281
287
  test('failureReason names one short cause', () => {
282
288
  expect(failureReason({ error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) })).toBe('gh not installed')
283
289
  expect(failureReason({ error: new Error('spawn gh ENOENT') })).toBe('gh not installed')
@@ -1,6 +1,9 @@
1
1
  import { expect, test } from 'claude-code/testing'
2
2
 
3
- import { GH_ISSUE_WRITE } from '../hooks/mod/map-pane'
3
+ import { GH_ISSUE_WRITE, isTrackerWrite } from '../hooks/mod/map-pane'
4
+
5
+ // A heredoc that writes a body file, as the model usually runs before a tracker write.
6
+ const HEREDOC = "cat > /tmp/x.md <<'EOF'\nbody line\nEOF"
4
7
 
5
8
  // Bash commands the pane refreshes on (a write to the tracker) and ones it must ignore.
6
9
  const WRITES = [
@@ -25,6 +28,13 @@ const WRITES = [
25
28
  "gh api -X PUT 'repos/o/r/issues/5/lock'",
26
29
  'gh api repos/o/r/issues/5 --input body.json',
27
30
  'node /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x',
31
+ // multi-line: a heredoc before the write, or a line on either side of it
32
+ `${HEREDOC}\ncd /repo && gh issue edit 325 --title "t"`,
33
+ `${HEREDOC}\ngh issue create --body-file /tmp/x.md`,
34
+ `${HEREDOC}\ngh issue comment 5 --body-file /tmp/x.md`,
35
+ 'echo hi\ngh issue close 5',
36
+ 'gh issue close 5\necho hi',
37
+ `${HEREDOC}\nnode /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x`,
28
38
  ]
29
39
  const READS = [
30
40
  'gh issue list --state open',
@@ -40,6 +50,10 @@ const READS = [
40
50
  'gh api repos/o/r/pulls -f state=open',
41
51
  'git log && echo gh issue',
42
52
  'gh api repos/o/r/issues/5 | jq . ; echo -f x',
53
+ // multi-line reads
54
+ `${HEREDOC}\ngh issue view 5`,
55
+ `${HEREDOC}\ngh issue list --state open`,
56
+ 'cd /repo\ngh api repos/o/r/issues/5',
43
57
  ]
44
58
 
45
59
  test('the write matcher takes the tracker writes of a Bash call', () => {
@@ -49,3 +63,45 @@ test('the write matcher takes the tracker writes of a Bash call', () => {
49
63
  test('the write matcher leaves reads alone', () => {
50
64
  for (const command of READS) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, false])
51
65
  })
66
+
67
+ // A heredoc body is data, not commands: [command, is it a tracker write]. `\t` is a tab.
68
+ const HEREDOCS: Array<[string, boolean]> = [
69
+ ['cat >f <<EOF\ngh issue close 5\nEOF', false],
70
+ ["cat >f <<'EOF'\ngh issue close 5\nEOF", false],
71
+ ['cat >f <<"EOF"\ngh issue close 5\nEOF', false],
72
+ ['cat >f <<-EOF\n\tgh issue close 5\n\tEOF', false],
73
+ ['cat <<A >a; cat <<B >b\ngh issue close 1\nA\ngh issue edit 2\nB', false],
74
+ ['cat <<A >a; cat <<B >b\nx\nA\ny\nB\ngh issue close 3', true],
75
+ ['gh issue create --body-file - <<EOF\nbody\nEOF', true],
76
+ ['gh issue comment 5 -F - <<EOF\ngh issue close 9\nEOF', true],
77
+ ["cat <<'EOF' >x\nb\nEOF\ngh issue close 5", true],
78
+ ["cat <<'EOF' >x\nEOF2\ngh issue close 5\n EOF\nEOF", false],
79
+ ["cat <<'EOF' >x\ngh issue close 5", false],
80
+ ['cat <<-EOF >x\nb\n EOF\ngh issue close 5\nEOF', false],
81
+ ["cat <<< 'x'\ngh issue close 5", true],
82
+ ['echo $((1<<2))\ngh issue close 5', true],
83
+ ['echo $((1<<2)); gh issue view 5', false],
84
+ ["cat <<'EOF' >x\nnode trailhead-issue-body.js append\nEOF", false],
85
+ ['echo "a <<EOF"\ngh issue close 5', true],
86
+ // a `<<` in a comment opens nothing, so the write after it stays
87
+ ['# build body with <<EOF\ngh issue close 5', true],
88
+ ['git commit -m "x" # see <<EOF\ngh issue close 5', true],
89
+ ['# build body with <<EOF\ngh issue view 5', false],
90
+ ['cat a#b <<EOF\ngh issue close 5\nEOF', false],
91
+ // an opener inside a multi-line quoted string opens nothing
92
+ ['echo "a\ncat <<EOF\nb"\ngh issue close 5\nEOF', true],
93
+ ['echo "a\ncat <<EOF\nb"\ngh issue view 5\nEOF', false],
94
+ // a `<<` inside a parameter expansion opens nothing
95
+ ['echo ${x:-<<EOF}\ngh issue close 5\nEOF', true],
96
+ // an apostrophe in a heredoc body leaks no quote state into later lines
97
+ ["cat <<EOF\nit's\nEOF\ncat <<B\ngh issue close 5\nB", false],
98
+ ]
99
+
100
+ test('isTrackerWrite takes the writes and leaves the reads', () => {
101
+ for (const command of WRITES) expect([command, isTrackerWrite(command)]).toEqual([command, true])
102
+ for (const command of READS) expect([command, isTrackerWrite(command)]).toEqual([command, false])
103
+ })
104
+
105
+ test('isTrackerWrite ignores a heredoc body and keeps a write around it', () => {
106
+ for (const [command, verdict] of HEREDOCS) expect([command, isTrackerWrite(command)]).toEqual([command, verdict])
107
+ })
@@ -0,0 +1,66 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { parseTickets } from '../hooks/mod/map-pane-view'
4
+ import { elsewhereInProgress, myTicketsFromRows, parseLogin, scopeLine } from '../hooks/mod/my-tickets'
5
+
6
+ const row = (number: number, names: string[], logins: string[] = ['me']) => ({
7
+ number,
8
+ title: `t${number}`,
9
+ url: `https://github.com/o/r/issues/${number}`,
10
+ labels: names.map(name => ({ name })),
11
+ assignees: logins.map(login => ({ login })),
12
+ })
13
+
14
+ test('parseLogin reads the viewer login, null on anything else', () => {
15
+ expect(parseLogin(JSON.stringify({ data: { viewer: { login: 'me' } } }))).toBe('me')
16
+ expect(parseLogin(JSON.stringify({ data: { viewer: { login: '' } } }))).toBeNull()
17
+ expect(parseLogin(JSON.stringify({ errors: [{}], data: { viewer: { login: 'me' } } }))).toBeNull()
18
+ expect(parseLogin('not json')).toBeNull()
19
+ })
20
+
21
+ test('myTicketsFromRows tags each ticket with its map or the whiteboard, and drops the rest', () => {
22
+ const mine = myTicketsFromRows([
23
+ row(5, ['trailhead:ticket', 'trailhead:build', 'trailhead:map-12']),
24
+ row(6, ['trailhead:ticket', 'trailhead:bug', 'trailhead:whiteboard']),
25
+ // no scope, contradictory scopes, and quarantined: never shown
26
+ row(7, ['trailhead:ticket', 'trailhead:build']),
27
+ row(8, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:whiteboard']),
28
+ row(9, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:map-2']),
29
+ row(10, ['trailhead:ticket', 'trailhead:map-1', 'trailhead:unverified']),
30
+ ])
31
+ expect(mine.map(t => [t.number, t.scope])).toEqual([
32
+ [5, { kind: 'map', number: 12 }],
33
+ [6, { kind: 'whiteboard' }],
34
+ ])
35
+ expect(mine[0]?.state).toBe('in-progress')
36
+ })
37
+
38
+ test('elsewhereInProgress keeps my in-progress tickets of the other scopes only', () => {
39
+ const mine = myTicketsFromRows([
40
+ row(1, ['trailhead:ticket', 'trailhead:map-12']),
41
+ row(2, ['trailhead:ticket', 'trailhead:map-30']),
42
+ row(3, ['trailhead:ticket', 'trailhead:whiteboard']),
43
+ // not in progress: paused, blocked, needs-you, UAT
44
+ row(4, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:paused']),
45
+ row(5, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:blocked']),
46
+ row(6, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:needs-you']),
47
+ row(7, ['trailhead:ticket', 'trailhead:map-30', 'trailhead:uat']),
48
+ ])
49
+ expect(elsewhereInProgress(mine, { kind: 'map', number: 12 }, []).map(t => t.number)).toEqual([2, 3])
50
+ expect(elsewhereInProgress(mine, { kind: 'whiteboard' }, []).map(t => t.number)).toEqual([1, 2])
51
+ // a ticket the view already lists is never shown twice
52
+ const shown = parseTickets(JSON.stringify([row(2, ['trailhead:ticket', 'trailhead:map-12'])]))?.tickets ?? []
53
+ expect(elsewhereInProgress(mine, { kind: 'map', number: 12 }, shown).map(t => t.number)).toEqual([3])
54
+ })
55
+
56
+ test('scopeLine names the map by number and title, or the whiteboard, cut to the width', () => {
57
+ const [onMap, onBoard] = myTicketsFromRows([
58
+ row(1, ['trailhead:ticket', 'trailhead:map-30']),
59
+ row(2, ['trailhead:ticket', 'trailhead:whiteboard']),
60
+ ])
61
+ const maps = [{ number: 30, title: 'Billing rework', counts: null }]
62
+ expect(onMap && scopeLine(onMap, maps, 60)).toBe('on map #30 Billing rework')
63
+ expect(onMap && scopeLine(onMap, [], 60)).toBe('on map #30')
64
+ expect(onBoard && scopeLine(onBoard, maps, 60)).toBe('on whiteboard')
65
+ expect((onMap && scopeLine(onMap, maps, 16))?.length).toBeLessThanOrEqual(16)
66
+ })
@@ -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
+ })
@@ -112,12 +112,27 @@ test('repoFromGit keeps a repo with no origin remote unpinned, so the last pin s
112
112
  expect(nextPin(current, repo)).toBe(current)
113
113
  })
114
114
 
115
- test('checkoutArg hands the root over as one shell-safe word the helper decodes back', () => {
115
+ const fromBase64url = (token: string): string => {
116
+ const bytes = Uint8Array.from(atob(token.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(token.length / 4) * 4, '=')), c => c.charCodeAt(0))
117
+ return new TextDecoder().decode(bytes)
118
+ }
119
+
120
+ test('checkoutArg hands the root and the repo over as one shell-safe word the helper decodes back', () => {
116
121
  for (const root of ['/repo', '/with space/r', "/it's/r", '/-dash/r', '/uni\u00e8\u4e2d/r', '/a"b$c`d']) {
117
122
  const arg = checkoutArg({ ...GH, root })
118
- expect(arg).toMatch(/^--in=[A-Za-z0-9_-]+$/)
119
- const token = arg.slice('--in='.length)
120
- const bytes = Uint8Array.from(atob(token.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(token.length / 4) * 4, '=')), c => c.charCodeAt(0))
121
- expect(new TextDecoder().decode(bytes)).toBe(root)
123
+ expect(arg).toMatch(/^--in=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)
124
+ const [path, repo] = arg.slice('--in='.length).split('.')
125
+ expect(fromBase64url(path)).toBe(root)
126
+ expect(fromBase64url(repo)).toBe('github.com/o/r')
122
127
  }
123
128
  })
129
+
130
+ test('checkoutArg carries host and a nested slug in the repo part', () => {
131
+ const arg = checkoutArg({ root: '/w/p', host: 'gitlab.com', slug: 'g/sub/r' })
132
+ expect(fromBase64url(arg.split('.')[1])).toBe('gitlab.com/g/sub/r')
133
+ })
134
+
135
+ test('checkoutArg carries a dotless host in the repo part', () => {
136
+ const arg = checkoutArg({ root: '/w/p', host: 'gitea', slug: 'owner/name' })
137
+ expect(fromBase64url(arg.split('.')[1])).toBe('gitea/owner/name')
138
+ })
@@ -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`)
@@ -42,6 +42,11 @@ export type PaneTicket = {
42
42
  uatOf: UatParent | null
43
43
  }
44
44
 
45
+ // Where one of my tickets lives: an open map by number, or the whiteboard.
46
+ export type TicketScope = { kind: 'map'; number: number } | { kind: 'whiteboard' }
47
+ // A ticket assigned to the gh login, tagged with its scope (the In progress group lists it outside that scope).
48
+ export type MyTicket = PaneTicket & { scope: TicketScope }
49
+
45
50
  // A map's sub-issue counts: `open` still to do, `total` all of them.
46
51
  export type MapCounts = { open: number; total: number }
47
52
  // One open map of the repo, as the maps list shows it; `counts` null = unknown, drawn name-only.
@@ -113,6 +118,8 @@ declare module 'claude-code' {
113
118
  // The pane shows the dashboard (maps index + whiteboard) instead of a map.
114
119
  isDashboard: boolean
115
120
  dashboardPane: DashboardPaneData
121
+ // My open tickets across every map and the whiteboard (the last good read), for every view's In progress.
122
+ myTickets: readonly MyTicket[]
116
123
  pendingWork: number | null
117
124
  // Whether the pending press waits for the turn to end or has been sent.
118
125
  pendingPhase: 'queued' | 'starting' | null