@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
@@ -6,24 +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, 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)
26
+ // The ticket this session works, kept across a work-state file other sessions share.
27
+ export const ownTicket = atom({ plugin: 'trailhead', key: 'ownTicket' } as const, null as number | null)
17
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)
18
37
 
19
38
  const STATE_PATH = '.trailhead/work-state.json'
20
39
  const HELPER = 'trailhead-work-state.js'
21
40
  const ACCENT = '#7aa2f7'
22
41
  const GREEN = '#9ece6a'
23
42
  const AMBER = '#e0af68'
43
+ const AUTO_COMMAND = 'trailhead:auto'
24
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.'
25
47
 
26
48
  type Dollar = EngineInterface
49
+ type RefreshInit = { isStart?: boolean; fresh?: boolean }
27
50
 
28
51
  // The status text last pinned; the sentinel makes the first refresh set or clear it.
29
52
  // (A hot reload resets it, which only costs one repeated status call.)
@@ -32,8 +55,7 @@ let shownStatus: string | undefined | typeof UNSET = UNSET
32
55
  // Refreshes run one after the other, so two events never interleave their writes.
33
56
  let queue: Promise<void> = Promise.resolve()
34
57
 
35
- function pin($: Dollar, state: WorkState | null, orphaned: boolean) {
36
- const text = statusLine(state, orphaned)
58
+ function pin($: Dollar, text: string | undefined) {
37
59
  if (text === shownStatus) return
38
60
  shownStatus = text
39
61
  $.ui.status(text)
@@ -45,15 +67,114 @@ async function setWorkState($: Dollar, value: WorkState | null) {
45
67
  if (!sameValue(current, value)) await update($, workState, () => value)
46
68
  }
47
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
+
48
122
  async function setOrphaned($: Dollar, value: boolean) {
49
123
  const current = await read($, isOrphaned)
50
124
  if (current !== value) await update($, isOrphaned, () => value)
51
125
  }
52
126
 
53
- // 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.
54
174
  async function setSnapshot($: Dollar, value: PromptSnapshot | null) {
55
175
  const current = await read($, promptSnapshot)
56
176
  if (!sameValue(current, value)) await update($, promptSnapshot, () => value)
177
+ told.set(await $.session.id(), value)
57
178
  }
58
179
 
59
180
  const debug = ($: Dollar, message: string) => {
@@ -64,54 +185,131 @@ const debug = ($: Dollar, message: string) => {
64
185
  }
65
186
  }
66
187
 
67
- // The latched text: rebuilt only when its key changed or the snapshot was reset.
68
- // A repo that cannot be read only costs the links, never the section.
69
- 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
70
227
  try {
71
- return repoSlug((await $.session.repo())?.remote ?? null)
228
+ await setSnapshot($, live)
72
229
  } catch (err) {
73
- debug($, `trailhead mod: repo read failed: ${String(err)}`)
74
- 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
75
233
  }
234
+ const note = live.text !== null ? live.text : latched.text === null ? null : STALE_NOTE
235
+ return { note, previous: latched, live }
76
236
  }
77
237
 
78
- // With `persist` false (a /context render) the snapshot is computed but never stored.
79
- async function currentSnapshot($: Dollar, persist: boolean): Promise<PromptSnapshot> {
80
- const state = await read($, workState)
81
- const orphaned = await read($, isOrphaned)
82
- const key = snapshotKey(state, orphaned)
83
- const latched = await read($, promptSnapshot)
84
- if (latched !== null && latched.key === key) return latched
85
- const slug = state === null || orphaned ? null : await readSlug($)
86
- const built = { key, text: promptSection(state, orphaned, slug) }
87
- if (persist) await setSnapshot($, built)
88
- 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)
89
242
  }
90
243
 
91
- async function apply($: Dollar, state: WorkState | null, orphaned: boolean) {
244
+ async function apply($: Dollar, state: WorkState | null, orphaned: boolean, elsewhere = false) {
92
245
  const isStale = state !== null && orphaned
93
246
  await setWorkState($, state)
94
247
  await setOrphaned($, isStale)
95
- pin($, state, isStale)
248
+ await setElsewhere($, isStale && elsewhere)
249
+ await settleRun($, state)
250
+ await showStatus($)
96
251
  }
97
252
 
98
- async function refresh($: Dollar) {
253
+ // Decided from the state read before this refresh replaces it.
254
+ async function setOwnTicket($: Dollar, next: WorkState | null) {
255
+ const prev = await read($, ownTicket)
256
+ const value = nextOwnTicket(prev, await read($, workState), next, await $.session.id())
257
+ if (value !== prev) await update($, ownTicket, () => value)
258
+ }
259
+
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 = {}) {
99
290
  let text: string
100
291
  try {
101
- text = (await $.fs.read(STATE_PATH)) as string
292
+ text = await readStateText($, init)
102
293
  } catch (err) {
103
294
  // A missing file means no ticket in play; any other failure is transient: keep the last state.
104
- if (isMissing(err)) await apply($, null, false)
295
+ if (isMissing(err)) {
296
+ await setOwnTicket($, null)
297
+ await apply($, null, false)
298
+ }
105
299
  return
106
300
  }
107
301
  const state = parseWorkState(text)
302
+ await setOwnTicket($, state)
108
303
  if (state === null) return apply($, null, false)
109
304
  const session = await $.session.id()
110
- 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)
111
309
  }
