@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,365 @@
1
+ import type { On } from 'claude-code'
2
+ import { expect, test } from 'claude-code/testing'
3
+
4
+ const SURFACES = ['terminal', 'desktop'] as const
5
+ const SESSION = 'sess-A'
6
+ const NEXT_SESSION = 'sess-B'
7
+ const ACCENT = '#7aa2f7'
8
+ const GREEN = '#9ece6a'
9
+ const AMBER = '#e0af68'
10
+ const TITLE = 'Status entry and pipeline band'
11
+ const STATUS_PLAN = `map #198 · #203 ${TITLE} · plan`
12
+ const HELPER_CALL = 'node ~/.claude/trailhead/templates/trailhead-work-state.js set --repo . --step execute'
13
+
14
+ const stateText = (over: Record<string, unknown> = {}): string =>
15
+ JSON.stringify({
16
+ map: 198,
17
+ ticket: 203,
18
+ title: TITLE,
19
+ step: 'plan',
20
+ auto: false,
21
+ autoDone: 0,
22
+ session: SESSION,
23
+ updatedAt: '2026-10-11T00:00:00.000Z',
24
+ ...over,
25
+ })
26
+
27
+ const BAND_PROPS = {
28
+ hasSurvey: false,
29
+ isWorking: false,
30
+ maxRows: 10,
31
+ bodyColumns: 80,
32
+ scroll: { offset: 0, bodyRows: 10 },
33
+ view: {},
34
+ } as const
35
+
36
+ // What the engine would answer beneath the plugin: the file, the session id, and a
37
+ // record of every status line and every write to the plugin's atoms. The bottom
38
+ // of ui.render draws a marked Box, so a test can tell the engine's own drawing
39
+ // (the plugin yielded) from the band.
40
+ const rig = (on: On, engine: any) => {
41
+ const r = {
42
+ file: null as string | Error | null,
43
+ session: SESSION,
44
+ statuses: [] as (string | undefined)[],
45
+ writes: 0,
46
+ reads: 0,
47
+ }
48
+ on('fs.read', (_$, e) => {
49
+ r.reads += 1
50
+ if (!e.path.endsWith('/.trailhead/work-state.json')) return { deny: `unexpected read of ${e.path}` }
51
+ if (r.file === null) return { deny: "ENOENT: no such file or directory, open '.trailhead/work-state.json'" }
52
+ if (r.file instanceof Error) return { deny: r.file.message }
53
+ return { value: r.file }
54
+ })
55
+ on('session.id', () => ({ value: r.session }))
56
+ on('ui.status', (_$, e) => {
57
+ r.statuses.push(e.text)
58
+ return { value: undefined }
59
+ })
60
+ // The engine's /clear, in the order seen live: the old session ends, the atoms reset and the id
61
+ // changes, the new session starts. The reset is the atoms reading as never written until the
62
+ // plugin writes them again.
63
+ const emptied = new Set<string>()
64
+ on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
65
+ r.writes += 1
66
+ emptied.delete(e.key)
67
+ return next(e)
68
+ })
69
+ on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
70
+ const read = await next(e)
71
+ return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
72
+ })
73
+ on('command.run', async (_$, e) => {
74
+ if (e.command !== 'clear') return { text: '' }
75
+ await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
76
+ ;['workState', 'isOrphaned', 'promptSnapshot'].forEach(key => emptied.add(key))
77
+ r.session = NEXT_SESSION
78
+ await engine.classic.SessionStart({ source: 'clear' })
79
+ return { text: '' }
80
+ })
81
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
82
+ on('classic.SessionStart', () => ({}))
83
+ on('turn.start', (_$, e) => ({ turnId: e.turnId }))
84
+ on('turn.complete', () => ({ text: '' }))
85
+ on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
86
+ on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
87
+ return r
88
+ }
89
+ type Rig = ReturnType<typeof rig>
90
+
91
+ const lastStatus = (r: Rig) => r.statuses[r.statuses.length - 1]
92
+ // The Text inside the Box keyed `key` (a Text carries no key of its own).
93
+ type Leaf = { props: Record<string, unknown>; text: string }
94
+ const leaf = async (ui: any, key: string): Promise<Leaf | undefined> => {
95
+ const box = await ui.find({ key })
96
+ const text = box?.children[0]
97
+ return text === undefined ? undefined : { props: text.props ?? {}, text: String(text.children?.join('') ?? '') }
98
+ }
99
+ const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
100
+ let turns = 0
101
+ const turn = ($: any) => $.turn.start({ text: 'go', turnId: `t${(turns += 1)}` })
102
+
103
+ for (const surface of SURFACES) {
104
+ const mountBand = ($: any, props: Record<string, unknown> = {}) =>
105
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'AbovePrompt', props: { ...BAND_PROPS, ...props } })
106
+
107
+ test(`no state file: the band yields and the status stays clear [${surface}]`, async ($, on) => {
108
+ const r = rig(on, $)
109
+ await $.session.start(START)
110
+ expect(lastStatus(r)).toBeUndefined()
111
+ const ui = await mountBand($)
112
+ expect(await ui.find({ key: 'engine' })).toBeDefined()
113
+ expect(await ui.find({ key: 'ticket' })).toBeUndefined()
114
+ expect(await ui.find({ key: 'stage-plan' })).toBeUndefined()
115
+ })
116
+
117
+ test(`a state file shows entry and band; removing it clears both [${surface}]`, async ($, on) => {
118
+ const r = rig(on, $)
119
+ r.file = stateText()
120
+ await $.session.start(START)
121
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
122
+ const ui = await mountBand($)
123
+ expect(await ui.find({ key: 'engine' })).toBeUndefined()
124
+ expect((await leaf(ui, 'ticket'))?.text).toBe('#203')
125
+ expect((await leaf(ui, 'title'))?.text).toBe(TITLE)
126
+
127
+ r.file = null
128
+ await turn($)
129
+ expect(lastStatus(r)).toBeUndefined()
130
+ expect(await ui.find({ key: 'ticket' })).toBeUndefined()
131
+ expect(await ui.find({ key: 'engine' })).toBeDefined()
132
+
133
+ r.file = stateText()
134
+ await turn($)
135
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
136
+ expect(await ui.find({ key: 'ticket' })).toBeDefined()
137
+
138
+ r.file = '{not json'
139
+ await turn($)
140
+ expect(lastStatus(r)).toBeUndefined()
141
+ expect(await ui.find({ key: 'ticket' })).toBeUndefined()
142
+ })
143
+
144
+ test(`a read error other than ENOENT keeps the last state [${surface}]`, async ($, on) => {
145
+ const r = rig(on, $)
146
+ r.file = stateText()
147
+ await $.session.start(START)
148
+ const ui = await mountBand($)
149
+ r.file = new Error('EIO: i/o error, read')
150
+ await turn($)
151
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
152
+ expect(await ui.find({ key: 'ticket' })).toBeDefined()
153
+ })
154
+
155
+ test(`the status names the map, or the whiteboard when there is none [${surface}]`, async ($, on) => {
156
+ const r = rig(on, $)
157
+ r.file = stateText({ map: null })
158
+ await $.session.start(START)
159
+ expect(lastStatus(r)).toBe(`whiteboard · #203 ${TITLE} · plan`)
160
+ r.file = stateText({ title: null })
161
+ await turn($)
162
+ expect(lastStatus(r)).toBe('map #198 · #203 · plan')
163
+ })
164
+
165
+ test(`each step highlights its stage [${surface}]`, async ($, on) => {
166
+ const r = rig(on, $)
167
+ r.file = stateText({ step: 'discuss' })
168
+ await $.session.start(START)
169
+ const ui = await mountBand($)
170
+ const names = ['discuss', 'plan', 'execute', 'verify']
171
+ const expected: Record<string, string[]> = {
172
+ discuss: ['current', 'todo', 'todo', 'todo'],
173
+ plan: ['done', 'current', 'todo', 'todo'],
174
+ execute: ['done', 'done', 'current', 'todo'],
175
+ verify: ['done', 'done', 'done', 'current'],
176
+ resolve: ['done', 'done', 'done', 'done'],
177
+ paused: ['todo', 'todo', 'todo', 'todo'],
178
+ }
179
+ for (const [step, marks] of Object.entries(expected)) {
180
+ r.file = stateText({ step })
181
+ await turn($)
182
+ for (const [i, name] of names.entries()) {
183
+ const el = await leaf(ui, `stage-${name}`)
184
+ const mark = marks[i]
185
+ if (mark === 'done') {
186
+ expect(el?.text).toBe(`✓ ${name}`)
187
+ expect(el?.props).toMatchObject({ color: GREEN })
188
+ } else if (mark === 'current') {
189
+ expect(el?.text).toBe(`▸ ${name}`)
190
+ expect(el?.props).toMatchObject({ color: ACCENT, bold: true })
191
+ } else {
192
+ expect(el?.text).toBe(name)
193
+ expect(el?.props).toMatchObject({ dimColor: true })
194
+ }
195
+ }
196
+ const tag = await leaf(ui, 'tag')
197
+ if (step === 'paused') {
198
+ expect(tag?.text).toBe(' ‖ paused')
199
+ expect(tag?.props).toMatchObject({ color: AMBER })
200
+ } else {
201
+ expect(tag).toBeUndefined()
202
+ }
203
+ }
204
+ const sep = await ui.findAll({ type: 'Text', text: ' › ' })
205
+ expect(sep).toHaveLength(3)
206
+ expect(sep[0]?.props).toMatchObject({ dimColor: true })
207
+ })
208
+
209
+ test(`row one: bold accent ticket, title truncated with an ellipsis [${surface}]`, async ($, on) => {
210
+ const r = rig(on, $)
211
+ r.file = stateText({ title: 'x'.repeat(200) })
212
+ await $.session.start(START)
213
+ const ui = await mountBand($, { bodyColumns: 40 })
214
+ const ticket = await leaf(ui, 'ticket')
215
+ expect(ticket?.props).toMatchObject({ color: ACCENT, bold: true })
216
+ const title = (await leaf(ui, 'title'))?.text ?? ''
217
+ expect(title.endsWith('…')).toBe(true)
218
+ expect('#203 '.length + title.length).toBeLessThanOrEqual(40)
219
+ // a short title is left whole
220
+ r.file = stateText({ title: 'short' })
221
+ await turn($)
222
+ expect((await leaf(ui, 'title'))?.text).toBe('short')
223
+ // the cut never splits a surrogate pair
224
+ r.file = stateText({ title: 'a' + '\u{1F680}'.repeat(60) })
225
+ await turn($)
226
+ const astral = (await leaf(ui, 'title'))?.text ?? ''
227
+ expect(astral.endsWith('…')).toBe(true)
228
+ // a lone half would reach the screen as U+FFFD or stay unpaired
229
+ expect(/\uFFFD|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(astral)).toBe(false)
230
+ })
231
+
232
+ test(`an autonomous run shows its counter, right aligned on row two [${surface}]`, async ($, on) => {
233
+ const r = rig(on, $)
234
+ r.file = stateText({ auto: true, autoDone: 3 })
235
+ await $.session.start(START)
236
+ expect(lastStatus(r)).toBe(`${STATUS_PLAN} · auto 3 done`)
237
+ const ui = await mountBand($)
238
+ const auto = await leaf(ui, 'auto')
239
+ expect(auto?.text).toBe('auto · 3 done')
240
+ expect(auto?.props).toMatchObject({ color: AMBER })
241
+ expect((await ui.find({ key: 'pipeline' }))?.props).toMatchObject({ justifyContent: 'space-between' })
242
+ r.file = stateText({ auto: false })
243
+ await turn($)
244
+ expect(await ui.find({ key: 'auto' })).toBeUndefined()
245
+ })
246
+
247
+ test(`a state from another session is orphaned: dim band, tag, status [${surface}]`, async ($, on) => {
248
+ const r = rig(on, $)
249
+ r.file = stateText({ session: 'sess-OLD' })
250
+ await $.session.start(START)
251
+ expect(lastStatus(r)).toBe(`${STATUS_PLAN} · orphaned, resume?`)
252
+ const ui = await mountBand($)
253
+ const ticket = await leaf(ui, 'ticket')
254
+ expect(ticket?.props).toMatchObject({ dimColor: true })
255
+ expect(ticket?.props).not.toHaveProperty('bold')
256
+ expect((await leaf(ui, 'tag'))?.text).toBe(' orphaned, resume?')
257
+ expect((await leaf(ui, 'tag'))?.props).toMatchObject({ color: AMBER })
258
+ expect((await leaf(ui, 'stage-discuss'))?.text).toBe('✓ discuss')
259
+ expect((await leaf(ui, 'stage-discuss'))?.props).toMatchObject({ dimColor: true })
260
+ expect((await leaf(ui, 'stage-plan'))?.text).toBe('▸ plan')
261
+ expect((await leaf(ui, 'stage-plan'))?.props).toMatchObject({ dimColor: true })
262
+ expect((await leaf(ui, 'stage-plan'))?.props).not.toHaveProperty('bold')
263
+ expect((await leaf(ui, 'stage-verify'))?.props).toMatchObject({ dimColor: true })
264
+
265
+ // resumed in this session: back to normal
266
+ r.file = stateText()
267
+ await turn($)
268
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
269
+ expect(await ui.find({ key: 'tag' })).toBeUndefined()
270
+ // a null session is never orphaned
271
+ r.file = stateText({ session: null })
272
+ await turn($)
273
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
274
+ })
275
+
276
+ test(`the band yields while a survey holds it [${surface}]`, async ($, on) => {
277
+ const r = rig(on, $)
278
+ r.file = stateText()
279
+ await $.session.start(START)
280
+ const ui = await mountBand($, { hasSurvey: true })
281
+ expect(await ui.find({ key: 'engine' })).toBeDefined()
282
+ expect(await ui.find({ key: 'ticket' })).toBeUndefined()
283
+ })
284
+
285
+ test(`a Bash call naming the helper re-reads after it ran and redraws [${surface}]`, async ($, on) => {
286
+ const r = rig(on, $)
287
+ r.file = stateText()
288
+ on('tool.call', { tool: 'Bash' }, () => {
289
+ // The helper "runs" here: the file changes before the tool answers.
290
+ r.file = stateText({ step: 'execute' })
291
+ return { result: 'ok' } as never
292
+ })
293
+ await $.session.start(START)
294
+ const ui = await mountBand($)
295
+ expect((await leaf(ui, 'stage-plan'))?.text).toBe('▸ plan')
296
+
297
+ await $.tool.call({ tool: 'Bash', command: HELPER_CALL } as never)
298
+ expect(lastStatus(r)).toBe(`map #198 · #203 ${TITLE} · execute`)
299
+ expect((await leaf(ui, 'stage-execute'))?.text).toBe('▸ execute')
300
+
301
+ // any other Bash call reads nothing
302
+ const reads = r.reads
303
+ await $.tool.call({ tool: 'Bash', command: 'ls -la' } as never)
304
+ expect(r.reads).toBe(reads)
305
+ })
306
+
307
+ test(`an unchanged file causes no status call and no atom write [${surface}]`, async ($, on) => {
308
+ const r = rig(on, $)
309
+ r.file = stateText()
310
+ await $.session.start(START)
311
+ await mountBand($)
312
+ const statuses = r.statuses.length
313
+ const writes = r.writes
314
+ await turn($)
315
+ await $.turn.complete({ turnId: 'x', answer: '', durationMs: 1, isAborted: false, reason: 'answer' } as never)
316
+ r.file = JSON.stringify(JSON.parse(stateText()), null, 4) // same content, other bytes
317
+ await turn($)
318
+ expect(r.statuses.length).toBe(statuses)
319
+ expect(r.writes).toBe(writes)
320
+
321
+ // only updatedAt moved: the state is written, the status text did not change
322
+ r.file = stateText({ updatedAt: '2026-10-11T01:00:00.000Z' })
323
+ await turn($)
324
+ expect(r.writes).toBe(writes + 1)
325
+ expect(r.statuses.length).toBe(statuses)
326
+ })
327
+
328
+ test(`/clear marks a shown state orphaned at once [${surface}]`, async ($, on) => {
329
+ const r = rig(on, $)
330
+ r.file = stateText()
331
+ await $.session.start(START)
332
+ const ui = await mountBand($)
333
+ expect(await ui.find({ key: 'tag' })).toBeUndefined()
334
+ await $.session.end({ reason: 'clear', sessionId: SESSION, resume: {} } as never)
335
+ expect(lastStatus(r)).toBe(`${STATUS_PLAN} · orphaned, resume?`)
336
+ expect((await leaf(ui, 'tag'))?.text).toBe(' orphaned, resume?')
337
+ // other reasons leave it alone
338
+ r.file = stateText()
339
+ await turn($)
340
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
341
+ await $.session.end({ reason: 'other', sessionId: SESSION, resume: {} } as never)
342
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
343
+ })
344
+
345
+ test(`after /clear the engine resets the atoms and the band shows the ticket orphaned without a turn [${surface}]`, async ($, on) => {
346
+ const r = rig(on, $)
347
+ r.file = stateText()
348
+ await $.session.start(START)
349
+ const ui = await mountBand($)
350
+ await $.command.run({ command: 'clear', args: '' } as never)
351
+ expect(lastStatus(r)).toBe(`${STATUS_PLAN} · orphaned, resume?`)
352
+ expect((await leaf(ui, 'ticket'))?.text).toBe('#203')
353
+ expect((await leaf(ui, 'tag'))?.text).toBe(' orphaned, resume?')
354
+ })
355
+
356
+ test(`/clear leaves a state with no session un-orphaned [${surface}]`, async ($, on) => {
357
+ const r = rig(on, $)
358
+ r.file = stateText({ session: null })
359
+ await $.session.start(START)
360
+ const ui = await mountBand($)
361
+ await $.session.end({ reason: 'clear', sessionId: SESSION, resume: {} } as never)
362
+ expect(lastStatus(r)).toBe(STATUS_PLAN)
363
+ expect(await ui.find({ key: 'tag' })).toBeUndefined()
364
+ })
365
+ }
@@ -0,0 +1,110 @@
1
+ import { expect, test } from 'claude-code/testing'
2
+
3
+ import { fit, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from '../hooks/mod/work-state-view'
4
+
5
+ const valid = {
6
+ map: 198,
7
+ ticket: 203,
8
+ title: 'Status entry',
9
+ step: 'plan',
10
+ auto: false,
11
+ autoDone: 0,
12
+ session: 's1',
13
+ updatedAt: '2026-10-11T00:00:00.000Z',
14
+ }
15
+ const parse = (over: Record<string, unknown>) => parseWorkState(JSON.stringify({ ...valid, ...over }))
16
+
17
+ test('parseWorkState accepts a full state and rejects what has no ticket or step', () => {
18
+ expect(parse({})).toEqual(valid)
19
+ expect(parseWorkState('{not json')).toBeNull()
20
+ expect(parseWorkState('[1]')).toBeNull()
21
+ expect(parseWorkState('null')).toBeNull()
22
+ expect(parse({ ticket: 0 })).toBeNull()
23
+ expect(parse({ ticket: 'x' })).toBeNull()
24
+ expect(parse({ step: 'nope' })).toBeNull()
25
+ expect(parse({ step: undefined })).toBeNull()
26
+ })
27
+
28
+ test('parseWorkState defaults autoDone and normalises invalid optional fields', () => {
29
+ const { autoDone: _drop, ...old } = valid
30
+ expect(parseWorkState(JSON.stringify(old))?.autoDone).toBe(0)
31
+ expect(parse({ autoDone: -1 })?.autoDone).toBe(0)
32
+ expect(parse({ autoDone: 1.5 })?.autoDone).toBe(0)
33
+ expect(parse({ autoDone: 4, auto: true })).toMatchObject({ auto: true, autoDone: 4 })
34
+ expect(parse({ map: 'zz' })?.map).toBeNull()
35
+ expect(parse({ map: null })?.map).toBeNull()
36
+ expect(parse({ title: 42 })?.title).toBeNull()
37
+ expect(parse({ title: ' a\nb ' })?.title).toBe('a b')
38
+ expect(parse({ title: ' ' })?.title).toBeNull()
39
+ expect(parse({ auto: 'yes' })?.auto).toBe(false)
40
+ expect(parse({ session: 7 })?.session).toBeNull()
41
+ expect(parse({ session: null })?.session).toBeNull()
42
+ })
43
+
44
+ test('statusLine builds the entry for each shape', () => {
45
+ const s = parse({})
46
+ expect(statusLine(s, false)).toBe('map #198 · #203 Status entry · plan')
47
+ expect(statusLine(parse({ map: null }), false)).toBe('whiteboard · #203 Status entry · plan')
48
+ expect(statusLine(parse({ title: null }), false)).toBe('map #198 · #203 · plan')
49
+ expect(statusLine(parse({ auto: true, autoDone: 3 }), false)).toBe(
50
+ 'map #198 · #203 Status entry · plan · auto 3 done',
51
+ )
52
+ expect(statusLine(s, true)).toBe('map #198 · #203 Status entry · plan · orphaned, resume?')
53
+ expect(statusLine(parse({ auto: true, autoDone: 1 }), true)).toBe(
54
+ 'map #198 · #203 Status entry · plan · auto 1 done · orphaned, resume?',
55
+ )
56
+ expect(statusLine(null, false)).toBeUndefined()
57
+ expect(statusLine(null, true)).toBeUndefined()
58
+ })
59
+
60
+ test('pipeline marks the stages for each step', () => {
61
+ const status = (step: string) => pipeline(step).stages.map(s => s.status)
62
+ expect(pipeline('plan').stages.map(s => s.name)).toEqual(['discuss', 'plan', 'execute', 'verify'])
63
+ expect(status('discuss')).toEqual(['current', 'todo', 'todo', 'todo'])
64
+ expect(status('plan')).toEqual(['done', 'current', 'todo', 'todo'])
65
+ expect(status('execute')).toEqual(['done', 'done', 'current', 'todo'])
66
+ expect(status('verify')).toEqual(['done', 'done', 'done', 'current'])
67
+ expect(status('resolve')).toEqual(['done', 'done', 'done', 'done'])
68
+ expect(status('paused')).toEqual(['todo', 'todo', 'todo', 'todo'])
69
+ expect(pipeline('paused').isPaused).toBe(true)
70
+ expect(pipeline('plan').isPaused).toBe(false)
71
+ })
72
+
73
+ test('sameValue compares by content, not by key order or reference', () => {
74
+ expect(sameValue({ a: 1, b: null }, { b: null, a: 1 })).toBe(true)
75
+ expect(sameValue(parse({}), parse({}))).toBe(true)
76
+ expect(sameValue(parse({}), parse({ step: 'execute' }))).toBe(false)
77
+ expect(sameValue(null, null)).toBe(true)
78
+ expect(sameValue(null, parse({}))).toBe(false)
79
+ })
80
+
81
+ test('fit cuts with an ellipsis by code point and leaves a short text whole', () => {
82
+ expect(fit('short', 10)).toBe('short')
83
+ expect(fit('abcdef', 4)).toBe('abc…')
84
+ expect(fit('abcdef', 1)).toBe('…')
85
+ expect(fit('a' + '\u{1F680}'.repeat(5), 3)).toBe('a\u{1F680}…')
86
+ })
87
+
88
+ // nextOwnTicket(prev, lastState, nextState, session): the ticket this session owns across a shared file.
89
+ const stamped = (ticket: number, session: string | null) => ({ ...valid, ticket, session })
90
+
91
+ test('nextOwnTicket takes the ticket of a state stamped with this session', () => {
92
+ expect(nextOwnTicket(null, null, stamped(259, 's1'), 's1')).toBe(259)
93
+ expect(nextOwnTicket(212, stamped(212, 's1'), stamped(259, 's1'), 's1')).toBe(259)
94
+ // a state with no session is this one's, like the orphan rule
95
+ expect(nextOwnTicket(null, null, stamped(259, null), 's1')).toBe(259)
96
+ })
97
+
98
+ test('nextOwnTicket leaves the ticket alone when another session wrote the file', () => {
99
+ expect(nextOwnTicket(259, stamped(259, 's1'), stamped(212, 's2'), 's1')).toBe(259)
100
+ expect(nextOwnTicket(null, null, stamped(212, 's2'), 's1')).toBeNull()
101
+ })
102
+
103
+ test('nextOwnTicket clears on a missing file only when the last state read was this session\'s', () => {
104
+ expect(nextOwnTicket(259, stamped(259, 's1'), null, 's1')).toBeNull()
105
+ expect(nextOwnTicket(259, stamped(259, null), null, 's1')).toBeNull()
106
+ // own state, then another session's, then a missing file: the last read was not ours
107
+ expect(nextOwnTicket(259, stamped(212, 's2'), null, 's1')).toBe(259)
108
+ expect(nextOwnTicket(259, null, null, 's1')).toBe(259)
109
+ expect(nextOwnTicket(null, null, null, 's1')).toBeNull()
110
+ })
@@ -0,0 +1,103 @@
1
+ // Typed contract of the trailhead mod: the shared work state (see #200).
2
+ export type WorkState = {
3
+ map: number | null
4
+ ticket: number | null
5
+ title: string | null
6
+ step: string | null
7
+ auto: boolean
8
+ autoDone: number
9
+ session: string | null
10
+ updatedAt: string | null
11
+ }
12
+
13
+ // The latched system-prompt section: the key it was built for and its text (null: no section).
14
+ export type PromptSnapshot = { key: string; text: string | null }
15
+
16
+ // The map pane (#208): what the last `gh` read of the active map gave.
17
+ export type TicketType = 'decision' | 'research' | 'prototype' | 'build' | 'bug' | 'task'
18
+ export type TicketState = 'frontier' | 'in-progress' | 'paused' | 'blocked'
19
+
20
+ // The parent a pending UAT ticket tests (from its `UAT of:` body line); title is empty for a bare `#<n>`.
21
+ export type UatParent = { number: number; title: string }
22
+
23
+ export type PaneTicket = {
24
+ number: number
25
+ title: string
26
+ type: TicketType | null
27
+ state: TicketState
28
+ assignees: readonly string[]
29
+ url: string
30
+ // Carries `trailhead:uat`: a marker beside `state`, only the grouping moves it (paused still wins).
31
+ isUat: boolean
32
+ // The parent it tests, once read; null when unknown (no body read, or no usable `UAT of:` line).
33
+ uatOf: UatParent | null
34
+ }
35
+
36
+ // A map's sub-issue counts: `open` still to do, `total` all of them.
37
+ export type MapCounts = { open: number; total: number }
38
+ // One open map of the repo, as the maps list shows it; `counts` null = unknown, drawn name-only.
39
+ export type MapSummary = { number: number; title: string; counts: MapCounts | null }
40
+
41
+ // What the maps read gives: the open maps and the whiteboard's counts (null when they were unreadable).
42
+ export type MapsRead = { maps: readonly MapSummary[]; whiteboard: MapCounts | null }
43
+
44
+ export type MapInfo = { number: number; title: string; destination: string | null; url: string }
45
+
46
+ // One ticket of the map's history, in epoch ms; `closedAt` null while open.
47
+ export type BurndownIssue = { openedAt: number; closedAt: number | null; isRemoved: boolean }
48
+ // `isCapped`: gh returned as many rows as it was asked for, so the oldest tickets may be missing.
49
+ export type BurndownHistory = { issues: readonly BurndownIssue[]; isCapped: boolean }
50
+
51
+ // `map` and `tickets` keep the last good read through a failure; `error` is its reason (null: healthy).
52
+ // `history` has its own last good read and time (`historyAt`): a history-only failure keeps it.
53
+ export type MapPaneData = {
54
+ map: MapInfo | null
55
+ tickets: readonly PaneTicket[]
56
+ unverifiedCount: number
57
+ isCapped: boolean
58
+ // The repo's open maps (empty until a first good read, then the last good one).
59
+ maps: readonly MapSummary[]
60
+ // The whiteboard's open and total tickets; null = unknown, the Whiteboard row is drawn name-only.
61
+ whiteboard: MapCounts | null
62
+ history: BurndownHistory | null
63
+ historyAt: number | null
64
+ fetchedAt: number | null
65
+ error: string | null
66
+ isLoading: boolean
67
+ }
68
+
69
+ // The pinned per-repo index issue (`trailhead:dashboard`), as the pane links it.
70
+ export type DashboardRef = { number: number; url: string }
71
+
72
+ // The pane's dashboard view: the whiteboard tickets (map-less) and the pinned index; the open maps
73
+ // stay in `MapPaneData.maps`. Same failure rule: the last good read is kept, `error` says why.
74
+ export type DashboardPaneData = {
75
+ dashboard: DashboardRef | null
76
+ tickets: readonly PaneTicket[]
77
+ unverifiedCount: number
78
+ isCapped: boolean
79
+ fetchedAt: number | null
80
+ error: string | null
81
+ isLoading: boolean
82
+ }
83
+
84
+ declare module 'claude-code' {
85
+ interface PluginState {
86
+ trailhead: {
87
+ workState: WorkState | null
88
+ isOrphaned: boolean
89
+ // The ticket this session works, kept when another session's state takes over the shared file.
90
+ ownTicket: number | null
91
+ promptSnapshot: PromptSnapshot | null
92
+ mapPane: MapPaneData
93
+ activeMap: number | null
94
+ // The map the pane shows when it is not the active one; null follows the active map.
95
+ viewedMap: number | null
96
+ // The pane shows the dashboard (maps index + whiteboard) instead of a map.
97
+ isDashboard: boolean
98
+ dashboardPane: DashboardPaneData
99
+ pendingWork: number | null
100
+ paneAutoOpened: boolean
101
+ }
102
+ }
103
+ }