@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.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- 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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
async function
|
|
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
|
-
|
|
228
|
+
await setSnapshot($, live)
|
|
72
229
|
} catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
79
|
-
async function
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
-
|
|
248
|
+
await setElsewhere($, isStale && elsewhere)
|
|
249
|
+
await settleRun($, state)
|
|
250
|
+
await showStatus($)
|
|
96
251
|
}
|
|
97
252
|
|
|
98
|
-
|
|
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 =
|
|
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))
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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))
|
|
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
|
|
233
|
-
|
|
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 && !
|
|
543
|
+
{hint || (state.auto && !isStale) ? (
|
|
272
544
|
<Box key="auto">
|
|
273
|
-
<Text color={AMBER}>{
|
|
545
|
+
<Text color={AMBER}>{hint ? RESUME_HINT : runLabel(run ?? { done: state.autoDone }, counts)}</Text>
|
|
274
546
|
</Box>
|
|
275
547
|
) : null}
|
|
276
548
|
</Box>
|