112
310
 
113
- function safeRefresh($: Dollar): Promise<void> {
114
- queue = queue.then(() => refresh($)).catch(err => {
311
+ function safeRefresh($: Dollar, init: RefreshInit = {}): Promise<void> {
312
+ queue = queue.then(() => refresh($, init)).catch(err => {
115
313
  try {
116
314
  $.ui.log(`trailhead mod: work state refresh failed: ${String(err)}`, { to: 'debug' })
117
315
  } catch {
@@ -121,13 +319,23 @@ function safeRefresh($: Dollar): Promise<void> {
121
319
  return queue
122
320
  }
123
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
+
124
331
  // A /clear ends the session the state belongs to: show it as orphaned at once.
125
332
  async function markOrphaned($: Dollar) {
126
333
  const state = await read($, workState)
127
334
  // A file with no session is never orphaned, /clear included.
128
335
  if (state === null || state.session === null) return
129
336
  await setOrphaned($, true)
130
- pin($, state, true)
337
+ await setElsewhere($, false)
338
+ await showStatus($)
131
339
  }
132
340
 
133
341
  type Look = { dim?: boolean; bold?: boolean; color?: string }
@@ -153,7 +361,8 @@ export const register: Register = on => {
153
361
 
154
362
  on('session.start', async ($, e, next) => {
155
363
  const result = await next(e)
156
- await safeRefresh($)
364
+ await safeRefresh($, { isStart: true })
365
+ await followWhiteboard($)
157
366
  await setSnapshot($, null).catch(err => debug($, `trailhead mod: snapshot reset failed: ${String(err)}`))
158
367
  return result
159
368
  })
@@ -172,12 +381,41 @@ export const register: Register = on => {
172
381
  }
173
382
  })
174
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
+
175
411
  // After a compaction the agent re-reads the live state: refresh, then rebuild the snapshot.
176
412
  on('session.compact', async ($, e, next) => {
177
413
  const result = await next(e)
178
414
  // A subagent's or a precomputed compaction installs nothing in the main conversation.
179
415
  if (e.agentId !== undefined || e.trigger === 'precompute' || result.messages === undefined) return result
180
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)
181
419
  await safeRefresh($)
182
420
  await setSnapshot($, null)
183
421
  } catch (err) {
@@ -187,33 +425,52 @@ export const register: Register = on => {
187
425
  })
188
426
 
189
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()
190
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
+ }
191
435
  await safeRefresh($)
192
436
  return result
193
437
  })
194
438
 
195
439
  on('turn.complete', async ($, e, next) => {
196
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
+ }
197
445
  await safeRefresh($)
446
+ await followWhiteboard($)
198
447
  return result
199
448
  })
200
449
 
201
450
  // The helper runs through Bash: read again once the call has run.
202
451
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
203
452
  const result = await next(e)
204
- 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
+ }
205
457
  return result
206
458
  // This hook never judges the call: if it fails, the call goes on untouched.
207
459
  }).catch((_$, e, next) => next(e))
208
460
 
209
461
  on('session.end', async ($, e, next) => {
462
+ // The session ends with no turn left running, whatever the reason.
463
+ markTurnComplete()
210
464
  if (e.reason === 'clear') {
465
+ ended.add(e.sessionId)
211
466
  try {
212
467
  await markOrphaned($)
213
468
  } catch {
214
469
  // Never block the session ending.
215
470
  }
216
471
  }
472
+ // The ended session's mirror is never read again.
473
+ told.delete(e.sessionId)
217
474
  return next(e)
218
475
  })
219
476
 
@@ -229,15 +486,30 @@ export const register: Register = on => {
229
486
 
230
487
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231
488
  const state = await read($, workState)
232
- const orphaned = await read($, isOrphaned)
233
- 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
+ }
234
506
 
235
507
  const { Box, Text } = $.ui.resolve(e)
236
508
  const { stages, isPaused } = pipeline(state.step)
237
509
  const ticketLabel = `#${state.ticket}`
238
510
  const room = e.props.bodyColumns - ticketLabel.length - 1
239
511
  const title = state.title === null ? null : fit(state.title, room)
240
- const tag = orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
512
+ const tag = elsewhere ? ` ${ELSEWHERE}` : orphaned ? ' orphaned, resume?' : isPaused ? ' ‖ paused' : null
241
513
 
242
514
  return (
243
515
  <Box flexDirection="column">
@@ -268,9 +540,9 @@ export const register: Register = on => {
268
540
  </Box>
269
541
  )}
270
542
  </Box>
271
- {state.auto && !orphaned ? (
543
+ {hint || (state.auto && !isStale) ? (
272
544
  <Box key="auto">
273
- <Text color={AMBER}>{`auto · ${state.autoDone} done`}</Text>
545
+ <Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run ?? { done: state.autoDone }, counts)}</Text>
274
546
  </Box>
275
547
  ) : null}
276
548
  </Box>