@marcomigozzi/trailhead 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -6,26 +6,47 @@
6
6
  import { atom, read, update } from 'claude-code'
7
7
  import type { EngineInterface, Register } from 'claude-code'
8
8
 
9
- import type { PromptSnapshot, WorkState } from '../../types'
10
- import { registerMapPane } from './map-pane'
11
- import { promptSection, repoSlug, snapshotKey } from './work-state-prompt'
12
- import { fit, isMissing, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
9
+ import type { AutoRun, PromptSnapshot, RepoPin, RunCounts, WorkState } from '../../types'
10
+ import { RESUME_HINT, runLabel, showsResumeHint } from './auto-run-view'
11
+ import { clearRunCounts, readRunCounts, scanRun } from './auto-run'
12
+ import { followWhiteboard, onPinMove, registerMapPane } from './map-pane'
13
+ import { pinKey, pinPath, refreshPin } from './repo-pin'
14
+ import { isMainTurnRunning, markTurnComplete, markTurnStart } from './turn-state'
15
+ import { isForeignState, isOwnState, locateOwnState, noteFound, wasFoundElsewhere } from './work-state-locate'
16
+ import { sessionLiveness } from './session-registry'
17
+ import { promptSection, snapshotKey } from './work-state-prompt'
18
+ import { ELSEWHERE, fit, isMissing, nextOwnTicket, parseWorkState, pipeline, sameValue, statusLine } from './work-state-view'
13
19
  import type { StageStatus } from './work-state-view'
14
20
 
15
21
  export const workState = atom({ plugin: 'trailhead', key: 'workState' } as const, null as WorkState | null)
22
+ // A foreign state is "orphaned" (not this session's, nothing in play); `isElsewhere` says the session
23
+ // that wrote it is not known gone, so the wording is neutral and offers no resume.
16
24
  export const isOrphaned = atom({ plugin: 'trailhead', key: 'isOrphaned' } as const, false)
25
+ export const isElsewhere = atom({ plugin: 'trailhead', key: 'isElsewhere' } as const, false)
17
26
  // The ticket this session works, kept across a work-state file other sessions share.
18
27
  export const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
19
28
  export const promptSnapshot = atom({ plugin: 'trailhead', key: 'promptSnapshot' } as const, null as PromptSnapshot | null)
29
+ // The repo pin refreshPin keeps (repo-pin.ts); the module scan wants the atom spelled in the file that reads it,
30
+ // so this copy and the one in repo-pin.ts must keep the same { plugin, key }.
31
+ const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
32
+
33
+ // L'ultimo run trovato accanto al main checkout, i suoi conteggi di scope e il flag di ripresa (Esc o compattazione).
34
+ export const autoRun = atom({ plugin: 'trailhead', key: 'autoRun' } as const, null as AutoRun | null)
35
+ export const runCounts = atom({ plugin: 'trailhead', key: 'runCounts' } as const, null as RunCounts | null)
36
+ export const autoInterrupted = atom({ plugin: 'trailhead', key: 'autoInterrupted' } as const, false)
20
37
 
21
38
  const STATE_PATH = '.trailhead/work-state.json'
22
39
  const HELPER = 'trailhead-work-state.js'
23
40
  const ACCENT = '#7aa2f7'
24
41
  const GREEN = '#9ece6a'
25
42
  const AMBER = '#e0af68'
43
+ const AUTO_COMMAND = 'trailhead:auto'
26
44
  const SECTION_ID = 'trailhead:work-state'
45
+ const STALE_NOTE =
46
+ 'No trailhead ticket is in play any more: the earlier `# trailhead work state` section of the system prompt is stale.'
27
47
 
28
48
  type Dollar = EngineInterface
49
+ type RefreshInit = { isStart?: boolean; fresh?: boolean }
29
50
 
30
51
  // The status text last pinned; the sentinel makes the first refresh set or clear it.
31
52
  // (A hot reload resets it, which only costs one repeated status call.)
@@ -34,8 +55,7 @@ let shownStatus: string | undefined | typeof UNSET = UNSET
34
55
  // Refreshes run one after the other, so two events never interleave their writes.
35
56
  let queue: Promise<void> = Promise.resolve()
36
57
 
37
- function pin($: Dollar, state: WorkState | null, orphaned: boolean) {
38
- const text = statusLine(state, orphaned)
58
+ function pin($: Dollar, text: string | undefined) {
39
59
  if (text === shownStatus) return
40
60
  shownStatus = text
41
61
  $.ui.status(text)
@@ -47,15 +67,114 @@ async function setWorkState($: Dollar, value: WorkState | null) {
47
67
  if (!sameValue(current, value)) await update($, workState, () => value)
48
68
  }
49
69
 
70
+ async function setRun($: Dollar, value: AutoRun | null) {
71
+ const current = await read($, autoRun)
72
+ if (!sameValue(current, value)) await update($, autoRun, () => value)
73
+ }
74
+
75
+ async function setCounts($: Dollar, value: RunCounts | null) {
76
+ const current = await read($, runCounts)
77
+ if (!sameValue(current, value)) await update($, runCounts, () => value)
78
+ }
79
+
80
+ async function setInterrupted($: Dollar, value: boolean) {
81
+ const current = await read($, autoInterrupted)
82
+ if (current !== value) await update($, autoInterrupted, () => value)
83
+ }
84
+
85
+ // Lo status si ricalcola per ultimo, da tutti gli atom: stato, run, conteggi e suggerimento.
86
+ async function showStatus($: Dollar) {
87
+ const state = await read($, workState)
88
+ const run = await read($, autoRun)
89
+ const hint = showsResumeHint(await read($, autoInterrupted), run)
90
+ const elsewhere = await read($, isElsewhere)
91
+ pin($, statusLine(state, await read($, isOrphaned), { run, counts: await read($, runCounts), hint, elsewhere }))
92
+ }
93
+
94
+ async function mainRootOf($: Dollar): Promise<string | null> {
95
+ try {
96
+ return (await $.session.repo())?.root ?? null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ // Il run dei checkout accanto al main (il main working tree, non quello della sessione); solo lettura.
103
+ async function settleRun($: Dollar, state: WorkState | null) {
104
+ let run: AutoRun | null = null
105
+ let found: RunCounts | null = null
106
+ try {
107
+ const pinned = await read($, repoPin)
108
+ const main = (await mainRootOf($)) ?? pinned?.root ?? null
109
+ if (main !== null) {
110
+ const scanned = await scanRun($, { main, pinned: pinned?.root ?? main, ownState: state })
111
+ run = scanned.run
112
+ if (run !== null && pinned !== null) found = await readRunCounts($, pinned, scanned.scope)
113
+ }
114
+ } catch (err) {
115
+ debug($, `trailhead mod: autonomous run scan failed: ${String(err)}`)
116
+ }
117
+ if (run === null) clearRunCounts()
118
+ await setRun($, run)
119
+ await setCounts($, run === null ? null : found)
120
+ }
121
+
50
122
  async function setOrphaned($: Dollar, value: boolean) {
51
123
  const current = await read($, isOrphaned)
52
124
  if (current !== value) await update($, isOrphaned, () => value)
53
125
  }
54
126
 
55
- // null means "rebuild on the next compose"; written only when it changes.
127
+ async function setElsewhere($: Dollar, value: boolean) {
128
+ const current = await read($, isElsewhere)
129
+ if (current !== value) await update($, isElsewhere, () => value)
130
+ }
131
+
132
+ // Le sessioni chiuse da un /clear di questa: per loro lo stato e' orfano subito, senza guardare il registro.
133
+ const ended = new Set<string>()
134
+
135
+ // Il registro delle sessioni del motore (<config>/sessions/<pid>.json). Il pid si controlla solo dove c'e'
136
+ // /proc (Linux); altrove conta la presenza della voce. Ogni errore e' "unknown": mai orfano per un dubbio.
137
+ async function registryDir($: Dollar): Promise<string | null> {
138
+ const config = await $.env.get('CLAUDE_CONFIG_DIR')
139
+ if (config !== undefined && config !== '') return `${config}/sessions`
140
+ const home = await $.env.get('HOME')
141
+ return home === undefined || home === '' ? null : `${home}/.claude/sessions`
142
+ }
143
+
144
+ async function foreignIsGone($: Dollar, sessionId: string): Promise<boolean> {
145
+ try {
146
+ if (ended.has(sessionId)) return true
147
+ const dir = await registryDir($)
148
+ if (dir === null) return false
149
+ const hasProc = await $.fs.exists('/proc/self').catch(() => false)
150
+ const liveness = await sessionLiveness(dir, sessionId, {
151
+ list: async path => (await $.fs.list(path)).filter(e => e.kind === 'file').map(e => e.name),
152
+ read: path => $.fs.read(path) as Promise<string>,
153
+ pidAlive: async pid => (hasProc ? $.fs.exists(`/proc/${pid}`) : null),
154
+ })
155
+ return liveness === 'gone'
156
+ } catch (err) {
157
+ debug($, `trailhead mod: session registry lookup failed: ${String(err)}`)
158
+ return false
159
+ }
160
+ }
161
+
162
+ // What the latch was last set to, per session, once the atom write succeeded. Overlapping calls may see the
163
+ // atom as it stood when they began, so the compare-and-set in changeNote trusts this copy first; a hot
164
+ // reload falls back to the atom.
165
+ const told = new Map<string, PromptSnapshot | null>()
166
+
167
+ async function latchedNow($: Dollar): Promise<PromptSnapshot | null> {
168
+ const session = await $.session.id()
169
+ return told.has(session) ? (told.get(session) as PromptSnapshot | null) : read($, promptSnapshot)
170
+ }
171
+
172
+ // null means "rebuild on the next compose"; written only when it changes. The mirror follows a
173
+ // successful write only, so a denied write leaves the latch (and the mirror) as they were.
56
174
  async function setSnapshot($: Dollar, value: PromptSnapshot | null) {
57
175
  const current = await read($, promptSnapshot)
58
176
  if (!sameValue(current, value)) await update($, promptSnapshot, () => value)
177
+ told.set(await $.session.id(), value)
59
178
  }
60
179
 
61
180
  const debug = ($: Dollar, message: string) => {
@@ -66,35 +185,69 @@ const debug = ($: Dollar, message: string) => {
66
185
  }
67
186
  }
68
187
 
69
- // The latched text: rebuilt only when its key changed or the snapshot was reset.
70
- // A repo that cannot be read only costs the links, never the section.
71
- async function readSlug($: Dollar): Promise<string | null> {
188
+ // What the state says now: its key and section text, with the links following the pin.
189
+ // The links follow the pin, so a shell `cd` out of the repo never loses them; no pin only costs the links.
190
+ async function liveSnapshot($: Dollar): Promise<PromptSnapshot> {
191
+ const state = await read($, workState)
192
+ const orphaned = await read($, isOrphaned)
193
+ const pinned = await read($, repoPin)
194
+ const repo = pinned === null ? null : { host: pinned.host, slug: pinned.slug }
195
+ return { key: snapshotKey(state, orphaned, repo), text: promptSection(state, orphaned, repo) }
196
+ }
197
+
198
+ // The latch is what the model was told. Only the first persisting compose after a reset (latch null:
199
+ // session start, compaction, /clear) writes it; a later compose returns the latched text, so the system
200
+ // prompt stays what the engine already holds (it composes every turn but keeps sending the first one).
201
+ // After that only prompt.submit advances the latch. A non-persisting (/context) compose never writes.
202
+ async function currentSnapshot($: Dollar, persist: boolean): Promise<PromptSnapshot> {
203
+ const latched = await read($, promptSnapshot)
204
+ if (latched !== null) return latched
205
+ const live = await liveSnapshot($)
206
+ if (persist) await setSnapshot($, live)
207
+ return live
208
+ }
209
+
210
+ // The latch compare-and-set runs one at a time, so two overlapping prompts attach a change once.
211
+ let latchQueue: Promise<unknown> = Promise.resolve()
212
+ function serial<T>(task: () => Promise<T>): Promise<T> {
213
+ const run = latchQueue.then(task, task)
214
+ latchQueue = run.catch(() => undefined)
215
+ return run
216
+ }
217
+
218
+ // A change after the latch reaches the model as context on the next prompt, once per change; no latch
219
+ // yet means the compose will carry it. The latch moves to the live snapshot here; `previous` is what to
220
+ // put back if the prompt does not enter.
221
+ async function changeNote($: Dollar): Promise<{ note: string | null; previous: PromptSnapshot | null; live: PromptSnapshot | null }> {
222
+ const none = { note: null, previous: null, live: null }
223
+ const latched = await latchedNow($)
224
+ if (latched === null) return none
225
+ const live = await liveSnapshot($)
226
+ if (live.key === latched.key) return none
72
227
  try {
73
- return repoSlug((await $.session.repo())?.remote ?? null)
228
+ await setSnapshot($, live)
74
229
  } catch (err) {
75
- debug($, `trailhead mod: repo read failed: ${String(err)}`)
76
- return null
230
+ // The latch stays where it was: the next prompt retries, and this one goes on without the note.
231
+ debug($, `trailhead mod: latch write failed: ${String(err)}`)
232
+ return none
77
233
  }
234
+ const note = live.text !== null ? live.text : latched.text === null ? null : STALE_NOTE
235
+ return { note, previous: latched, live }
78
236
  }
79
237
 
80
- // With `persist` false (a /context render) the snapshot is computed but never stored.
81
- async function currentSnapshot($: Dollar, persist: boolean): Promise<PromptSnapshot> {
82
- const state = await read($, workState)
83
- const orphaned = await read($, isOrphaned)
84
- const key = snapshotKey(state, orphaned)
85
- const latched = await read($, promptSnapshot)
86
- if (latched !== null && latched.key === key) return latched
87
- const slug = state === null || orphaned ? null : await readSlug($)
88
- const built = { key, text: promptSection(state, orphaned, slug) }
89
- if (persist) await setSnapshot($, built)
90
- return built
238
+ // A dropped prompt told the model nothing: put the latch back, unless something else moved it since.
239
+ async function restoreLatch($: Dollar, previous: PromptSnapshot, live: PromptSnapshot) {
240
+ const current = await latchedNow($)
241
+ if (current !== null && current.key === live.key) await setSnapshot($, previous)
91
242
  }
92
243
 
93
- async function apply($: Dollar, state: WorkState | null, orphaned: boolean) {
244
+ async function apply($: Dollar, state: WorkState | null, orphaned: boolean, elsewhere = false) {
94
245
  const isStale = state !== null && orphaned
95
246
  await setWorkState($, state)
96
247
  await setOrphaned($, isStale)
97
- pin($, state, isStale)
248
+ await setElsewhere($, isStale && elsewhere)
249
+ await settleRun($, state)
250
+ await showStatus($)
98
251
  }
99
252
 
100
253
  // Decided from the state read before this refresh replaces it.
@@ -104,10 +257,39 @@ async function setOwnTicket($: Dollar, next: WorkState | null) {
104
257
  if (value !== prev) await update($, ownTicket, () => value)
105
258
  }
106
259
 
107
- async function refresh($: Dollar) {
260
+ // The state text: the pinned checkout's file, whatever the session cwd is now; without a pin (no GitHub
261
+ // repo ever seen) it stays relative to the cwd, as before. A missing file there sends the lookup to the
262
+ // repo's other checkouts (#270): the engine writes the state in the ticket's own workspace. A clean miss
263
+ // rethrows the own one, so the caller sees what it saw before; a broken lookup, when the state was found
264
+ // elsewhere before, is a transient error that keeps the last state. Another session's state there gets
265
+ // the same lookup, but a miss or a failure leaves it shown (orphaned). `fresh` (a helper call just wrote
266
+ // the state) skips the lookup's no-state memory.
267
+ async function readStateText($: Dollar, init: RefreshInit): Promise<string> {
268
+ const pin = await refreshPin($, { isStart: init.isStart, onMove: moved => onPinMove($, moved) }).catch(() => null)
269
+ try {
270
+ const text = (await $.fs.read(pin === null ? STATE_PATH : pinPath(pin, STATE_PATH))) as string
271
+ if (pin === null) return text
272
+ const session = await $.session.id()
273
+ if (isOwnState(text, session)) noteFound({ key: pinKey(pin), root: pin.root })
274
+ else if (isForeignState(text, session)) {
275
+ const found = await locateOwnState($, { key: pinKey(pin), root: pin.root }, session, init.fresh === true)
276
+ if (found.kind === 'found') return found.text
277
+ }
278
+ return text
279
+ } catch (err) {
280
+ if (pin === null || !isMissing(err)) throw err
281
+ const key = pinKey(pin)
282
+ const found = await locateOwnState($, { key, root: pin.root }, await $.session.id(), init.fresh === true)
283
+ if (found.kind === 'found') return found.text
284
+ if (found.kind === 'failed' && wasFoundElsewhere(key, pin.root)) throw new Error('trailhead mod: work state lookup failed')
285
+ throw err
286
+ }
287
+ }
288
+
289
+ async function refresh($: Dollar, init: RefreshInit = {}) {
108
290
  let text: string
109
291
  try {
110
- text = (await $.fs.read(STATE_PATH)) as string
292
+ text = await readStateText($, init)
111
293
  } catch (err) {
112
294
  // A missing file means no ticket in play; any other failure is transient: keep the last state.
113
295
  if (isMissing(err)) {
@@ -120,11 +302,14 @@ async function refresh($: Dollar) {
120
302
  await setOwnTicket($, state)
121
303
  if (state === null) return apply($, null, false)
122
304
  const session = await $.session.id()
123
- return apply($, state, state.session !== null && state.session !== session)
305
+ const foreign = state.session !== null && state.session !== session
306
+ // Una sessione straniera e' "altrove" finche' il registro non conferma che e' finita.
307
+ const gone = foreign && (await foreignIsGone($, state.session as string))
308
+ return apply($, state, foreign, foreign && !gone)
124
309
  }
125
310
 
126
- function safeRefresh($: Dollar): Promise<void> {
127
- queue = queue.then(() => refresh($)).catch(err => {
311
+ function safeRefresh($: Dollar, init: RefreshInit = {}): Promise<void> {
312
+ queue = queue.then(() => refresh($, init)).catch(err => {
128
313
  try {
129
314
  $.ui.log(`trailhead mod: work state refresh failed: ${String(err)}`, { to: 'debug' })
130
315
  } catch {
@@ -134,13 +319,23 @@ function safeRefresh($: Dollar): Promise<void> {
134
319
  return queue
135
320
  }
136
321
 
322
+ async function resumeHintCleared($: Dollar) {
323
+ try {
324
+ await setInterrupted($, false)
325
+ await showStatus($)
326
+ } catch (err) {
327
+ debug($, `trailhead mod: resume hint reset failed: ${String(err)}`)
328
+ }
329
+ }
330
+
137
331
  // A /clear ends the session the state belongs to: show it as orphaned at once.
138
332
  async function markOrphaned($: Dollar) {
139
333
  const state = await read($, workState)
140
334
  // A file with no session is never orphaned, /clear included.
141
335
  if (state === null || state.session === null) return
142
336
  await setOrphaned($, true)
143
- pin($, state, true)
337
+ await setElsewhere($, false)
338
+ await showStatus($)
144
339
  }
145
340
 
146
341
  type Look = { dim?: boolean; bold?: boolean; color?: string }
@@ -166,7 +361,8 @@ export const register: Register = on => {
166
361
 
167
362
  on('session.start', async ($, e, next) => {
168
363
  const result = await next(e)
169
- await safeRefresh($)
364
+ await safeRefresh($, { isStart: true })
365
+ await followWhiteboard($)
170
366
  await setSnapshot($, null).catch(err => debug($, `trailhead mod: snapshot reset failed: ${String(err)}`))
171
367
  return result
172
368
  })
@@ -185,12 +381,41 @@ export const register: Register = on => {
185
381
  }
186
382
  })
187
383
 
384
+ // A change after the one compose reaches the model here; fail-open, the prompt always goes on.
385
+ on('prompt.submit', async ($, e, next) => {
386
+ if (e.command === AUTO_COMMAND) {
387
+ // Il run riprende: il suggerimento sparisce, a meno che il comando non sia stato scartato.
388
+ const result = await next(e)
389
+ if (result.drop === undefined) await resumeHintCleared($)
390
+ return result
391
+ }
392
+ if (e.command !== undefined) return next(e)
393
+ await safeRefresh($)
394
+ const { note, previous, live } = await serial(() => changeNote($))
395
+ const put = () => (previous === null || live === null ? Promise.resolve() : serial(() => restoreLatch($, previous, live)).catch(err => debug($, `trailhead mod: latch restore failed: ${String(err)}`)))
396
+ let result
397
+ try {
398
+ result = await next(note === null ? e : { ...e, context: [...(e.context ?? []), note] })
399
+ } catch (err) {
400
+ // Delivery is not confirmed: put the latch back so the next prompt carries the change.
401
+ await put()
402
+ throw err
403
+ }
404
+ if (result.drop !== undefined) await put()
405
+ return result
406
+ }).catch(($, e, next) => {
407
+ debug($, 'trailhead mod: work state context failed')
408
+ return next(e)
409
+ })
410
+
188
411
  // After a compaction the agent re-reads the live state: refresh, then rebuild the snapshot.
189
412
  on('session.compact', async ($, e, next) => {
190
413
  const result = await next(e)
191
414
  // A subagent's or a precomputed compaction installs nothing in the main conversation.
192
415
  if (e.agentId !== undefined || e.trigger === 'precompute' || result.messages === undefined) return result
193
416
  try {
417
+ // Una compattazione tra due turni interrompe il run; dentro un turno in corso no (il run prosegue).
418
+ if (!isMainTurnRunning()) await setInterrupted($, true)
194
419
  await safeRefresh($)
195
420
  await setSnapshot($, null)
196
421
  } catch (err) {
@@ -200,33 +425,52 @@ export const register: Register = on => {
200
425
  })
201
426
 
202
427
  on('turn.start', async ($, e, next) => {
428
+ // Marked before the chain runs, so a press handled meanwhile already sees the turn.
429
+ if (e.agentId === undefined) markTurnStart()
203
430
  const result = await next(e)
431
+ if (e.agentId === undefined) {
432
+ // Il run può proseguire da solo (il completamento di uno slot apre un nuovo turno).
433
+ await setInterrupted($, false).catch(err => debug($, `trailhead mod: resume hint reset failed: ${String(err)}`))
434
+ }
204
435
  await safeRefresh($)
205
436
  return result
206
437
  })
207
438
 
208
439
  on('turn.complete', async ($, e, next) => {
209
440
  const result = await next(e)
441
+ if (e.agentId === undefined) {
442
+ markTurnComplete()
443
+ if (e.isAborted) await setInterrupted($, true).catch(err => debug($, `trailhead mod: resume hint set failed: ${String(err)}`))
444
+ }
210
445
  await safeRefresh($)
446
+ await followWhiteboard($)
211
447
  return result
212
448
  })
213
449
 
214
450
  // The helper runs through Bash: read again once the call has run.
215
451
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
216
452
  const result = await next(e)
217
- if (typeof e.command === 'string' && e.command.includes(HELPER)) await safeRefresh($)
453
+ if (typeof e.command === 'string' && e.command.includes(HELPER)) {
454
+ await safeRefresh($, { fresh: true })
455
+ await followWhiteboard($)
456
+ }
218
457
  return result
219
458
  // This hook never judges the call: if it fails, the call goes on untouched.
220
459
  }).catch((_$, e, next) => next(e))
221
460
 
222
461
  on('session.end', async ($, e, next) => {
462
+ // The session ends with no turn left running, whatever the reason.
463
+ markTurnComplete()
223
464
  if (e.reason === 'clear') {
465
+ ended.add(e.sessionId)
224
466
  try {
225
467
  await markOrphaned($)
226
468
  } catch {
227
469
  // Never block the session ending.
228
470
  }
229
471
  }
472
+ // The ended session's mirror is never read again.
473
+ told.delete(e.sessionId)
230
474
  return next(e)
231
475
  })
232
476
 
@@ -242,15 +486,30 @@ export const register: Register = on => {
242
486
 
243
487
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244
488
  const state = await read($, workState)
245
- const orphaned = await read($, isOrphaned)
246
- if (state === null || e.props.hasSurvey) return next(e)
489
+ const isStale = await read($, isOrphaned)
490
+ const elsewhere = isStale && (await read($, isElsewhere))
491
+ const orphaned = isStale && !elsewhere
492
+ const run = await read($, autoRun)
493
+ const counts = await read($, runCounts)
494
+ const hint = showsResumeHint(await read($, autoInterrupted), run)
495
+ if (e.props.hasSurvey) return next(e)
496
+ if (state === null) {
497
+ // Nessuno stato proprio (gli slot non hanno sessione): una riga sola per il run o per il suggerimento.
498
+ if (run === null) return next(e)
499
+ const { Box, Text } = $.ui.resolve(e)
500
+ return (
501
+ <Box key="run">
502
+ <Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run, counts)}</Text>
503
+ </Box>
504
+ )
505
+ }
247
506
 
248
507
  const { Box, Text } = $.ui.resolve(e)
249
508
  const { stages, isPaused } = pipeline(state.step)
250
509
  const ticketLabel = `#${state.ticket}`
251
510
  const room = e.props.bodyColumns - ticketLabel.length - 1
252
511
  const title = state.title === null ? null : fit(state.title, room)
253
- const tag = orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
512
+ const tag = elsewhere ? ` ${ELSEWHERE}` : orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
254
513
 
255
514
  return (
256
515
  <Box flexDirection="column">
@@ -281,9 +540,9 @@ export const register: Register = on => {
281
540
  </Box>
282
541
  )}
283
542
  </Box>
284
- {state.auto && !orphaned ? (
543
+ {hint || (state.auto && !isStale) ? (
285
544
  <Box key="auto">
286
- <Text color={AMBER}>{`auto · ${state.autoDone} done`}</Text>
545
+ <Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run ?? { done: state.autoDone }, counts)}</Text>
287
546
  </Box>
288
547
  ) : null}
289
548
  </Box>
@@ -0,0 +1,167 @@
1
+ // The repo the session works in, pinned (#268): the map pane and the band read their files and run
2
+ // `gh` against it, so a shell `cd` out of the repo (the session cwd moves) never loses them.
3
+ // Pure parts first, then the engine part that keeps the pin in an atom.
4
+ import { atom, read, update } from 'claude-code'
5
+ import type { EngineInterface } from 'claude-code'
6
+
7
+ import type { RepoPin } from '../../types'
8
+ import { sameValue } from './work-state-view'
9
+
10
+ type RepoRead = { root: string; remote: string | null }
11
+
12
+ // Host and owner/name are kept to the characters a link may carry: they reach the system prompt.
13
+ const URL_REMOTE = /^(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?([A-Za-z0-9.-]+)(?::\d+)?\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/i
14
+ const SCP_REMOTE = /^(?:[^@/\s]+@)?([A-Za-z0-9.-]+):([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/
15
+
16
+ // Host and owner/name of a remote, any host (github.com or an Enterprise one; `gh` resolves the host
17
+ // itself from the pinned root). The host is lower-cased: DNS names are case-insensitive.
18
+ export const remoteId = (remote: string | null): { host: string; slug: string } | null => {
19
+ if (remote === null) return null
20
+ const text = remote.trim()
21
+ const m = URL_REMOTE.exec(text) ?? (text.includes('://') ? null : SCP_REMOTE.exec(text))
22
+ return m === null ? null : { host: m[1].toLowerCase(), slug: `${m[2]}/${m[3]}` }
23
+ }
24
+
25
+ // The repo as a pin: a working copy with a parseable remote. `toplevel` is the checkout's own root
26
+ // (a worktree's, where trailhead keeps its files); `repo.root` is the main working tree's.
27
+ export const pinOf = (repo: RepoRead | null, toplevel: string | null = null): RepoPin | null => {
28
+ const id = remoteId(repo?.remote ?? null)
29
+ return repo === null || id === null ? null : { root: toplevel ?? repo.root, ...id }
30
+ }
31
+
32
+ // What makes two pins the same repo (the root can differ between checkouts of it).
33
+ export const pinKey = (pin: RepoPin): string => `${pin.host}/${pin.slug}`
34
+ export const isSamePin = (a: RepoPin | null, b: RepoPin | null): boolean =>
35
+ a === null || b === null ? a === b : a.root === b.root && pinKey(a) === pinKey(b)
36
+
37
+ // Whether the checkout the session is in (what `session.repo()` and its toplevel read) is the pinned one.
38
+ export const isPinCheckout = (pin: RepoPin | null, repo: RepoRead | null, toplevel: string | null): boolean =>
39
+ pin !== null && toplevel !== null && isSamePin(pin, pinOf(repo, toplevel))
40
+
41
+ // A check outside any repo, or in one with no usable remote, keeps the last pin; another repo moves it.
42
+ export const nextPin = (current: RepoPin | null, repo: RepoRead | null, toplevel: string | null = null): RepoPin | null => {
43
+ const found = pinOf(repo, toplevel)
44
+ if (found === null) return current
45
+ return isSamePin(current, found) ? current : found
46
+ }
47
+
48
+ // The pinned checkout as the trailing word of a verb's args: `--in=` plus base64url of the root (alphabet
49
+ // [A-Za-z0-9_-], no padding), so a path with spaces, quotes or a leading `-` stays one shell-safe word. The verb
50
+ // decodes it with templates/trailhead-checkout-in.js and moves there first (#312).
51
+ // No Buffer in the mod's runtime: UTF-8 bytes through btoa, then the url-safe alphabet.
52
+ export const checkoutArg = (pin: RepoPin): string => {
53
+ const binary = Array.from(new TextEncoder().encode(pin.root), byte => String.fromCharCode(byte)).join('')
54
+ return `--in=${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
55
+ }
56
+
57
+ // A repo-relative path under the pinned root, absolute whatever the session cwd is.
58
+ export const pinPath = (pin: RepoPin, relative: string): string => `${pin.root.replace(/\/+$/, '')}/${relative}`
59
+
60
+ // Same {plugin, key} as the copies the pane declares: the module scan wants the atom spelled in the file that uses it.
61
+ export const repoPin = atom({ plugin: 'trailhead', key: 'repoPin' } as const, null as RepoPin | null)
62
+
63
+ // Machinery, not drawn: survives a /clear, which empties the atom while the session stays in its repo.
64
+ let remembered: RepoPin | null = null
65
+
66
+ // The checkout's own root: `git rev-parse --show-toplevel` in the session's directory, the same one
67
+ // `$.session.repo()` reads, so root and remote always name one checkout. null when it cannot be read;
68
+ // the caller falls back.
69
+ export async function toplevelOf($: EngineInterface): Promise<string | null> {
70
+ try {
71
+ const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd(), timeoutMs: 5_000 })
72
+ const top = out.stdout.trim()
73
+ return out.exitCode === 0 && top.startsWith('/') ? top : null
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ // The repo as git answers it, for when the host's `session.repo()` has nothing (#299): the two outputs
80
+ // of `git rev-parse --show-toplevel` and `git remote get-url origin`. null outside a repo; a repo with
81
+ // no origin has a null remote (no usable pin, so the last one stays).
82
+ type GitOut = { exitCode: number; stdout: string }
83
+ export const repoFromGit = (top: GitOut | null, origin: GitOut | null): RepoRead | null => {
84
+ const root = top !== null && top.exitCode === 0 ? top.stdout.trim() : ''
85
+ if (!root.startsWith('/')) return null
86
+ const remote = origin !== null && origin.exitCode === 0 ? origin.stdout.trim() : ''
87
+ return { root, remote: remote === '' ? null : remote }
88
+ }
89
+
90
+ // The fallback read: null outside a repo (no second spawn then); `toplevel` is the root git just gave.
91
+ async function gitRepo($: EngineInterface): Promise<{ repo: RepoRead; toplevel: string } | null> {
92
+ try {
93
+ const cwd = await $.session.cwd()
94
+ const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 5_000 })
95
+ const top = await run(['git', 'rev-parse', '--show-toplevel'])
96
+ if (repoFromGit(top, null) === null) return null
97
+ const repo = repoFromGit(top, await run(['git', 'remote', 'get-url', 'origin']))
98
+ return repo === null ? null : { repo, toplevel: repo.root }
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ // The repo the session is in: the host's answer, else git's (#299). `toplevel` comes with it only on the
105
+ // git path, where it is already read; a throw of the host read reaches the caller. The git fallback covers
106
+ // a host whose `repo()` stays null for a repo created mid-session (anthropics/claude-code#104070): keep it
107
+ // while hosts without the upstream fix are supported, even once a fixed release ships.
108
+ export async function currentRepo($: EngineInterface): Promise<{ repo: RepoRead | null; toplevel: string | null }> {
109
+ const host = await $.session.repo()
110
+ if (host !== null) return { repo: host, toplevel: null }
111
+ return (await gitRepo($)) ?? { repo: null, toplevel: null }
112
+ }
113
+
114
+ // github.com is served by gh as it is; any other host only once `gh auth status --hostname <host>` succeeds
115
+ // (an Enterprise host the person logged in to). A success is kept for the life of the module (the
116
+ // session's start hooks of the pane and the band both refresh, so it is not reset there). A failure is
117
+ // kept only HOST_RETRY_MS: it may be a network blip or a login still to come, so it is asked again
118
+ // later. A check that could not run at all is not kept, so it is tried again.
119
+ const GITHUB_HOST = 'github.com'
120
+ const HOST_RETRY_MS = 60_000
121
+ // true: served; a number: the time the last check failed.
122
+ const servedHosts = new Map<string, true | number>()
123
+
124
+ async function isServed($: EngineInterface, host: string, cwd: string): Promise<boolean> {
125
+ if (host === GITHUB_HOST) return true
126
+ const known = servedHosts.get(host)
127
+ if (known === true) return true
128
+ const now = await $.clock.now()
129
+ if (known !== undefined && now - known < HOST_RETRY_MS) return false
130
+ try {
131
+ const out = await $.process.run(['gh', 'auth', 'status', '--hostname', host], { cwd, timeoutMs: 10_000 })
132
+ servedHosts.set(host, out.exitCode === 0 ? true : now)
133
+ return out.exitCode === 0
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+
139
+ // Checks the working copy now and pins what it finds; a failed read keeps the last pin. The host's
140
+ // `session.repo()` can stay null for a repo created mid-session, so git in the cwd answers then (#299). A session
141
+ // start begins from nothing: the pin of an earlier session is not this one's.
142
+ export async function refreshPin($: EngineInterface, init: { isStart?: boolean; onMove?: (pin: RepoPin | null) => Promise<void> } = {}): Promise<RepoPin | null> {
143
+ const stored = await read($, repoPin)
144
+ const current = init.isStart === true ? null : (stored ?? remembered)
145
+ let repo: RepoRead | null = null
146
+ let toplevel: string | null = null
147
+ try {
148
+ const found = await currentRepo($)
149
+ repo = found.repo
150
+ const id = remoteId(repo?.remote ?? null)
151
+ if (repo !== null && id !== null) {
152
+ toplevel = found.toplevel ?? (await toplevelOf($))
153
+ // A host gh cannot serve is no usable remote: the last pin stays.
154
+ if (!(await isServed($, id.host, toplevel ?? repo.root))) repo = null
155
+ }
156
+ } catch {
157
+ // Unreadable repo: keep the last pin.
158
+ }
159
+ const pin = nextPin(current, repo, toplevel)
160
+ remembered = pin
161
+ if (!sameValue(stored, pin)) {
162
+ // Right before the atom changes, whoever moves it, so what was read for the old pin goes no later than the change.
163
+ await init.onMove?.(pin)
164
+ await update($, repoPin, () => pin)
165
+ }
166
+ return pin
167
+ }