@marcomigozzi/trailhead 0.15.0 → 0.16.1

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 +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  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 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -0,0 +1,123 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { checkoutArg, isPinCheckout, isSamePin, nextPin, pinOf, pinPath, remoteId, repoFromGit } from '../hooks/mod/repo-pin'
4
+
5
+ const repo = (remote: string | null, root = '/repo') => ({ root, remote })
6
+ const GH = { host: 'github.com', slug: 'o/r' }
7
+
8
+ test('remoteId reads host and owner/name from every remote form, with or without .git', () => {
9
+ expect(remoteId('https://github.com/o/r.git')).toEqual(GH)
10
+ expect(remoteId('https://github.com/o/r')).toEqual(GH)
11
+ expect(remoteId('https://github.com/o/r/')).toEqual(GH)
12
+ expect(remoteId('ssh://git@github.com/o/r.git')).toEqual(GH)
13
+ expect(remoteId('git@github.com:o/r.git')).toEqual(GH)
14
+ expect(remoteId('git@github.com:o/r')).toEqual(GH)
15
+ expect(remoteId(' git@github.com:o/r.git\n')).toEqual(GH)
16
+ })
17
+
18
+ test('remoteId accepts a user, a port, any case of the host, and Enterprise hosts', () => {
19
+ expect(remoteId('https://user@github.com/o/r.git')).toEqual(GH)
20
+ expect(remoteId('ssh://git@github.com:22/o/r')).toEqual(GH)
21
+ expect(remoteId('https://GitHub.com/o/r.git')).toEqual(GH)
22
+ expect(remoteId('git@GitHub.COM:o/r.git')).toEqual(GH)
23
+ expect(remoteId('git@ghe.example.com:o/r.git')).toEqual({ host: 'ghe.example.com', slug: 'o/r' })
24
+ expect(remoteId('https://ghe.example.com:8443/o/r')).toEqual({ host: 'ghe.example.com', slug: 'o/r' })
25
+ expect(remoteId('github.com:o/r')).toEqual(GH)
26
+ })
27
+
28
+ test('remoteId is host-aware: any host gives its own, a local path none', () => {
29
+ expect(remoteId('https://gitlab.com/a/b.git')).toEqual({ host: 'gitlab.com', slug: 'a/b' })
30
+ expect(remoteId('ssh://git@github.com/ToRvaLDz/trailhead.git')).toEqual({ host: 'github.com', slug: 'ToRvaLDz/trailhead' })
31
+ expect(remoteId('/srv/git/trailhead.git')).toBeNull()
32
+ })
33
+
34
+ test('remoteId refuses a host or owner/name with characters a link must not carry', () => {
35
+ expect(remoteId('https://github.com/o​/r.git')).toBeNull()
36
+ expect(remoteId('git@github.com:o/r‮.git')).toBeNull()
37
+ expect(remoteId('git@gh⁦e.example.com:o/r.git')).toBeNull()
38
+ expect(remoteId('https://github.com/o/r<x>.git')).toBeNull()
39
+ expect(remoteId('git@github.com:my.org/r_1-x.git')).toEqual({ host: 'github.com', slug: 'my.org/r_1-x' })
40
+ })
41
+
42
+ test('remoteId is null for no remote or one that is not host/owner/name', () => {
43
+ expect(remoteId(null)).toBeNull()
44
+ expect(remoteId('')).toBeNull()
45
+ expect(remoteId('/srv/git/r.git')).toBeNull()
46
+ expect(remoteId('../r.git')).toBeNull()
47
+ expect(remoteId('https://github.com/o')).toBeNull()
48
+ expect(remoteId('file:///srv/o/r.git')).toBeNull()
49
+ })
50
+
51
+ test('pinOf pins the checkout toplevel when given, else the repo root', () => {
52
+ expect(pinOf(null)).toBeNull()
53
+ expect(pinOf(repo(null))).toBeNull()
54
+ expect(pinOf(repo('git@github.com:o/r.git'))).toEqual({ root: '/repo', ...GH })
55
+ expect(pinOf(repo('git@github.com:o/r.git'), '/repo-t5')).toEqual({ root: '/repo-t5', ...GH })
56
+ })
57
+
58
+ test('nextPin keeps the last pin when the check finds no usable repo, and follows a different one', () => {
59
+ const kept = { root: '/repo', ...GH }
60
+ expect(nextPin(kept, null)).toBe(kept)
61
+ expect(nextPin(kept, repo(null))).toBe(kept)
62
+ expect(nextPin(kept, repo('/srv/git/r.git'))).toBe(kept)
63
+ expect(nextPin(null, null)).toBeNull()
64
+ expect(nextPin(null, repo('git@github.com:o/r.git'))).toEqual(kept)
65
+ expect(nextPin(kept, repo('git@github.com:o/r.git'))).toBe(kept)
66
+ expect(nextPin(kept, repo('git@github.com:o/other.git', '/other'))).toEqual({ root: '/other', host: 'github.com', slug: 'o/other' })
67
+ expect(nextPin(kept, repo('git@github.com:o/r.git'), '/repo-t5')).toEqual({ root: '/repo-t5', ...GH })
68
+ })
69
+
70
+ test('isSamePin compares root and repo identity', () => {
71
+ const a = { root: '/repo', ...GH }
72
+ expect(isSamePin(a, { ...a })).toBe(true)
73
+ expect(isSamePin(a, { ...a, root: '/repo-t5' })).toBe(false)
74
+ expect(isSamePin(a, { ...a, host: 'ghe.example.com' })).toBe(false)
75
+ expect(isSamePin(null, null)).toBe(true)
76
+ expect(isSamePin(a, null)).toBe(false)
77
+ })
78
+
79
+ test('pinPath joins a repo-relative path under the pinned root', () => {
80
+ expect(pinPath({ root: '/repo', ...GH }, '.trailhead/active-map')).toBe('/repo/.trailhead/active-map')
81
+ expect(pinPath({ root: '/repo/', ...GH }, '.trailhead/active-map')).toBe('/repo/.trailhead/active-map')
82
+ })
83
+
84
+ test('isPinCheckout holds only for the pinned repo, checked out at the pinned root', () => {
85
+ const pin = { root: '/repo', ...GH }
86
+ expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), '/repo')).toBe(true)
87
+ expect(isPinCheckout(pin, repo('git@github.com:o/r.git', '/main'), '/repo')).toBe(true)
88
+ expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), '/repo-t5')).toBe(false)
89
+ expect(isPinCheckout(pin, repo('git@github.com:o/other.git'), '/repo')).toBe(false)
90
+ expect(isPinCheckout(pin, repo(null), '/repo')).toBe(false)
91
+ expect(isPinCheckout(pin, null, null)).toBe(false)
92
+ expect(isPinCheckout(pin, repo('git@github.com:o/r.git'), null)).toBe(false)
93
+ expect(isPinCheckout(null, repo('git@github.com:o/r.git'), '/repo')).toBe(false)
94
+ })
95
+
96
+ test('repoFromGit builds the repo from the two git outputs', () => {
97
+ expect(repoFromGit({ exitCode: 0, stdout: '/w/p\n' }, { exitCode: 0, stdout: 'git@github.com:o/p.git\n' })).toEqual({ root: '/w/p', remote: 'git@github.com:o/p.git' })
98
+ })
99
+
100
+ test('repoFromGit is null outside a repo, so the last pin stays', () => {
101
+ const current = { root: '/w/p', host: 'github.com', slug: 'o/p' }
102
+ const repo = repoFromGit({ exitCode: 128, stdout: '' }, { exitCode: 128, stdout: '' })
103
+ expect(repo).toBeNull()
104
+ expect(nextPin(current, repo)).toBe(current)
105
+ expect(repoFromGit(null, null)).toBeNull()
106
+ })
107
+
108
+ test('repoFromGit keeps a repo with no origin remote unpinned, so the last pin stays', () => {
109
+ const current = { root: '/w/p', host: 'github.com', slug: 'o/p' }
110
+ const repo = repoFromGit({ exitCode: 0, stdout: '/w/q\n' }, { exitCode: 2, stdout: '' })
111
+ expect(repo).toEqual({ root: '/w/q', remote: null })
112
+ expect(nextPin(current, repo)).toBe(current)
113
+ })
114
+
115
+ test('checkoutArg hands the root over as one shell-safe word the helper decodes back', () => {
116
+ for (const root of ['/repo', '/with space/r', "/it's/r", '/-dash/r', '/uni\u00e8\u4e2d/r', '/a"b$c`d']) {
117
+ 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)
122
+ }
123
+ })
@@ -0,0 +1,50 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { sessionLiveness } from '../hooks/mod/session-registry'
4
+ import type { RegistryDeps } from '../hooks/mod/session-registry'
5
+
6
+ const DIR = '/home/u/.claude/sessions'
7
+ const entry = (sessionId: string, pid: number) => JSON.stringify({ sessionId, pid })
8
+
9
+ // Un registro finto: nome file -> testo; i pid in `alive` sono vivi, `null` = la piattaforma non lo dice.
10
+ const deps = (files: Record<string, string> | Error, alive: number[] | null = []): RegistryDeps => ({
11
+ list: async () => {
12
+ if (files instanceof Error) throw files
13
+ return Object.keys(files)
14
+ },
15
+ read: async path => {
16
+ if (files instanceof Error) throw files
17
+ const text = files[path.slice(DIR.length + 1)]
18
+ if (text === undefined) throw new Error('ENOENT')
19
+ return text
20
+ },
21
+ pidAlive: async pid => (alive === null ? null : alive.includes(pid)),
22
+ })
23
+
24
+ test('a session with a live pid is live', async () => {
25
+ const files = { '10.json': entry('s-A', 10), '11.json': entry('s-B', 11) }
26
+ expect(await sessionLiveness(DIR, 's-A', deps(files, [10, 11]))).toBe('live')
27
+ })
28
+
29
+ test('a session whose entry is missing, or whose pid is dead, is gone', async () => {
30
+ const files = { '10.json': entry('s-A', 10), '11.json': entry('s-B', 11) }
31
+ expect(await sessionLiveness(DIR, 's-OLD', deps(files, [10, 11]))).toBe('gone')
32
+ expect(await sessionLiveness(DIR, 's-A', deps(files, [11]))).toBe('gone')
33
+ })
34
+
35
+ test('with no way to check a pid the entry alone says live', async () => {
36
+ const files = { '10.json': entry('s-A', 10) }
37
+ expect(await sessionLiveness(DIR, 's-A', deps(files, null))).toBe('live')
38
+ })
39
+
40
+ test('an unreadable registry is unknown, never gone', async () => {
41
+ expect(await sessionLiveness(DIR, 's-A', deps(new Error('EACCES')))).toBe('unknown')
42
+ expect(await sessionLiveness(DIR, 's-A', deps({}))).toBe('unknown')
43
+ expect(await sessionLiveness(DIR, 's-A', deps({ 'x.key': 'secret' }))).toBe('unknown')
44
+ })
45
+
46
+ test('a file that does not parse keeps a missing session unknown, but not a found live one', async () => {
47
+ const files = { '9.json': '{nope', '10.json': entry('s-A', 10), '11.json': JSON.stringify({ sessionId: 's-C' }) }
48
+ expect(await sessionLiveness(DIR, 's-OLD', deps(files, [10]))).toBe('unknown')
49
+ expect(await sessionLiveness(DIR, 's-A', deps(files, [10]))).toBe('live')
50
+ })
@@ -0,0 +1,90 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import type { WorkState } from '../types'
4
+ import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from '../hooks/mod/whiteboard-follow'
5
+
6
+ const state = (over: Partial<WorkState> = {}): WorkState => ({ map: null, ticket: 301, ...over }) as WorkState
7
+
8
+ test('nextFollow follows an own whiteboard ticket once', () => {
9
+ expect(nextFollow(null, state(), 301, false)).toEqual({ follow: true, memo: 301 })
10
+ expect(nextFollow(301, state(), 301, false)).toEqual({ follow: false, memo: 301 })
11
+ })
12
+
13
+ test('nextFollow follows a different whiteboard ticket again', () => {
14
+ expect(nextFollow(301, state({ ticket: 302 }), 302, false)).toEqual({ follow: true, memo: 302 })
15
+ })
16
+
17
+ test('nextFollow never follows a map ticket, a foreign or orphaned state, or no own ticket', () => {
18
+ expect(nextFollow(null, state({ map: 12 }), 301, false)).toEqual({ follow: false, memo: null })
19
+ expect(nextFollow(null, state(), 999, false)).toEqual({ follow: false, memo: null })
20
+ expect(nextFollow(null, state(), null, false)).toEqual({ follow: false, memo: null })
21
+ expect(nextFollow(null, state(), 301, true)).toEqual({ follow: false, memo: null })
22
+ })
23
+
24
+ test('nextFollow keeps the memo on a null state', () => {
25
+ expect(nextFollow(301, null, null, false)).toEqual({ follow: false, memo: 301 })
26
+ })
27
+
28
+ test('isWhiteboardCreate reads every label form of an issue create', () => {
29
+ const yes = [
30
+ 'gh issue create -t t --label trailhead:whiteboard',
31
+ 'gh issue create -t t --label a,trailhead:whiteboard',
32
+ 'gh issue create -t t --label=trailhead:whiteboard',
33
+ 'gh issue create -t t -l trailhead:whiteboard',
34
+ 'gh issue create -t t --label bug --label trailhead:whiteboard',
35
+ 'gh issue create -t t --label "trailhead:whiteboard"',
36
+ "gh issue create -t t --label 'bug,trailhead:whiteboard' -b x",
37
+ 'gh -R o/r issue create -l trailhead:whiteboard',
38
+ ]
39
+ for (const c of yes) expect(isWhiteboardCreate(c)).toBe(true)
40
+ })
41
+
42
+ test('isWhiteboardCreate refuses other labels, other verbs and other commands', () => {
43
+ const no = [
44
+ 'gh issue create -t t --label trailhead:whiteboardx',
45
+ 'gh issue create -t t --label trailhead:map',
46
+ 'gh issue create -t t',
47
+ 'gh issue create -t "trailhead:whiteboard"',
48
+ 'gh issue edit 3 --add-label trailhead:whiteboard',
49
+ 'gh issue create -t t --label bug; echo --label trailhead:whiteboard',
50
+ 'echo --label trailhead:whiteboard',
51
+ ]
52
+ for (const c of no) expect(isWhiteboardCreate(c)).toBe(false)
53
+ })
54
+
55
+ test('isWhiteboardCreate reads continued lines, quoted separators and the attached short flag', () => {
56
+ const yes = [
57
+ 'gh issue create \\\n --title x \\\n --label trailhead:whiteboard',
58
+ 'gh issue create --title "a & b" --label trailhead:whiteboard',
59
+ 'gh issue create --title "a; b | c" --label trailhead:whiteboard',
60
+ 'gh issue create --body "a\nb" --label trailhead:whiteboard',
61
+ 'gh issue create -t t -ltrailhead:whiteboard',
62
+ 'cd /x && gh issue create -t t --label trailhead:whiteboard',
63
+ ]
64
+ for (const c of yes) expect(isWhiteboardCreate(c)).toBe(true)
65
+ })
66
+
67
+ test('isWhiteboardCreate ignores a label flag written inside a quoted argument', () => {
68
+ expect(isWhiteboardCreate('gh issue create --title "fix -l trailhead:whiteboard"')).toBe(false)
69
+ expect(isWhiteboardCreate("gh issue create -b 'see --label trailhead:whiteboard' -t t")).toBe(false)
70
+ })
71
+
72
+ test('createdIssueNumber reads the last-line issue URL from stdout, a string or the text', () => {
73
+ const url = 'https://github.com/o/r/issues/310'
74
+ expect(createdIssueNumber({ result: { stdout: `${url}\n`, stderr: '' } })).toBe(310)
75
+ expect(createdIssueNumber({ result: `creating\n${url}\n` })).toBe(310)
76
+ expect(createdIssueNumber({ result: {}, text: url })).toBe(310)
77
+ })
78
+
79
+ test('createdIssueNumber gives none on an error, no URL or a bad shape', () => {
80
+ expect(createdIssueNumber({ isError: true, result: 'https://github.com/o/r/issues/310' })).toBeNull()
81
+ expect(createdIssueNumber({ result: { stdout: 'ok' } })).toBeNull()
82
+ expect(createdIssueNumber({ result: { stdout: 'https://github.com/o/r/issues/310/extra' } })).toBeNull()
83
+ expect(createdIssueNumber(undefined)).toBeNull()
84
+ expect(createdIssueNumber('x')).toBeNull()
85
+ expect(createdIssueNumber({ result: 5 })).toBeNull()
86
+ })
87
+
88
+ test('whiteboardHint names the ticket in a few words', () => {
89
+ expect(whiteboardHint(310)).toBe('new whiteboard ticket #310: open Whiteboard')
90
+ })
@@ -0,0 +1,33 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { candidates, isOwnState, parseWorktrees, pickOwn, siblingClones } from '../hooks/mod/work-state-locate'
4
+
5
+ const state = (session: string | null) =>
6
+ JSON.stringify({ map: null, ticket: 270, title: 'T', step: 'execute', auto: false, autoDone: 0, session, updatedAt: null })
7
+
8
+ test('parseWorktrees keeps the checkout paths in git order, spaces included', () => {
9
+ const out = 'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /my work/repo-t270\nHEAD def\ndetached\n\nworktree /repo-t5/\nbare\n'
10
+ expect(parseWorktrees(out)).toEqual(['/repo', '/my work/repo-t270', '/repo-t5'])
11
+ expect(parseWorktrees('')).toEqual([])
12
+ expect(parseWorktrees('fatal: not a git repository')).toEqual([])
13
+ })
14
+
15
+ test('siblingClones matches <basename>-t<digits> only, sorted, under the main root parent', () => {
16
+ const names = ['repo-t12', 'repo', 'repo-t5', 'repo-t5x', 'repo-tx', 'other-t3', 'repo-t5-old', 'repoXt7']
17
+ expect(siblingClones('/home/me/repo', names)).toEqual(['/home/me/repo-t12', '/home/me/repo-t5'])
18
+ expect(siblingClones('/r.x', ['r.x-t1', 'rax-t2'])).toEqual(['/r.x-t1'])
19
+ })
20
+
21
+ test('candidates drops the pinned root and duplicates, worktrees before siblings', () => {
22
+ expect(candidates('/repo', ['/repo', '/repo-t1', '/repo-t2'], ['/repo-t2', '/repo-t3'])).toEqual(['/repo-t1', '/repo-t2', '/repo-t3'])
23
+ expect(candidates('/repo/', ['/repo'], [])).toEqual([])
24
+ })
25
+
26
+ test('only a state naming this session is adopted', () => {
27
+ expect(isOwnState(state('A'), 'A')).toBe(true)
28
+ expect(isOwnState(state('B'), 'A')).toBe(false)
29
+ expect(isOwnState(state(null), 'A')).toBe(false)
30
+ expect(isOwnState('{not json', 'A')).toBe(false)
31
+ expect(pickOwn([null, state(null), state('B'), state('A'), state('A')], 'A')).toBe(3)
32
+ expect(pickOwn([null, state('B')], 'A')).toBe(-1)
33
+ })
@@ -27,7 +27,10 @@ const rig = (on: On) => {
27
27
  file: null as string | null,
28
28
  remote: REMOTE as string | null,
29
29
  repoFails: false,
30
+ // The hosts `gh auth status --hostname` accepts besides github.com.
31
+ authHosts: [] as string[],
30
32
  skipCompact: false,
33
+ denyLatch: false,
31
34
  composes: 0,
32
35
  repoReads: 0,
33
36
  writes: 0,
@@ -42,11 +45,21 @@ const rig = (on: On) => {
42
45
  if (r.repoFails) return { deny: 'repo read failed' }
43
46
  return { value: { root: '/repo', remote: r.remote, internal: false } }
44
47
  })
