@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Liveness di una sessione Claude Code dal registro del motore (<config>/sessions/<pid>.json,
|
|
2
|
+
// con `sessionId` e `pid`). Nessuna chiamata al motore qui: fs e controllo del pid sono iniettati.
|
|
3
|
+
// Il registro e' interno e non tipizzato, quindi ogni dubbio (cartella illeggibile, file non
|
|
4
|
+
// parsabile, registro vuoto) vale "unknown", mai "gone".
|
|
5
|
+
|
|
6
|
+
export type Liveness = 'live' | 'gone' | 'unknown'
|
|
7
|
+
|
|
8
|
+
export type RegistryDeps = {
|
|
9
|
+
// I nomi dei file della cartella del registro; rifiuta se non si legge.
|
|
10
|
+
list: (dir: string) => Promise<string[]>
|
|
11
|
+
read: (path: string) => Promise<string>
|
|
12
|
+
// true/false se il pid si sa vivo/morto, null se la piattaforma non lo dice (conta la sola presenza).
|
|
13
|
+
pidAlive: (pid: number) => Promise<boolean | null>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Entry = { sessionId: string; pid: number }
|
|
17
|
+
|
|
18
|
+
const parseEntry = (text: string): Entry | null => {
|
|
19
|
+
let raw: unknown
|
|
20
|
+
try {
|
|
21
|
+
raw = JSON.parse(text)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
26
|
+
const r = raw as Record<string, unknown>
|
|
27
|
+
if (typeof r.sessionId !== 'string' || r.sessionId === '') return null
|
|
28
|
+
if (!Number.isInteger(r.pid) || (r.pid as number) <= 0) return null
|
|
29
|
+
return { sessionId: r.sessionId, pid: r.pid as number }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function sessionLiveness(dir: string, sessionId: string, deps: RegistryDeps): Promise<Liveness> {
|
|
33
|
+
let names: string[]
|
|
34
|
+
try {
|
|
35
|
+
names = (await deps.list(dir)).filter(name => name.endsWith('.json'))
|
|
36
|
+
} catch {
|
|
37
|
+
return 'unknown'
|
|
38
|
+
}
|
|
39
|
+
let parsed = 0
|
|
40
|
+
let doubtful = false
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
let entry: Entry | null = null
|
|
43
|
+
try {
|
|
44
|
+
entry = parseEntry(await deps.read(`${dir}/${name}`))
|
|
45
|
+
} catch {
|
|
46
|
+
entry = null
|
|
47
|
+
}
|
|
48
|
+
if (entry === null) {
|
|
49
|
+
doubtful = true
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
parsed += 1
|
|
53
|
+
if (entry.sessionId !== sessionId) continue
|
|
54
|
+
let alive: boolean | null
|
|
55
|
+
try {
|
|
56
|
+
alive = await deps.pidAlive(entry.pid)
|
|
57
|
+
} catch {
|
|
58
|
+
alive = null
|
|
59
|
+
}
|
|
60
|
+
if (alive !== false) return 'live'
|
|
61
|
+
}
|
|
62
|
+
// Nessuna voce leggibile (la sessione corrente dovrebbe esserci) o file che non si leggono: non si sa.
|
|
63
|
+
if (parsed === 0 || doubtful) return 'unknown'
|
|
64
|
+
return 'gone'
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Whether a main-loop turn is running, shared by the band (register.tsx) and the map pane, which each
|
|
2
|
+
// need it and may not hook the same event twice with the same matcher. Module state: a hot reload
|
|
3
|
+
// resets it to idle, which only costs a held press being released early.
|
|
4
|
+
let isRunning = false
|
|
5
|
+
|
|
6
|
+
export const markTurnStart = (): void => {
|
|
7
|
+
isRunning = true
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const markTurnComplete = (): void => {
|
|
11
|
+
isRunning = false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isMainTurnRunning = (): boolean => isRunning
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure rules of the map panel following the whiteboard (#306): when this session takes a whiteboard
|
|
2
|
+
// ticket the panel shows the whiteboard once, and a whiteboard ticket created from a map raises a hint.
|
|
3
|
+
// No atoms and no engine calls here, so the rules are easy to test.
|
|
4
|
+
import type { WorkState } from '../../types'
|
|
5
|
+
|
|
6
|
+
export const WHITEBOARD_LABEL = 'trailhead:whiteboard'
|
|
7
|
+
|
|
8
|
+
export type Follow = { follow: boolean; memo: number | null }
|
|
9
|
+
|
|
10
|
+
// Follow once per ticket: the memo is the last ticket followed, kept by the caller across a /clear.
|
|
11
|
+
// A null state (no file, cleared by Resolve) changes nothing; a map ticket, a foreign or an orphaned state
|
|
12
|
+
// is never followed and leaves the memo as it was.
|
|
13
|
+
export const nextFollow = (memo: number | null, state: WorkState | null, own: number | null, isOrphaned: boolean): Follow => {
|
|
14
|
+
if (state === null || state.map !== null || isOrphaned || own === null || state.ticket !== own) return { follow: false, memo }
|
|
15
|
+
return state.ticket === memo ? { follow: false, memo } : { follow: true, memo: state.ticket }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The command split into shell segments of words: quotes group (and are dropped), a backslash escapes outside
|
|
19
|
+
// single quotes, a backslash-newline joins lines, and an unquoted `;`, `&`, `|` or newline ends a segment.
|
|
20
|
+
// Enough shell for reading flags, not a full parser: no expansion, no heredocs.
|
|
21
|
+
const SEPARATORS = new Set([';', '&', '|', '\n'])
|
|
22
|
+
|
|
23
|
+
function segmentsOf(command: string): string[][] {
|
|
24
|
+
const segments: string[][] = []
|
|
25
|
+
let words: string[] = []
|
|
26
|
+
let word: string | null = null
|
|
27
|
+
let quote: '"' | "'" | null = null
|
|
28
|
+
const endWord = () => {
|
|
29
|
+
if (word !== null) words = [...words, word]
|
|
30
|
+
word = null
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < command.length; i++) {
|
|
33
|
+
const ch = command[i]
|
|
34
|
+
if (quote !== null) {
|
|
35
|
+
if (ch === quote) quote = null
|
|
36
|
+
else if (ch === '\\' && quote === '"' && i + 1 < command.length) word = (word ?? '') + command[++i]
|
|
37
|
+
else word = (word ?? '') + ch
|
|
38
|
+
} else if (ch === '\\' && command[i + 1] === '\n') i++
|
|
39
|
+
else if (ch === '\\' && i + 1 < command.length) word = (word ?? '') + command[++i]
|
|
40
|
+
else if (ch === '"' || ch === "'") {
|
|
41
|
+
quote = ch
|
|
42
|
+
word = word ?? ''
|
|
43
|
+
} else if (SEPARATORS.has(ch)) {
|
|
44
|
+
endWord()
|
|
45
|
+
if (words.length > 0) segments.push(words)
|
|
46
|
+
words = []
|
|
47
|
+
} else if (/\s/.test(ch)) endWord()
|
|
48
|
+
else word = (word ?? '') + ch
|
|
49
|
+
}
|
|
50
|
+
endWord()
|
|
51
|
+
if (words.length > 0) segments.push(words)
|
|
52
|
+
return segments
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isCreate = (words: string[]): boolean => {
|
|
56
|
+
const at = words.indexOf('issue')
|
|
57
|
+
return words.includes('gh') && at >= 0 && words[at + 1] === 'create'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The values of every label flag: `--label X`, `--label=X`, `-l X`, `-lX`.
|
|
61
|
+
function labelValues(words: string[]): string[] {
|
|
62
|
+
const values: string[] = []
|
|
63
|
+
words.forEach((w, i) => {
|
|
64
|
+
if ((w === '--label' || w === '-l') && i + 1 < words.length) values.push(words[i + 1])
|
|
65
|
+
else if (w.startsWith('--label=')) values.push(w.slice('--label='.length))
|
|
66
|
+
else if (w.startsWith('-l') && !w.startsWith('--') && w.length > 2) values.push(w.slice(2))
|
|
67
|
+
})
|
|
68
|
+
return values
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A `gh issue create` whose label flags (repeated, `=` or space, comma lists, quoted) carry the whiteboard
|
|
72
|
+
// label. Only the create's own segment counts, so a label named in another command never does.
|
|
73
|
+
export const isWhiteboardCreate = (command: string): boolean =>
|
|
74
|
+
segmentsOf(command)
|
|
75
|
+
.filter(isCreate)
|
|
76
|
+
.some(words => labelValues(words).some(v => v.split(',').some(label => label.trim() === WHITEBOARD_LABEL)))
|
|
77
|
+
|
|
78
|
+
const ISSUE_URL = /\/issues\/(\d+)\s*$/
|
|
79
|
+
|
|
80
|
+
const textOf = (value: unknown): string | null => {
|
|
81
|
+
if (typeof value === 'string') return value
|
|
82
|
+
const stdout = (value as { stdout?: unknown } | null)?.stdout
|
|
83
|
+
return typeof stdout === 'string' ? stdout : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The number `gh issue create` printed (the issue URL is its last line), from what `next(e)` resolved to:
|
|
87
|
+
// the Bash `result` is `{ stdout }` or a plain string; the call's `text` is the fallback. An errored call,
|
|
88
|
+
// or output with no issue URL, gives none.
|
|
89
|
+
export const createdIssueNumber = (call: unknown): number | null => {
|
|
90
|
+
const c = call as { result?: unknown; text?: unknown; isError?: unknown } | null
|
|
91
|
+
if (c === null || typeof c !== 'object' || c.isError === true) return null
|
|
92
|
+
const out = textOf(c.result) ?? textOf(c.text)
|
|
93
|
+
const m = out === null ? null : ISSUE_URL.exec(out.trimEnd())
|
|
94
|
+
return m === null ? null : Number(m[1])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The toast box is narrow: a few words.
|
|
98
|
+
export const whiteboardHint = (n: number): string => `new whiteboard ticket #${n}: open Whiteboard`
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Where the session's work state lives when it is not in the pinned checkout (#270): the engine writes
|
|
2
|
+
// it in the per-ticket workspace (a git worktree or a sibling clone `<repo>-t<n>`), while the session
|
|
3
|
+
// may sit in the main checkout. Pure parts first, then the engine part that looks the state up.
|
|
4
|
+
import type { EngineInterface } from 'claude-code'
|
|
5
|
+
|
|
6
|
+
import { isMissing, parseWorkState } from './work-state-view'
|
|
7
|
+
import { MAX_FAILURES, parseWorktreeBranches } from './worktree-porcelain'
|
|
8
|
+
|
|
9
|
+
const STATE_PATH = '.trailhead/work-state.json'
|
|
10
|
+
const LOOKUP_TIMEOUT_MS = 5_000
|
|
11
|
+
|
|
12
|
+
export const dirname = (path: string): string => path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
|
|
13
|
+
const basename = (path: string): string => path.replace(/\/+$/, '').replace(/^.*\//, '')
|
|
14
|
+
export const trim = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
|
|
15
|
+
|
|
16
|
+
// The checkout paths of `git worktree list --porcelain`, in the order git prints them (main first).
|
|
17
|
+
export const parseWorktrees = (porcelain: string): string[] => parseWorktreeBranches(porcelain).map(w => w.path)
|
|
18
|
+
|
|
19
|
+
// The per-ticket clones beside the main working tree: `<basename>-t<digits>`, sorted.
|
|
20
|
+
export const siblingClones = (mainRoot: string, names: readonly string[]): string[] => {
|
|
21
|
+
const base = basename(mainRoot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
22
|
+
const parent = dirname(mainRoot)
|
|
23
|
+
const pattern = new RegExp(`^${base}-t\\d+$`)
|
|
24
|
+
return names
|
|
25
|
+
.filter(name => pattern.test(name))
|
|
26
|
+
.sort()
|
|
27
|
+
.map(name => `${parent === '/' ? '' : parent}/${name}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Where to look, in order: the worktrees, then the sibling clones.
|
|
31
|
+
// The pinned root is the own read, which already missed; no path twice.
|
|
32
|
+
export const candidates = (pinned: string, worktrees: readonly string[], siblings: readonly string[]): string[] => {
|
|
33
|
+
const all = [...worktrees, ...siblings].map(trim)
|
|
34
|
+
return all.filter((path, i) => path !== trim(pinned) && all.indexOf(path) === i)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A state is this session's only when it names the session: a state with no session, or another's, is not adopted.
|
|
38
|
+
export const isOwnState = (text: string, session: string): boolean => parseWorkState(text)?.session === session
|
|
39
|
+
|
|
40
|
+
// A state that names another session (a state with no session is nobody's, shown as is).
|
|
41
|
+
export const isForeignState = (text: string, session: string): boolean => {
|
|
42
|
+
const owner = parseWorkState(text)?.session
|
|
43
|
+
return owner !== undefined && owner !== null && owner !== session
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The first candidate's text that is this session's state.
|
|
47
|
+
export const pickOwn = (texts: readonly (string | null)[], session: string): number => texts.findIndex(text => text !== null && isOwnState(text, session))
|
|
48
|
+
|
|
49
|
+
// The checkout where this session's state was last found, with the repo it belongs to; machinery, like
|
|
50
|
+
// `remembered` in repo-pin.ts. A checkout of another repo is never tried.
|
|
51
|
+
let lastFound: { key: string; root: string } | null = null
|
|
52
|
+
|
|
53
|
+
// A hit ends the run of failed lookups for the repo.
|
|
54
|
+
export const noteFound = (found: { key: string; root: string } | null): void => {
|
|
55
|
+
lastFound = found
|
|
56
|
+
if (found !== null) for (const memo of failures.keys()) if (memo.startsWith(`${found.key}|`)) failures.delete(memo)
|
|
57
|
+
}
|
|
58
|
+
// Whether the state was last found in this repo, in a checkout other than the pinned one.
|
|
59
|
+
export const wasFoundElsewhere = (key: string, pinned: string): boolean =>
|
|
60
|
+
lastFound !== null && lastFound.key === key && trim(lastFound.root) !== trim(pinned)
|
|
61
|
+
|
|
62
|
+
// A scan that found nothing is not repeated for NO_STATE_MS, per repo and session. Time comes from the
|
|
63
|
+
// engine clock; without one nothing is kept.
|
|
64
|
+
const NO_STATE_MS = 10_000
|
|
65
|
+
const misses = new Map<string, number>()
|
|
66
|
+
// A scan that failed is remembered for a shorter while, with how many failed in a row: past MAX_FAILURES
|
|
67
|
+
// the caller stops keeping the last state and treats the file as missing.
|
|
68
|
+
const FAILED_MS = 3_000
|
|
69
|
+
const failures = new Map<string, { at: number | null; count: number }>()
|
|
70
|
+
|
|
71
|
+
// Writes a miss time, dropping the misses whose window has ended.
|
|
72
|
+
const remember = (map: Map<string, number>, key: string, now: number, windowMs: number): void => {
|
|
73
|
+
for (const [k, at] of map) if (now - at >= windowMs) map.delete(k)
|
|
74
|
+
map.set(key, now)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const resetLocate = (): void => {
|
|
78
|
+
lastFound = null
|
|
79
|
+
misses.clear()
|
|
80
|
+
failures.clear()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function nowOf($: EngineInterface): Promise<number | null> {
|
|
84
|
+
try {
|
|
85
|
+
return await $.clock.now()
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// null: the listing itself failed (not the same as an empty one). Path and branch of each checkout.
|
|
92
|
+
export async function listWorktreeBranches($: EngineInterface, root: string): Promise<{ path: string; branch: string | null }[] | null> {
|
|
93
|
+
try {
|
|
94
|
+
const out = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root, timeoutMs: LOOKUP_TIMEOUT_MS })
|
|
95
|
+
return out.exitCode === 0 ? parseWorktreeBranches(out.stdout) : null
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function listWorktrees($: EngineInterface, root: string): Promise<string[] | null> {
|
|
102
|
+
return (await listWorktreeBranches($, root))?.map(w => w.path) ?? null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function listSiblings($: EngineInterface, mainRoot: string): Promise<string[] | null> {
|
|
106
|
+
try {
|
|
107
|
+
const entries = await $.fs.list(dirname(mainRoot))
|
|
108
|
+
return siblingClones(mainRoot, entries.filter(entry => entry.kind === 'dir').map(entry => entry.name))
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The file's text; null when it is not there, undefined when the read failed any other way.
|
|
115
|
+
export async function readAt($: EngineInterface, root: string): Promise<string | null | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
return (await $.fs.read(`${root}/${STATE_PATH}`)) as string
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return isMissing(err) ? null : undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type Lookup = { kind: 'found'; text: string; root: string } | { kind: 'none' } | { kind: 'failed' }
|
|
124
|
+
|
|
125
|
+
const NONE: Lookup = { kind: 'none' }
|
|
126
|
+
|
|
127
|
+
async function scan($: EngineInterface, pinned: string, session: string): Promise<{ kind: 'found'; text: string; root: string } | { kind: 'miss'; hadFailure: boolean }> {
|
|
128
|
+
const worktrees = await listWorktrees($, pinned)
|
|
129
|
+
const siblings = await listSiblings($, worktrees?.[0] ?? pinned)
|
|
130
|
+
const roots = candidates(pinned, worktrees ?? [], siblings ?? [])
|
|
131
|
+
const texts = await Promise.all(roots.map(root => readAt($, root)))
|
|
132
|
+
const i = pickOwn(texts.map(text => text ?? null), session)
|
|
133
|
+
if (i !== -1) return { kind: 'found', text: texts[i] as string, root: roots[i] }
|
|
134
|
+
return { kind: 'miss', hadFailure: worktrees === null || siblings === null || texts.includes(undefined) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// This session's state from another checkout of the repo. 'none' is a clean miss; 'failed' means the
|
|
138
|
+
// lookup itself broke (git, the listing, a read), so the caller keeps what it had. `fresh` skips the
|
|
139
|
+
// no-state memory (a helper call just wrote a state).
|
|
140
|
+
export async function locateOwnState($: EngineInterface, pin: { key: string; root: string }, session: string, fresh = false): Promise<Lookup> {
|
|
141
|
+
const memo = `${pin.key}|${session}`
|
|
142
|
+
try {
|
|
143
|
+
// The remembered checkout of this repo is read first and alone, so a steady state costs no process.
|
|
144
|
+
const first = lastFound !== null && lastFound.key === pin.key && trim(lastFound.root) !== trim(pin.root) ? lastFound.root : null
|
|
145
|
+
const fast = first === null ? null : await readAt($, first)
|
|
146
|
+
if (first !== null && typeof fast === 'string' && isOwnState(fast, session)) {
|
|
147
|
+
failures.delete(memo)
|
|
148
|
+
return { kind: 'found', text: fast, root: first }
|
|
149
|
+
}
|
|
150
|
+
const now = await nowOf($)
|
|
151
|
+
const seen = misses.get(memo)
|
|
152
|
+
const failed = failures.get(memo)
|
|
153
|
+
if (!fresh && now !== null && seen !== undefined && now - seen < NO_STATE_MS) return NONE
|
|
154
|
+
if (!fresh && now !== null && failed?.at != null && now - failed.at < FAILED_MS) return failedLookup(failed.count)
|
|
155
|
+
const result = await scan($, pin.root, session)
|
|
156
|
+
if (result.kind === 'miss' && result.hadFailure) {
|
|
157
|
+
const count = (failed?.count ?? 0) + 1
|
|
158
|
+
setFailure(memo, count, now)
|
|
159
|
+
return failedLookup(count)
|
|
160
|
+
}
|
|
161
|
+
failures.delete(memo)
|
|
162
|
+
if (result.kind === 'found') {
|
|
163
|
+
misses.delete(memo)
|
|
164
|
+
noteFound({ key: pin.key, root: result.root })
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
if (now !== null) remember(misses, memo, now, NO_STATE_MS)
|
|
168
|
+
if (lastFound?.key === pin.key) noteFound(null)
|
|
169
|
+
return NONE
|
|
170
|
+
} catch {
|
|
171
|
+
return failedLookup(bump(memo, await nowOf($)))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Failed lookups in a row count against the keep: the MAX_FAILURES-th reads as a missing file.
|
|
176
|
+
const failedLookup = (count: number): Lookup => (count >= MAX_FAILURES ? NONE : { kind: 'failed' })
|
|
177
|
+
|
|
178
|
+
// The run of failures for the key; only a success ends it, however far apart the failures are. Writing
|
|
179
|
+
// one drops the runs of other keys that are long gone.
|
|
180
|
+
const FAILURE_KEEP_MS = 600_000
|
|
181
|
+
|
|
182
|
+
function setFailure(memo: string, count: number, now: number | null): void {
|
|
183
|
+
for (const [k, v] of failures) if (k !== memo && (v.at === null || (now !== null && now - v.at >= FAILURE_KEEP_MS))) failures.delete(k)
|
|
184
|
+
failures.set(memo, { at: now, count })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bump(memo: string, now: number | null): number {
|
|
188
|
+
const count = (failures.get(memo)?.count ?? 0) + 1
|
|
189
|
+
setFailure(memo, count, now)
|
|
190
|
+
return count
|
|
191
|
+
}
|
|
@@ -8,12 +8,8 @@ const ELLIPSIS = '…'
|
|
|
8
8
|
const encoder = new TextEncoder()
|
|
9
9
|
const bytes = (text: string): number => encoder.encode(text).length
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
export
|
|
13
|
-
if (remote === null) return null
|
|
14
|
-
const m = /^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote.trim())
|
|
15
|
-
return m === null ? null : `${m[1]}/${m[2]}`
|
|
16
|
-
}
|
|
11
|
+
// The repo the links point at: host and owner/name of the pin.
|
|
12
|
+
export type LinkRepo = { host: string; slug: string }
|
|
17
13
|
|
|
18
14
|
// Flat single line, cut by code point so a surrogate pair is never split.
|
|
19
15
|
// Zero-width and bidi controls are dropped, so the title cannot hide or reorder text.
|
|
@@ -40,7 +36,7 @@ const capBytes = (text: string): string => {
|
|
|
40
36
|
return out
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
const link = (
|
|
39
|
+
const link = (repo: LinkRepo | null, n: number): string => (repo === null ? '' : ` https://${repo.host}/${repo.slug}/issues/${n}`)
|
|
44
40
|
|
|
45
41
|
const stepLine = (step: string | null): string => {
|
|
46
42
|
const live = 'may have moved on since this snapshot; .trailhead/work-state.json holds the live one'
|
|
@@ -48,13 +44,13 @@ const stepLine = (step: string | null): string => {
|
|
|
48
44
|
return `Step: ${step ?? 'unknown'} (${live})`
|
|
49
45
|
}
|
|
50
46
|
|
|
51
|
-
export const promptSection = (state: WorkState | null, orphaned: boolean,
|
|
47
|
+
export const promptSection = (state: WorkState | null, orphaned: boolean, repo: LinkRepo | null): string | null => {
|
|
52
48
|
if (state === null || state.ticket === null || orphaned) return null
|
|
53
49
|
const title = state.title === null ? '' : ` "${cutTitle(state.title)}"`
|
|
54
50
|
const lines = [
|
|
55
51
|
'# trailhead work state',
|
|
56
|
-
state.map === null ? 'Map: none (whiteboard ticket)' : `Map: #${state.map}${link(
|
|
57
|
-
`Ticket: #${state.ticket}${title}${link(
|
|
52
|
+
state.map === null ? 'Map: none (whiteboard ticket)' : `Map: #${state.map}${link(repo, state.map)}`,
|
|
53
|
+
`Ticket: #${state.ticket}${title}${link(repo, state.ticket)}`,
|
|
58
54
|
stepLine(state.step),
|
|
59
55
|
]
|
|
60
56
|
if (state.auto) lines.push('Mode: autonomous run')
|
|
@@ -62,8 +58,10 @@ export const promptSection = (state: WorkState | null, orphaned: boolean, slug:
|
|
|
62
58
|
return capBytes(lines.join('\n'))
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
// What a rebuild keys on: step and autoDone are left out so the text stays cache-stable.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
// What a rebuild keys on: step and autoDone are left out so the text stays cache-stable. The repo is in it,
|
|
62
|
+
// so a pin that moves rebuilds the links.
|
|
63
|
+
export const snapshotKey = (state: WorkState | null, orphaned: boolean, repo: LinkRepo | null): string =>
|
|
64
|
+
JSON.stringify([
|
|
65
|
+
repo === null ? null : `${repo.host}/${repo.slug}`,
|
|
66
|
+
...(state === null ? [null, orphaned] : [state.map, state.ticket, state.title, orphaned, state.auto]),
|
|
67
|
+
])
|