@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -9,14 +9,17 @@
9
9
  // node trailhead-work-state.js set [--repo <dir>] [--map <n>|none] [--ticket <n>]
10
10
  // [--title <t>] [--step <step>] [--auto true|false] [--auto-done <n>]
11
11
  // node trailhead-work-state.js clear [--repo <dir>]
12
+ // `clear` removes both work-state.json and the session-ticket marker, each
13
+ // attempted independently.
12
14
  // --repo defaults to cwd. `set` merges the given fields onto the existing file
13
15
  // (only --step at each step change); a --ticket other than the one on file
14
16
  // starts from an empty state. File shape:
15
17
  // { map, ticket, title, step, auto, autoDone, session, updatedAt }
16
18
  // autoDone: tickets resolved so far in the autonomous run (non-negative
17
19
  // integer, default 0; reset to 0 whenever auto is false).
18
- // Prints one JSON line {"status","path","state"?,"message"?}.
20
+ // Prints one JSON line {"status","path","state"?,"sessionTicket"?,"message"?}.
19
21
  // status: written | cleared | absent | skipped
22
+ // sessionTicket (clear only): removed | absent | skipped
20
23
  // Exit 0 on every normal outcome and on any unexpected I/O error (it must
21
24
  // never block the caller: status skipped with the message); exit 2 only on a
22
25
  // usage or validation error (nothing is written).
@@ -27,6 +30,7 @@ const crypto = require('crypto');
27
30
 
28
31
  const STATE_DIR = '.trailhead';
29
32
  const STATE_FILE = 'work-state.json';
33
+ const TICKET_MARKER = 'session-ticket';
30
34
  const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'];
31
35
  const TITLE_MAX = 200;
32
36
 
@@ -145,6 +149,18 @@ function writeAtomic(dir, file, text, fsMod) {
145
149
  }
146
150
  }
147
151
 
152
+ // Removes one file; never throws. Returns { status, message? } where status is
153
+ // doneLabel (removed), absentLabel (not there) or skipped (I/O error).
154
+ function removeFile(file, fsMod, doneLabel, absentLabel) {
155
+ try {
156
+ if (!fsMod.existsSync(file)) return { status: absentLabel };
157
+ fsMod.unlinkSync(file);
158
+ return { status: doneLabel };
159
+ } catch (err) {
160
+ return { status: 'skipped', message: 'error: ' + (err && err.message ? err.message : String(err)) };
161
+ }
162
+ }
163
+
148
164
  function writeResult(stdout, result) {
149
165
  stdout.write(JSON.stringify(result) + '\n');
150
166
  }
@@ -166,12 +182,14 @@ function main(argv, { cwd, stdout, env, fs: fsMod, now } = {}) {
166
182
  const file = path.join(dir, STATE_FILE);
167
183
  try {
168
184
  if (parsed.command === 'clear') {
169
- if (!fs_.existsSync(file)) {
170
- writeResult(out, { status: 'absent', path: file });
171
- return 0;
172
- }
173
- fs_.unlinkSync(file);
174
- writeResult(out, { status: 'cleared', path: file });
185
+ // Both markers are removed independently: a missing or failing first
186
+ // removal never skips the second (#253).
187
+ const state = removeFile(file, fs_, 'cleared', 'absent');
188
+ const marker = removeFile(path.join(dir, TICKET_MARKER), fs_, 'removed', 'absent');
189
+ const result = { status: state.status, path: file, sessionTicket: marker.status };
190
+ const message = [state.message, marker.message].filter(Boolean).join('; ');
191
+ if (message) result.message = message;
192
+ writeResult(out, result);
175
193
  return 0;
176
194
  }
177
195
 
@@ -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
+ })