48
+ on('session.cwd', () => ({ value: '/repo' }))
49
+ on('process.run', (_$, e) => {
50
+ 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)
53
+ })
45
54
  on('ui.status', () => ({ value: undefined }))
46
55
  on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
47
56
  r.writes += 1
48
57
  return next(e)
49
58
  })
59
+ // Only the latch key: the other trailhead atoms keep writing.
60
+ on('state.set', { plugin: 'trailhead', key: 'promptSnapshot' }, (_$, e, next) =>
61
+ r.denyLatch ? { deny: 'state is read-only' } : next(e),
62
+ )
50
63
  on('prompt.compose', () => {
51
64
  r.composes += 1
52
65
  return { sections: ENGINE }
@@ -87,7 +100,7 @@ test('no section without a file, and none when the state is orphaned', async ($,
87
100
  expect(await compose($)).toEqual(ENGINE)
88
101
  })
89
102
 
90
- test('a step change leaves the text untouched; a ticket change rebuilds it', async ($, on) => {
103
+ test('a step change leaves the text untouched; a ticket change rebuilds it after a compaction', async ($, on) => {
91
104
  const r = rig(on)
92
105
  r.file = stateText()
93
106
  await $.session.start(START)
@@ -97,6 +110,9 @@ test('a step change leaves the text untouched; a ticket change rebuilds it', asy
97
110
  expect((mine(await compose($)) as Section).text).toBe(first)
98
111
  r.file = stateText({ ticket: 205, title: 'Next ticket', step: 'execute' })
99
112
  await turn($)
113
+ // A later compose never advances the latch: the text stays what the model was told.
114
+ expect((mine(await compose($)) as Section).text).toBe(first)
115
+ await $.session.compact({ messages: SUMMARY })
100
116
  const next = (mine(await compose($)) as Section).text
101
117
  expect(next).toContain('#205 "Next ticket"')
102
118
  expect(next).toContain('execute')
@@ -161,7 +177,45 @@ test('no repo remote: numbers without links', async ($, on) => {
161
177
  expect(s.text).not.toContain('http')
162
178
  })
163
179
 
164
- test('the repo is read at a rebuild only, and the atom is written once per change', async ($, on) => {
180
+ test('the links follow the pin: they survive a cd out of the repo', async ($, on) => {
181
+ const r = rig(on)
182
+ r.file = stateText()
183
+ await $.session.start(START)
184
+ await compose($)
185
+ r.remote = null
186
+ r.file = stateText({ ticket: 205, title: 'Next ticket' })
187
+ await turn($)
188
+ await $.session.compact({ messages: SUMMARY })
189
+ const s = mine(await compose($)) as Section
190
+ expect(s.text).toContain('#205 "Next ticket" https://github.com/ToRvaLDz/trailhead/issues/205')
191
+ expect(s.text).toContain('#198 https://github.com/ToRvaLDz/trailhead/issues/198')
192
+ })
193
+
194
+ test('an Enterprise pin links to its own host', async ($, on) => {
195
+ const r = rig(on)
196
+ r.file = stateText()
197
+ r.remote = 'git@ghe.example.com:acme/app.git'
198
+ r.authHosts = ['ghe.example.com']
199
+ await $.session.start(START)
200
+ const s = mine(await compose($)) as Section
201
+ expect(s.text).toContain('#204 "Inject the work state" https://ghe.example.com/acme/app/issues/204')
202
+ expect(s.text).toContain('#198 https://ghe.example.com/acme/app/issues/198')
203
+ })
204
+
205
+ test('a move of the pin to another repo rebuilds the links, whatever the ticket', async ($, on) => {
206
+ const r = rig(on)
207
+ r.file = stateText()
208
+ await $.session.start(START)
209
+ expect((mine(await compose($)) as Section).text).toContain('https://github.com/ToRvaLDz/trailhead/issues/204')
210
+ r.remote = 'https://github.com/o/other.git'
211
+ await turn($)
212
+ await $.session.compact({ messages: SUMMARY })
213
+ const s = mine(await compose($)) as Section
214
+ expect(s.text).toContain('#204 "Inject the work state" https://github.com/o/other/issues/204')
215
+ expect(s.text).not.toContain('ToRvaLDz')
216
+ })
217
+
218
+ test('the repo is not read by a compose, and the atom is written once per change', async ($, on) => {
165
219
  const r = rig(on)
166
220
  r.file = stateText()
167
221
  await $.session.start(START)
@@ -205,3 +259,223 @@ test('an analysis render shows the section but writes nothing', async ($, on) =>
205
259
  expect(s.text).toContain('#204')
206
260
  expect(r.writes).toBe(writes)
207
261
  })
262
+
263
+ // A plugin's own prompt: the engine stub beneath answers with the context that arrived.
264
+ const submit = async ($: any): Promise<readonly string[]> => ($.prompt.submit({ text: 'next' }) as Promise<{ context?: readonly string[] }>).then(r => r.context ?? [])
265
+
266
+ test('a state written after the one compose reaches the model as context, once', async ($, on) => {
267
+ const r = rig(on)
268
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
269
+ await $.session.start(START)
270
+ expect(await compose($)).toEqual(ENGINE)
271
+ r.file = stateText()
272
+ const first = await submit($)
273
+ expect(first).toHaveLength(1)
274
+ expect(first[0]).toContain('#204 "Inject the work state"')
275
+ expect(await submit($)).toEqual([])
276
+ })
277
+
278
+ test('a step change alone attaches nothing to the next prompt', async ($, on) => {
279
+ const r = rig(on)
280
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
281
+ r.file = stateText()
282
+ await $.session.start(START)
283
+ await compose($)
284
+ r.file = stateText({ step: 'execute' })
285
+ expect(await submit($)).toEqual([])
286
+ })
287
+
288
+ test('a state removed after the compose attaches the stale note once', async ($, on) => {
289
+ const r = rig(on)
290
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
291
+ r.file = stateText()
292
+ await $.session.start(START)
293
+ await compose($)
294
+ r.file = null
295
+ const first = await submit($)
296
+ expect(first).toHaveLength(1)
297
+ expect(first[0]).toContain('# trailhead work state')
298
+ expect(first[0]).toContain('stale')
299
+ expect(await submit($)).toEqual([])
300
+ })
301
+
302
+ test('before any compose a prompt gets nothing attached', async ($, on) => {
303
+ const r = rig(on)
304
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
305
+ r.file = stateText()
306
+ await $.session.start(START)
307
+ expect(await submit($)).toEqual([])
308
+ })
309
+
310
+ test('a failing read never drops the prompt', async ($, on) => {
311
+ const r = rig(on)
312
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
313
+ await $.session.start(START)
314
+ await compose($)
315
+ r.file = stateText()
316
+ r.repoFails = true
317
+ const result = (await $.prompt.submit({ text: 'next' })) as { text?: string }
318
+ expect(result.text).toBe('next')
319
+ })
320
+
321
+ // The real engine composes every turn but keeps sending the first system prompt.
322
+ test('an engine compose per turn never advances the latch: the submit still carries the change', async ($, on) => {
323
+ const r = rig(on)
324
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
325
+ await $.session.start(START)
326
+ expect(await compose($, ['lean', 'print', 'skills'])).toEqual(ENGINE)
327
+ r.file = stateText()
328
+ await turn($)
329
+ expect(await compose($, ['lean', 'print', 'skills'])).toEqual(ENGINE)
330
+ const first = await submit($)
331
+ expect(first).toHaveLength(1)
332
+ expect(first[0]).toContain('#204 "Inject the work state"')
333
+ expect(await submit($)).toEqual([])
334
+ })
335
+
336
+ test('a compose after the latch is set returns the latched text, not the live one', async ($, on) => {
337
+ const r = rig(on)
338
+ r.file = stateText()
339
+ await $.session.start(START)
340
+ const first = (mine(await compose($)) as Section).text
341
+ r.file = stateText({ ticket: 205, title: 'Next ticket' })
342
+ await turn($)
343
+ expect((mine(await compose($, ['analysis'])) as Section).text).toBe(first)
344
+ })
345
+
346
+ test('a prompt dropped beneath keeps the change for the next one', async ($, on) => {
347
+ const r = rig(on)
348
+ let drops = 1
349
+ on('prompt.submit', (_$, e) => (drops-- > 0 ? { drop: 'busy' } : { text: e.text, context: e.context }))
350
+ await $.session.start(START)
351
+ await compose($)
352
+ r.file = stateText()
353
+ const dropped = (await $.prompt.submit({ text: 'next' })) as { drop?: string }
354
+ expect(dropped.drop).toBe('busy')
355
+ const again = await submit($)
356
+ expect(again).toHaveLength(1)
357
+ expect(again[0]).toContain('#204')
358
+ expect(await submit($)).toEqual([])
359
+ })
360
+
361
+ test('a failing latch write never drops the prompt and the next one delivers the change', async ($, on) => {
362
+ const r = rig(on)
363
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
364
+ await $.session.start(START)
365
+ await compose($)
366
+ r.file = stateText()
367
+ r.denyLatch = true
368
+ const result = (await $.prompt.submit({ text: 'next' })) as { text?: string; context?: readonly string[] }
369
+ expect(result.text).toBe('next')
370
+ expect(result.context ?? []).toEqual([])
371
+ r.denyLatch = false
372
+ const again = await submit($)
373
+ expect(again).toHaveLength(1)
374
+ expect(again[0]).toContain('#204')
375
+ expect(await submit($)).toEqual([])
376
+ })
377
+
378
+ test('a hook failing beneath after the note was attached re-delivers it on the next prompt', async ($, on) => {
379
+ const r = rig(on)
380
+ let fails = 1
381
+ on('prompt.submit', (_$, e) => {
382
+ if (fails-- > 0) throw new Error('boom')
383
+ return { text: e.text, context: e.context }
384
+ })
385
+ await $.session.start(START)
386
+ await compose($)
387
+ r.file = stateText()
388
+ const first = await $.prompt.submit({ text: 'next' }).catch(() => ({ context: [] as string[] }))
389
+ expect((first as { context?: readonly string[] }).context ?? []).toEqual([])
390
+ const again = await submit($)
391
+ expect(again).toHaveLength(1)
392
+ expect(again[0]).toContain('#204')
393
+ expect(await submit($)).toEqual([])
394
+ })
395
+
396
+ test('a pin moved to another repo with the same ticket attaches the section with the new links', async ($, on) => {
397
+ const r = rig(on)
398
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
399
+ r.file = stateText()
400
+ await $.session.start(START)
401
+ await compose($)
402
+ r.remote = 'https://github.com/o/other.git'
403
+ await turn($)
404
+ const moved = await submit($)
405
+ expect(moved).toHaveLength(1)
406
+ expect(moved[0]).toContain('#204 "Inject the work state" https://github.com/o/other/issues/204')
407
+ expect(moved[0]).not.toContain('ToRvaLDz')
408
+ })
409
+
410
+ test('two overlapping prompts attach the change once', async ($, on) => {
411
+ const r = rig(on)
412
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
413
+ await $.session.start(START)
414
+ await compose($)
415
+ r.file = stateText()
416
+ const all = await Promise.all([submit($), submit($)])
417
+ expect(all.flat()).toHaveLength(1)
418
+ })
419
+
420
+ test('a slash command attaches nothing and keeps the change for the next prompt', async ($, on) => {
421
+ const r = rig(on)
422
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
423
+ await $.session.start(START)
424
+ await compose($)
425
+ r.file = stateText()
426
+ const cmd = (await $.prompt.submit({ text: '/compact', command: 'compact' })) as { context?: readonly string[] }
427
+ expect(cmd.context ?? []).toEqual([])
428
+ expect(await submit($)).toHaveLength(1)
429
+ })
430
+
431
+ test('an orphaned state after a latched one gives the stale note', async ($, on) => {
432
+ const r = rig(on)
433
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
434
+ r.file = stateText()
435
+ await $.session.start(START)
436
+ await compose($)
437
+ r.file = stateText({ session: 'sess-other' })
438
+ const first = await submit($)
439
+ expect(first).toHaveLength(1)
440
+ expect(first[0]).toContain('stale')
441
+ })
442
+
443
+ test('an existing context is kept and the section appended', async ($, on) => {
444
+ const r = rig(on)
445
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
446
+ await $.session.start(START)
447
+ await compose($)
448
+ r.file = stateText()
449
+ const result = (await $.prompt.submit({ text: 'next', context: ['earlier'] })) as { context?: readonly string[] }
450
+ expect(result.context?.[0]).toBe('earlier')
451
+ expect(result.context).toHaveLength(2)
452
+ expect(result.context?.[1]).toContain('#204')
453
+ })
454
+
455
+ test('a compaction or a clear resets the latch: a prompt before the next compose gets nothing', async ($, on) => {
456
+ const r = rig(on)
457
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
458
+ on('classic.SessionStart', () => ({}))
459
+ r.file = stateText()
460
+ await $.session.start(START)
461
+ await compose($)
462
+ await $.session.compact({ messages: SUMMARY })
463
+ r.file = stateText({ ticket: 205, title: 'Next ticket' })
464
+ expect(await submit($)).toEqual([])
465
+ await compose($)
466
+ await $.classic.SessionStart({ source: 'clear' })
467
+ r.file = stateText({ ticket: 206, title: 'Third' })
468
+ expect(await submit($)).toEqual([])
469
+ })
470
+
471
+ test('a ticket to ticket change attaches the new section', async ($, on) => {
472
+ const r = rig(on)
473
+ on('prompt.submit', (_$, e) => ({ text: e.text, context: e.context }))
474
+ r.file = stateText()
475
+ await $.session.start(START)
476
+ await compose($)
477
+ r.file = stateText({ ticket: 205, title: 'Next ticket' })
478
+ const first = await submit($)
479
+ expect(first).toHaveLength(1)
480
+ expect(first[0]).toContain('#205 "Next ticket"')
481
+ })