@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.
- package/README.it.md +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- 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
|
+
}
|