@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.
- package/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import type { On } from 'claude-code'
|
|
2
|
+
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
|
|
4
|
+
import { resetAutoRun } from '../hooks/mod/auto-run'
|
|
5
|
+
import { resetLocate } from '../hooks/mod/work-state-locate'
|
|
6
|
+
|
|
7
|
+
const SESSION = 'sess-A'
|
|
8
|
+
const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
|
|
9
|
+
const FILE = '/.trailhead/work-state.json'
|
|
10
|
+
const RECORD = '/repo/.trailhead/auto-run.json'
|
|
11
|
+
const HINT = 'auto interrupted · /trailhead:auto to resume'
|
|
12
|
+
const HINT_STATUS = 'auto interrupted, /trailhead:auto to resume'
|
|
13
|
+
const HELPER_CALL = 'node trailhead-work-state.js set --repo . --step execute'
|
|
14
|
+
const BAND_PROPS = { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 80, scroll: { offset: 0, bodyRows: 10 }, view: {} } as const
|
|
15
|
+
const SUMMARY = [{ role: 'user', text: 'summary', toolUses: [] }]
|
|
16
|
+
|
|
17
|
+
// Uno stato di slot: senza sessione, auto, del ticket dato.
|
|
18
|
+
const slot = (ticket: number, over: Record<string, unknown> = {}): string =>
|
|
19
|
+
JSON.stringify({ map: 42, ticket, title: `T${ticket}`, step: 'execute', auto: true, autoDone: 0, session: null, updatedAt: '2026-10-21T00:00:00.000Z', ...over })
|
|
20
|
+
|
|
21
|
+
const wt = (path: string, branch: string | null) => `worktree ${path}\nHEAD abc\n${branch === null ? 'detached' : `branch ${branch}`}\n`
|
|
22
|
+
const porcelain = (...blocks: string[]) => blocks.join('\n')
|
|
23
|
+
const slotBranch = (n: number) => `refs/heads/trailhead/t${n}`
|
|
24
|
+
|
|
25
|
+
const rig = (on: On) => {
|
|
26
|
+
const r = {
|
|
27
|
+
files: {} as Record<string, string>,
|
|
28
|
+
record: null as string | null,
|
|
29
|
+
cwd: '/repo',
|
|
30
|
+
toplevel: '/repo',
|
|
31
|
+
now: 1_000_000,
|
|
32
|
+
worktrees: porcelain(wt('/repo', 'refs/heads/main')) as string | Error,
|
|
33
|
+
siblings: [] as string[],
|
|
34
|
+
// Per cartella fratello: git dir e HEAD con cui risponde git.
|
|
35
|
+
clones: {} as Record<string, { gitDir: string; head: string }>,
|
|
36
|
+
counts: { needsYou: 0, paused: 0 },
|
|
37
|
+
ghFails: false,
|
|
38
|
+
runs: [] as string[],
|
|
39
|
+
cwds: [] as string[],
|
|
40
|
+
reads: [] as string[],
|
|
41
|
+
statuses: [] as (string | undefined)[],
|
|
42
|
+
dropAuto: false,
|
|
43
|
+
clockDown: false,
|
|
44
|
+
}
|
|
45
|
+
on('session.repo', () => ({ value: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } }))
|
|
46
|
+
on('clock.now', () => (r.clockDown ? { deny: 'no clock' } : { value: r.now }))
|
|
47
|
+
on('session.cwd', () => ({ value: r.cwd }))
|
|
48
|
+
on('session.id', () => ({ value: SESSION }))
|
|
49
|
+
on('process.run', (_$, e) => {
|
|
50
|
+
const line = e.argv.join(' ')
|
|
51
|
+
r.runs.push(line)
|
|
52
|
+
r.cwds.push(e.cwd ?? '')
|
|
53
|
+
const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
|
|
54
|
+
if (line === 'git rev-parse --show-toplevel') return out(`${r.toplevel}\n`)
|
|
55
|
+
if (line === 'git worktree list --porcelain') return r.worktrees instanceof Error ? { deny: r.worktrees.message } : out(r.worktrees)
|
|
56
|
+
const clone = /^git -C (\S+) (rev-parse --absolute-git-dir|symbolic-ref --quiet HEAD)$/.exec(line)
|
|
57
|
+
if (clone !== null) {
|
|
58
|
+
const c = r.clones[clone[1]]
|
|
59
|
+
if (c === undefined) return out('', 128)
|
|
60
|
+
return out(clone[2].startsWith('rev-parse') ? c.gitDir : c.head)
|
|
61
|
+
}
|
|
62
|
+
if (line.startsWith('gh api graphql')) {
|
|
63
|
+
if (r.ghFails) return out('', 1)
|
|
64
|
+
return out(JSON.stringify({ data: { needsYou: { issueCount: r.counts.needsYou }, paused: { issueCount: r.counts.paused } } }))
|
|
65
|
+
}
|
|
66
|
+
return out('')
|
|
67
|
+
})
|
|
68
|
+
on('fs.list', () => ({ value: r.siblings.map(name => ({ name, kind: 'dir' as const, size: 0, mtimeMs: 0, isLink: false })) }))
|
|
69
|
+
on('fs.read', (_$, e) => {
|
|
70
|
+
r.reads.push(e.path)
|
|
71
|
+
if (e.path === RECORD) return r.record === null ? { deny: `ENOENT: no such file or directory, open '${e.path}'` } : { value: r.record }
|
|
72
|
+
const root = e.path.endsWith(FILE) ? e.path.slice(0, -FILE.length) : null
|
|
73
|
+
const text = root === null ? undefined : r.files[root]
|
|
74
|
+
return text === undefined ? { deny: `ENOENT: no such file or directory, open '${e.path}'` } : { value: text }
|
|
75
|
+
})
|
|
76
|
+
on('ui.status', (_$, e) => {
|
|
77
|
+
r.statuses.push(e.text)
|
|
78
|
+
return { value: undefined }
|
|
79
|
+
})
|
|
80
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
81
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
82
|
+
on('turn.complete', () => ({ text: '' }))
|
|
83
|
+
on('session.compact', () => ({ messages: SUMMARY }))
|
|
84
|
+
on('prompt.submit', (_$, e) => (r.dropAuto ? { drop: 'busy' } : { text: e.text }))
|
|
85
|
+
on('tool.call', () => ({ result: '' }) as never)
|
|
86
|
+
on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
|
|
87
|
+
return r
|
|
88
|
+
}
|
|
89
|
+
type Rig = ReturnType<typeof rig>
|
|
90
|
+
|
|
91
|
+
const last = (r: Rig) => r.statuses[r.statuses.length - 1]
|
|
92
|
+
const ghRuns = (r: Rig) => r.runs.filter(line => line.startsWith('gh '))
|
|
93
|
+
const wtRuns = (r: Rig) => r.runs.filter(line => line.startsWith('git worktree'))
|
|
94
|
+
let turns = 0
|
|
95
|
+
const turnId = () => `t${(turns += 1)}`
|
|
96
|
+
// Un turno intero del loop principale, che finisce normalmente o interrotto.
|
|
97
|
+
const runTurn = async ($: any, aborted = false) => {
|
|
98
|
+
const id = turnId()
|
|
99
|
+
await $.turn.start({ text: 'go', turnId: id })
|
|
100
|
+
await $.turn.complete({ turnId: id, answer: '', durationMs: 1, isAborted: aborted, reason: aborted ? 'aborted' : 'answer' } as never)
|
|
101
|
+
}
|
|
102
|
+
// Un solo refresh (un turno ne fa due, avvio e fine): serve a contare i fallimenti uno a uno.
|
|
103
|
+
const refresh = ($: any) => $.tool.call({ tool: 'Bash', command: HELPER_CALL } as never)
|
|
104
|
+
const compact = ($: any, extra: Record<string, unknown> = {}) => $.session.compact({ messages: SUMMARY, ...extra })
|
|
105
|
+
|
|
106
|
+
// Tre slot nel repo: t5 e t6 in volo, t7 in pausa.
|
|
107
|
+
const threeSlots = (r: Rig) => {
|
|
108
|
+
r.record = JSON.stringify({ version: 1, scope: 'map:42' })
|
|
109
|
+
r.worktrees = porcelain(wt('/repo', 'refs/heads/main'), wt('/repo-t5', slotBranch(5)), wt('/repo-t6', slotBranch(6)), wt('/repo-t7', slotBranch(7)))
|
|
110
|
+
r.files['/repo-t5'] = slot(5, { autoDone: 4 })
|
|
111
|
+
r.files['/repo-t6'] = slot(6, { step: 'verify', autoDone: 1 })
|
|
112
|
+
r.files['/repo-t7'] = slot(7, { step: 'paused' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const band = async ($: any) => {
|
|
116
|
+
const ui = await $.ui.mount({ plugin: 'trailhead', surface: 'terminal', component: 'AbovePrompt', props: { ...BAND_PROPS } })
|
|
117
|
+
const box = await ui.find({ key: 'run' })
|
|
118
|
+
const own = await ui.find({ key: 'auto' })
|
|
119
|
+
const node = box ?? own
|
|
120
|
+
const text = node?.children[0]
|
|
121
|
+
return node === undefined ? undefined : String(text?.children?.join('') ?? '')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fresh = () => {
|
|
125
|
+
resetLocate()
|
|
126
|
+
resetAutoRun()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test('several sessionless slots show done and in flight in the status and the band, in the first status', async ($, on) => {
|
|
130
|
+
fresh()
|
|
131
|
+
const r = rig(on)
|
|
132
|
+
threeSlots(r)
|
|
133
|
+
await $.session.start(START)
|
|
134
|
+
expect(r.statuses[0]).toBe('auto · 4 done · 2 in flight')
|
|
135
|
+
expect(await band($)).toBe('auto · 4 done · 2 in flight')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('needs-you and paused counts come from one gh read scoped by the record', async ($, on) => {
|
|
139
|
+
fresh()
|
|
140
|
+
const r = rig(on)
|
|
141
|
+
threeSlots(r)
|
|
142
|
+
r.counts = { needsYou: 1, paused: 2 }
|
|
143
|
+
await $.session.start(START)
|
|
144
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight · 1 needs you · 2 paused')
|
|
145
|
+
expect(await band($)).toBe('auto · 4 done · 2 in flight · 1 needs you · 2 paused')
|
|
146
|
+
const calls = ghRuns(r)
|
|
147
|
+
expect(calls).toHaveLength(1)
|
|
148
|
+
expect(calls[0]).toContain('label:"trailhead:map-42"')
|
|
149
|
+
expect(calls[0]).toContain('repo:o/r')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('an everything scope reads repo-wide counts', async ($, on) => {
|
|
153
|
+
fresh()
|
|
154
|
+
const r = rig(on)
|
|
155
|
+
threeSlots(r)
|
|
156
|
+
r.record = JSON.stringify({ scope: 'everything' })
|
|
157
|
+
r.counts = { needsYou: 3, paused: 0 }
|
|
158
|
+
await $.session.start(START)
|
|
159
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight · 3 needs you')
|
|
160
|
+
expect(ghRuns(r)[0]).not.toContain('trailhead:map-')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('the counts are read at most once per 15 s and a failed read keeps the last ones', async ($, on) => {
|
|
164
|
+
fresh()
|
|
165
|
+
const r = rig(on)
|
|
166
|
+
threeSlots(r)
|
|
167
|
+
r.counts = { needsYou: 1, paused: 0 }
|
|
168
|
+
await $.session.start(START)
|
|
169
|
+
await runTurn($)
|
|
170
|
+
r.now += 14_000
|
|
171
|
+
await runTurn($)
|
|
172
|
+
expect(ghRuns(r)).toHaveLength(1)
|
|
173
|
+
r.now += 2_000
|
|
174
|
+
r.ghFails = true
|
|
175
|
+
r.counts = { needsYou: 9, paused: 9 }
|
|
176
|
+
await runTurn($)
|
|
177
|
+
expect(ghRuns(r)).toHaveLength(2)
|
|
178
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight · 1 needs you')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('only real slots count: wrong branch, auto false, ticket mismatch, a sibling that is not an independent checkout', async ($, on) => {
|
|
182
|
+
fresh()
|
|
183
|
+
const r = rig(on)
|
|
184
|
+
r.record = JSON.stringify({ scope: 'everything' })
|
|
185
|
+
r.worktrees = porcelain(
|
|
186
|
+
wt('/repo', 'refs/heads/main'),
|
|
187
|
+
wt('/repo-t5', slotBranch(5)),
|
|
188
|
+
wt('/repo-t6', 'refs/heads/feature/x'),
|
|
189
|
+
wt('/repo-t7', slotBranch(7)),
|
|
190
|
+
wt('/repo-t8', slotBranch(8)),
|
|
191
|
+
)
|
|
192
|
+
r.siblings = ['repo-t9', 'repo-t10']
|
|
193
|
+
r.files['/repo-t5'] = slot(5, { autoDone: 2 })
|
|
194
|
+
r.files['/repo-t6'] = slot(6, { autoDone: 50 })
|
|
195
|
+
r.files['/repo-t7'] = slot(7, { auto: false, autoDone: 60 })
|
|
196
|
+
r.files['/repo-t8'] = slot(99, { autoDone: 70 })
|
|
197
|
+
// t9 è un worktree collegato (git dir fuori dalla cartella); t10 è un clone vero sul branch giusto.
|
|
198
|
+
r.files['/repo-t9'] = slot(9, { autoDone: 80 })
|
|
199
|
+
r.files['/repo-t10'] = slot(10, { autoDone: 3 })
|
|
200
|
+
r.clones['/repo-t9'] = { gitDir: '/repo/.git/worktrees/repo-t9', head: slotBranch(9) }
|
|
201
|
+
r.clones['/repo-t10'] = { gitDir: '/repo-t10/.git', head: slotBranch(10) }
|
|
202
|
+
await $.session.start(START)
|
|
203
|
+
expect(last(r)).toBe('auto · 3 done · 2 in flight')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('a sibling clone is checked with git only when its state is auto for its own ticket', async ($, on) => {
|
|
207
|
+
fresh()
|
|
208
|
+
const r = rig(on)
|
|
209
|
+
r.record = JSON.stringify({ scope: 'everything' })
|
|
210
|
+
r.siblings = ['repo-t4', 'repo-t5']
|
|
211
|
+
r.files['/repo-t4'] = slot(4, { auto: false })
|
|
212
|
+
r.files['/repo-t5'] = slot(6)
|
|
213
|
+
await $.session.start(START)
|
|
214
|
+
expect(r.runs.filter(line => line.startsWith('git -C'))).toEqual([])
|
|
215
|
+
expect(last(r)).toBeUndefined()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('a session outside a run costs one record read: no git scan, no gh', async ($, on) => {
|
|
219
|
+
fresh()
|
|
220
|
+
const r = rig(on)
|
|
221
|
+
r.files['/repo'] = slot(3, { auto: false, session: SESSION })
|
|
222
|
+
await $.session.start(START)
|
|
223
|
+
await runTurn($)
|
|
224
|
+
expect(wtRuns(r)).toEqual([])
|
|
225
|
+
expect(ghRuns(r)).toEqual([])
|
|
226
|
+
expect(r.reads.filter(path => path === RECORD).length).toBeGreaterThan(0)
|
|
227
|
+
expect(await band($)).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('an own auto state with no record still opens the scan', async ($, on) => {
|
|
231
|
+
fresh()
|
|
232
|
+
const r = rig(on)
|
|
233
|
+
r.files['/repo'] = slot(3, { session: SESSION, autoDone: 2 })
|
|
234
|
+
await $.session.start(START)
|
|
235
|
+
expect(wtRuns(r).length).toBeGreaterThan(0)
|
|
236
|
+
expect(last(r)).toBe('map #42 · #3 T3 · execute · auto 2 done · 1 in flight')
|
|
237
|
+
expect(await band($)).toBe('auto · 2 done · 1 in flight')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('a session in a linked worktree still resolves the main checkout from session.repo', async ($, on) => {
|
|
241
|
+
fresh()
|
|
242
|
+
const r = rig(on)
|
|
243
|
+
threeSlots(r)
|
|
244
|
+
r.cwd = '/repo-t5'
|
|
245
|
+
r.toplevel = '/repo-t5'
|
|
246
|
+
await $.session.start({ ...START, cwd: '/repo-t5' })
|
|
247
|
+
expect(r.reads).toContain(RECORD)
|
|
248
|
+
expect(last(r)).toContain('auto 4 done')
|
|
249
|
+
expect(last(r)).toContain('2 in flight')
|
|
250
|
+
expect(wtRuns(r).length).toBeGreaterThan(0)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('a failed scan keeps the last run for two failures, the third clears it; a clean miss clears at once', async ($, on) => {
|
|
254
|
+
fresh()
|
|
255
|
+
const r = rig(on)
|
|
256
|
+
threeSlots(r)
|
|
257
|
+
await $.session.start(START)
|
|
258
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
259
|
+
r.worktrees = new Error('git exploded')
|
|
260
|
+
for (const kept of [true, true, false]) {
|
|
261
|
+
r.now += 20_000
|
|
262
|
+
await refresh($)
|
|
263
|
+
expect(last(r) !== undefined).toBe(kept)
|
|
264
|
+
}
|
|
265
|
+
r.worktrees = porcelain(wt('/repo', 'refs/heads/main'), wt('/repo-t5', slotBranch(5)))
|
|
266
|
+
r.now += 20_000
|
|
267
|
+
await refresh($)
|
|
268
|
+
expect(last(r)).toBe('auto · 4 done · 1 in flight')
|
|
269
|
+
r.files = {}
|
|
270
|
+
await refresh($)
|
|
271
|
+
expect(last(r)).toBeUndefined()
|
|
272
|
+
expect(await band($)).toBeUndefined()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('failures inside one window count once: three refreshes in a row keep the run', async ($, on) => {
|
|
276
|
+
fresh()
|
|
277
|
+
const r = rig(on)
|
|
278
|
+
threeSlots(r)
|
|
279
|
+
await $.session.start(START)
|
|
280
|
+
r.worktrees = new Error('git exploded')
|
|
281
|
+
for (let i = 0; i < 6; i += 1) {
|
|
282
|
+
await refresh($)
|
|
283
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
284
|
+
}
|
|
285
|
+
r.now += 14_000
|
|
286
|
+
await refresh($)
|
|
287
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('failures spaced past the window clear the run on the third', async ($, on) => {
|
|
291
|
+
fresh()
|
|
292
|
+
const r = rig(on)
|
|
293
|
+
threeSlots(r)
|
|
294
|
+
await $.session.start(START)
|
|
295
|
+
r.worktrees = new Error('git exploded')
|
|
296
|
+
for (const kept of [true, true, false]) {
|
|
297
|
+
r.now += 15_000
|
|
298
|
+
await refresh($)
|
|
299
|
+
await refresh($)
|
|
300
|
+
expect(last(r) !== undefined).toBe(kept)
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('a success resets the failure count', async ($, on) => {
|
|
305
|
+
fresh()
|
|
306
|
+
const r = rig(on)
|
|
307
|
+
threeSlots(r)
|
|
308
|
+
await $.session.start(START)
|
|
309
|
+
const good = r.worktrees
|
|
310
|
+
for (let i = 0; i < 2; i += 1) {
|
|
311
|
+
r.worktrees = new Error('git exploded')
|
|
312
|
+
r.now += 15_000
|
|
313
|
+
await refresh($)
|
|
314
|
+
}
|
|
315
|
+
r.worktrees = good
|
|
316
|
+
r.now += 15_000
|
|
317
|
+
await refresh($)
|
|
318
|
+
r.worktrees = new Error('git exploded')
|
|
319
|
+
for (const kept of [true, true, false]) {
|
|
320
|
+
r.now += 15_000
|
|
321
|
+
await refresh($)
|
|
322
|
+
expect(last(r) !== undefined).toBe(kept)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('the counts read goes to the pin\'s own host', async ($, on) => {
|
|
327
|
+
fresh()
|
|
328
|
+
const r = rig(on)
|
|
329
|
+
threeSlots(r)
|
|
330
|
+
await $.session.start(START)
|
|
331
|
+
expect(ghRuns(r)[0]).toContain('gh api graphql --hostname github.com')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('with no clock the counts are never read unthrottled', async ($, on) => {
|
|
335
|
+
fresh()
|
|
336
|
+
const r = rig(on)
|
|
337
|
+
threeSlots(r)
|
|
338
|
+
r.clockDown = true
|
|
339
|
+
await $.session.start(START)
|
|
340
|
+
await refresh($)
|
|
341
|
+
expect(ghRuns(r)).toEqual([])
|
|
342
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('Esc shows the resume hint in the status and the band; a subagent abort does not', async ($, on) => {
|
|
346
|
+
fresh()
|
|
347
|
+
const r = rig(on)
|
|
348
|
+
threeSlots(r)
|
|
349
|
+
await $.session.start(START)
|
|
350
|
+
await $.turn.complete({ turnId: 'sub', agentId: 'agent-1', answer: '', durationMs: 1, isAborted: true, reason: 'aborted' } as never)
|
|
351
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
352
|
+
await runTurn($, true)
|
|
353
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
354
|
+
expect(await band($)).toBe(HINT)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('the hint rides the own state too: status suffix and the right-hand band text', async ($, on) => {
|
|
358
|
+
fresh()
|
|
359
|
+
const r = rig(on)
|
|
360
|
+
r.files['/repo'] = slot(3, { session: SESSION })
|
|
361
|
+
await $.session.start(START)
|
|
362
|
+
await runTurn($, true)
|
|
363
|
+
expect(last(r)).toBe(`map #42 · #3 T3 · execute · auto 0 done · 1 in flight · ${HINT_STATUS}`)
|
|
364
|
+
expect(await band($)).toBe(HINT)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('a turn that ends normally is no interruption', async ($, on) => {
|
|
368
|
+
fresh()
|
|
369
|
+
const r = rig(on)
|
|
370
|
+
threeSlots(r)
|
|
371
|
+
await $.session.start(START)
|
|
372
|
+
await runTurn($)
|
|
373
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('a compaction between turns shows the hint; a skipped or subagent one does not', async ($, on) => {
|
|
377
|
+
fresh()
|
|
378
|
+
const r = rig(on)
|
|
379
|
+
threeSlots(r)
|
|
380
|
+
await $.session.start(START)
|
|
381
|
+
await compact($, { agentId: 'sub-1' })
|
|
382
|
+
await compact($, { trigger: 'precompute' })
|
|
383
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
384
|
+
await compact($)
|
|
385
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('a compaction inside a running turn sets nothing until the turn ends aborted', async ($, on) => {
|
|
389
|
+
fresh()
|
|
390
|
+
const r = rig(on)
|
|
391
|
+
threeSlots(r)
|
|
392
|
+
await $.session.start(START)
|
|
393
|
+
const id = turnId()
|
|
394
|
+
await $.turn.start({ text: 'go', turnId: id })
|
|
395
|
+
await compact($)
|
|
396
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
397
|
+
await $.turn.complete({ turnId: id, answer: '', durationMs: 1, isAborted: true, reason: 'aborted' } as never)
|
|
398
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
399
|
+
const id2 = turnId()
|
|
400
|
+
await $.turn.start({ text: 'go', turnId: id2 })
|
|
401
|
+
await compact($)
|
|
402
|
+
await $.turn.complete({ turnId: id2, answer: '', durationMs: 1, isAborted: false, reason: 'answer' } as never)
|
|
403
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('the next main turn clears the hint; a subagent turn start does not', async ($, on) => {
|
|
407
|
+
fresh()
|
|
408
|
+
const r = rig(on)
|
|
409
|
+
threeSlots(r)
|
|
410
|
+
await $.session.start(START)
|
|
411
|
+
await runTurn($, true)
|
|
412
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
413
|
+
await $.turn.start({ text: 'x', turnId: 'sub-t', agentId: 'agent-1' })
|
|
414
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
415
|
+
await $.turn.start({ text: 'x', turnId: turnId() })
|
|
416
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('submitting /trailhead:auto clears the hint; a dropped submit leaves it', async ($, on) => {
|
|
420
|
+
fresh()
|
|
421
|
+
const r = rig(on)
|
|
422
|
+
threeSlots(r)
|
|
423
|
+
await $.session.start(START)
|
|
424
|
+
await runTurn($, true)
|
|
425
|
+
r.dropAuto = true
|
|
426
|
+
await $.prompt.submit({ text: '/trailhead:auto', command: 'trailhead:auto' } as never)
|
|
427
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
428
|
+
r.dropAuto = false
|
|
429
|
+
await $.prompt.submit({ text: '/trailhead:auto', command: 'trailhead:auto' } as never)
|
|
430
|
+
expect(last(r)).toBe('auto · 4 done · 2 in flight')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('the hint is hidden once no run member is left', async ($, on) => {
|
|
434
|
+
fresh()
|
|
435
|
+
const r = rig(on)
|
|
436
|
+
threeSlots(r)
|
|
437
|
+
await $.session.start(START)
|
|
438
|
+
await runTurn($, true)
|
|
439
|
+
expect(last(r)).toBe(HINT_STATUS)
|
|
440
|
+
r.files = { '/repo-t5': slot(5, { auto: false }), '/repo-t6': slot(6, { auto: false }), '/repo-t7': slot(7, { auto: false }) }
|
|
441
|
+
await refresh($)
|
|
442
|
+
expect(last(r)).toBeUndefined()
|
|
443
|
+
expect(await band($)).toBeUndefined()
|
|
444
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RESUME_HINT,
|
|
5
|
+
aggregateRun,
|
|
6
|
+
isRunMember,
|
|
7
|
+
nextRunAfterFailure,
|
|
8
|
+
parseRunCounts,
|
|
9
|
+
parseRunScope,
|
|
10
|
+
runCountsArgs,
|
|
11
|
+
runLabel,
|
|
12
|
+
showsResumeHint,
|
|
13
|
+
slotTicket,
|
|
14
|
+
} from '../hooks/mod/auto-run-view'
|
|
15
|
+
import { MAX_FAILURES, parseWorktreeBranches } from '../hooks/mod/worktree-porcelain'
|
|
16
|
+
|
|
17
|
+
const state = (over: Record<string, unknown> = {}) => ({
|
|
18
|
+
map: 1,
|
|
19
|
+
ticket: 5,
|
|
20
|
+
title: null,
|
|
21
|
+
step: 'execute',
|
|
22
|
+
auto: true,
|
|
23
|
+
autoDone: 0,
|
|
24
|
+
session: null,
|
|
25
|
+
updatedAt: null,
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('parseWorktreeBranches reads path and branch per block, null on a detached head', () => {
|
|
30
|
+
const text = 'worktree /r\nHEAD a\nbranch refs/heads/main\n\nworktree /r-t5\nHEAD b\nbranch refs/heads/trailhead/t5\n\nworktree /d\nHEAD c\ndetached\n'
|
|
31
|
+
expect(parseWorktreeBranches(text)).toEqual([
|
|
32
|
+
{ path: '/r', branch: 'refs/heads/main' },
|
|
33
|
+
{ path: '/r-t5', branch: 'refs/heads/trailhead/t5' },
|
|
34
|
+
{ path: '/d', branch: null },
|
|
35
|
+
])
|
|
36
|
+
expect(parseWorktreeBranches('')).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('slotTicket only takes trailhead/t<n> branches', () => {
|
|
40
|
+
expect(slotTicket('refs/heads/trailhead/t12')).toBe(12)
|
|
41
|
+
expect(slotTicket('refs/heads/trailhead/t0')).toBeNull()
|
|
42
|
+
expect(slotTicket('refs/heads/trailhead/t12x')).toBeNull()
|
|
43
|
+
expect(slotTicket('refs/heads/feature/t12')).toBeNull()
|
|
44
|
+
expect(slotTicket(null)).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('isRunMember: auto state in the main checkout, a slot worktree or an independent clone on the slot branch', () => {
|
|
48
|
+
expect(isRunMember({ kind: 'main' }, state())).toBe(true)
|
|
49
|
+
expect(isRunMember({ kind: 'main' }, state({ auto: false }))).toBe(false)
|
|
50
|
+
expect(isRunMember({ kind: 'worktree', ticket: 5 }, state())).toBe(true)
|
|
51
|
+
expect(isRunMember({ kind: 'worktree', ticket: 6 }, state())).toBe(false)
|
|
52
|
+
expect(isRunMember({ kind: 'worktree', ticket: 5 }, state({ auto: false }))).toBe(false)
|
|
53
|
+
expect(isRunMember({ kind: 'clone', ticket: 5, onBranch: true }, state())).toBe(true)
|
|
54
|
+
expect(isRunMember({ kind: 'clone', ticket: 5, onBranch: false }, state())).toBe(false)
|
|
55
|
+
expect(isRunMember({ kind: 'clone', ticket: 6, onBranch: true }, state())).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('aggregateRun: max autoDone, in flight = steps not paused, none = no run', () => {
|
|
59
|
+
expect(aggregateRun([])).toBeNull()
|
|
60
|
+
expect(aggregateRun([state({ autoDone: 2 }), state({ autoDone: 4, step: 'paused' }), state({ step: 'verify' })])).toEqual({ done: 4, inFlight: 2 })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('parseRunScope maps the record scope to a label; repo-wide is null', () => {
|
|
64
|
+
expect(parseRunScope('{"scope":"map:42"}')).toBe('trailhead:map-42')
|
|
65
|
+
expect(parseRunScope('{"scope":"whiteboard"}')).toBe('trailhead:whiteboard')
|
|
66
|
+
expect(parseRunScope('{"scope":"everything"}')).toBeNull()
|
|
67
|
+
expect(parseRunScope('{"scope":"map:x"}')).toBeNull()
|
|
68
|
+
expect(parseRunScope('{"scope":"map:0"}')).toBeNull()
|
|
69
|
+
expect(parseRunScope('nope')).toBeNull()
|
|
70
|
+
expect(parseRunScope('{}')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('runCountsArgs carries two searches, narrowed by the scope label', () => {
|
|
74
|
+
const wide = runCountsArgs('github.com', 'o/r', null).join(' ')
|
|
75
|
+
expect(wide).toContain('api graphql --hostname github.com')
|
|
76
|
+
expect(wide).toContain('needs=repo:o/r is:open label:"trailhead:ticket" label:"trailhead:needs-you"')
|
|
77
|
+
expect(wide).toContain('paused=repo:o/r is:open label:"trailhead:ticket" label:"trailhead:paused"')
|
|
78
|
+
expect(wide).not.toContain('map-')
|
|
79
|
+
const scoped = runCountsArgs('ghe.example.com', 'o/r', 'trailhead:map-7').join(' ')
|
|
80
|
+
expect(scoped).toContain('label:"trailhead:map-7"')
|
|
81
|
+
expect(scoped).toContain('--hostname ghe.example.com')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('parseRunCounts reads both counts or nothing', () => {
|
|
85
|
+
expect(parseRunCounts('{"data":{"needsYou":{"issueCount":2},"paused":{"issueCount":0}}}')).toEqual({ needsYou: 2, paused: 0 })
|
|
86
|
+
expect(parseRunCounts('{"data":{"needsYou":{"issueCount":2}}}')).toBeNull()
|
|
87
|
+
expect(parseRunCounts('{"data":{"needsYou":{"issueCount":-1},"paused":{"issueCount":0}}}')).toBeNull()
|
|
88
|
+
expect(parseRunCounts('x')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('runLabel lists done and in flight, then the non-zero counts', () => {
|
|
92
|
+
expect(runLabel({ done: 3, inFlight: 2 }, { needsYou: 1, paused: 1 })).toBe('auto · 3 done · 2 in flight · 1 needs you · 1 paused')
|
|
93
|
+
expect(runLabel({ done: 3, inFlight: 2 }, { needsYou: 0, paused: 0 })).toBe('auto · 3 done · 2 in flight')
|
|
94
|
+
expect(runLabel({ done: 3 }, null)).toBe('auto · 3 done')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('the hint shows only with the flag and a run member', () => {
|
|
98
|
+
expect(RESUME_HINT).toBe('auto interrupted · /trailhead:auto to resume')
|
|
99
|
+
expect(showsResumeHint(true, { done: 0, inFlight: 1 })).toBe(true)
|
|
100
|
+
expect(showsResumeHint(true, null)).toBe(false)
|
|
101
|
+
expect(showsResumeHint(false, { done: 0, inFlight: 1 })).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('nextRunAfterFailure keeps the run until the MAX_FAILURES-th failure in a row', () => {
|
|
105
|
+
const run = { done: 1, inFlight: 1 }
|
|
106
|
+
expect(MAX_FAILURES).toBe(3)
|
|
107
|
+
expect(nextRunAfterFailure(run, 1)).toEqual(run)
|
|
108
|
+
expect(nextRunAfterFailure(run, 2)).toEqual(run)
|
|
109
|
+
expect(nextRunAfterFailure(run, 3)).toBeNull()
|
|
110
|
+
expect(nextRunAfterFailure(null, 1)).toBeNull()
|
|
111
|
+
})
|