Ather Lanes: the machine's S2 agent lanes as a board. /lanes shows the decisions waiting for you, the lanes that need a look, and every lane with its slot…

The machine's S2 agent lanes in one pane: the decisions waiting for you, the lanes that need a look, and every lane on one line when you ask. It reads what the S2 lane scripts (.agents/skills/s2-agent-lanes/scripts/) and git already keep, and changes nothing. While the pane is closed it starts no process: the status line is kept fresh by reading files. It loads next to Ather Automata and changes nothing Automata does.
/lanes opens the pane. In a terminal it sits beside the chat; on the desktop it opens in the side panel.main; it holds a slot past that slot's end; it needs a slot that another lane holds; it is not registered.main, the slot it needs and holds, when it last moved and how that is known, then its branch, intent, tier and path. An unregistered worktree shows the lanes.ps1 -Action Register line to copy. A second press closes it.lane combat-hitstop · ◆ 3 for you · 1 to look at (lane 0 in the main checkout, not in a lane elsewhere on the machine; a count shows only when it is not zero). It is text only: the engine's status line takes no press or hotkey, so type /lanes to open the pane.lanes_summary tool (this session's lane with its branch, intent, tier, slot and PR, then every For you and A look item); "show the lanes" calls lanes_open, which opens the pane as /lanes does, and with a lane's name opens that lane in place.this session (quiet) wherever it shows: beside its name in For you and A look, in its opened details, and in the opened fold, where it comes right after lane 0.A lane that is fine says nothing until you open the fold. Lime marks only For you, and amber only A look's reasons (in the pane; the status line is plain text).
Look at the status line: lane <name> is the worktree this session's checkout is. Or ask Claude "which lane am I in?". The match is by folder (case and slashes aside): a session started in a folder inside a lane is in that lane; a harness worktree inside the main checkout is its own worktree, not lane 0. Before the pane has first been opened on the machine, a worktree outside the lanes folder (a harness one) reads not in a lane, as only lanes.ps1 -Action Status lists those.
claude plugin install ather-lanes@ather --scope user
Then type /lanes in a session opened in an S2 checkout or one of its lanes.
On a machine with lanes (a checkout whose machine folder exists), from the session's start and while the pane is closed or behind another tab, once a minute at most, and with no process: the sidecars (_machine\lanes\*.json), one listing of the lanes folder (a folder in it with a .git is a worktree; no tree walk), the slots, the heartbeats and each registered lane's intent. That is enough for For you and for A look's slot and unregistered reasons. The reasons that need git or gh (checks failing, behind main) come from the last full read the pane made, on this machine and kept in the mod's store; they are never read while the pane is closed. The status line and lanes_summary say what those reads left. While the pane is shown:
| Every | What | How |
|---|---|---|
| 30 s | the slots (_machine\slots\*.txt, read as Ather reads the Editor lock), the lane heartbeats (_machine\heartbeats\), each worktree's Ather Automata session heartbeats (only those on the worktree's own branch), origin/main's last move (its reflog) | file reads, no process |
| 2 min, and on r | lanes.ps1 -Action Status -Json: the worktrees, their kind and sidecar as the script gives them; one git for-each-ref for ahead, behind and last commit of the lane branches (with GIT_OPTIONAL_LOCKS=0); each registered lane's intent (docs\intent\<slug>\) | one PowerShell process, then one git process |
| 5 min | one gh pr list for the lane branches (lane 0's and the pool's left out), run from the machine folder; another session reuses the answer for 5 minutes | one gh process |
At most one process runs at a time; a read that comes due while one runs is skipped. The mod never runs git status, git fetch, git diff or git merge-tree, and never fetches: "main as of 19:04" is when origin/main last moved on this machine.
The one write it causes. lanes.ps1 -Action Status moves a sidecar whose worktree is gone to _machine\lanes-stale\ by itself. Opening the pane runs Status, so it can trigger that move. Otherwise the mod writes nothing: no sidecar, slot, heartbeat, pool or Automata file, only its own store (the gh answer and the last full read). It never calls editor-slot.ps1. It checks that the machine folder exists before it runs Status, so Status never creates one on a machine without lanes.
Two settings, both empty by default; /config lists them under Ather Lanes:
lanes.ps1. Empty: the session's own checkout's .agents\skills\s2-agent-lanes\scripts\. Point it at a checkout whose scripts you trust when the session's own checkout has none yet.-MachineDir. Empty: <the lanes folder next to the main checkout>\_machine. Set it only for a scratch proof.Outside a git checkout, without the scripts, or without a machine folder, the pane says what it could not find, draws the rest, and starts no process.
hooks/lanes-store.mjs, both kept per lane 0 (two lane 0 checkouts of one repository no longer share gh's answer), and a write deletes those that name a lane 0 whose folder is gone (0.2.0's, which name none, are left: a session not yet reloaded may still write them); a worktree the last Status found missing is shown again as soon as its folder is back.lanes_summary and lanes_open tools; this session's lane marked in the pane and listed after lane 0 in the opened fold.lanes.ps1 -Action Status -Json, the slot and heartbeat files, one for-each-ref and one gh call.The pane's look, the intent parser and the Editor-lock reader are Ather Automata's own modules, copied into hooks/ather/ by node dev/sync-shared.mjs ather-lanes (the entry modules are named in shared.json); never edit those copies. The mod's own files: hooks/sources.mjs (one pure reader per source), hooks/records.mjs (one record per worktree), hooks/board.mjs (what the pane shows), hooks/pane.mjs (its lines and drawing) and hooks/index.mjs (the engine half: the command, the tools, the status line, the timer, the processes). node dev/test-all.mjs checks the copies and runs tests/*.test.mjs on the made-up machine in tests/fixtures/. On Windows, with the lane scripts at hand (LANES_SCRIPTS, or S2_ROOT's), it also runs the real lanes.ps1 -Action Status -Json on a scratch repository and reads its output. Then run claude plugin validate ather-lanes and claude plugin test ather-lanes, bump version in .claude-plugin/plugin.json and the repository's .claude-plugin/marketplace.json, and add a line under Changes.
hooks/index.mjs 418 lines1// @ts-check
2// Ather Lanes: the engine half. /lanes (or the lanes_open tool) opens the pane. One 30-second timer, started on a
3// lanes machine at session start: while the pane is shown (D3) each tick reads the slot and heartbeat files, and
4// every 2 minutes (and on r) runs lanes.ps1 Status, the one for-each-ref and reads the lanes' intents; gh at most
5// every 5 minutes, its answer kept in the store for any session. While the pane is closed (D8) a tick at most once a
6// minute reads files only, no process: the sidecars, the lanes folder's one listing, the slots, heartbeats and
7// intents, with the git and gh figures of the last full read kept in the store. The status line and the
8// lanes_summary tool (D9) say what those reads left. One process at a time: a run that comes due while one runs is
9// skipped. Read-only: the mod writes only its own store (Status' own stale-sidecar move aside, README). It hooks no
10// tool call but its own two tools', no prompt or turn, and a draw only reads what the last refresh left.
11// Everything here catches its own failures: the pane says what it could not read and draws the rest.
12
13import { unreal } from './ather/packs/unreal.mjs'
14import { GIT_ENV } from './ather/team.mjs'
15import { checkoutOf, gitDirOf, normalFolder } from './ather/workspace.mjs'
16import { boardOf, lineText, sectionOf, summaryText } from './board.mjs'
17import { keepFull, keepPrs, readFull, readKeptPrs } from './lanes-store.mjs'
18import { drawPane, paneLines } from './pane.mjs'
19import { NO_SOURCES, beatsOf, intentOf, laneBranches, laneOf, laneRecords, statusOfFiles } from './records.mjs'
20import { REF_FORMAT, folderKey, laneZeroOf, prListArgs, readOrigin, readPrs, readRefs, readReflogTime, readSidecar, readSlot, readStatus } from './sources.mjs'
21
22/**
23 * @typedef {import('claude-code').EngineInterface} Engine
24 * @typedef {import('./records.mjs').Sources} Sources
25 * @typedef {import('./sources.mjs').Status} Status
26 * @typedef {import('./pane.mjs').Opened} Opened
27 * @typedef {import('./pane.mjs').Press} Press
28 * @typedef {{ root: string, main: string, lanesRoot: string, machineDir: string, scriptsDir: string, repo: string, isMachine: boolean, errors: string[] }} Place
29 * the session's checkout and lane 0, the lanes and machine folders and the scripts; `isMachine`: a checkout with a
30 * machine folder, so a lanes machine; `errors`: what could not be found (empty: Status may run)
31 * @typedef {import('./records.mjs').Full} Full
32 */
33
34const PANE_ID = 'lanes'
35// The two tools as the model calls them (mcp__<plugin>__<name>); each is registered by the name after the prefix.
36const SUMMARY_TOOL = 'mcp__ather-lanes__lanes_summary'
37const OPEN_TOOL = 'mcp__ather-lanes__lanes_open'
38/** @param {string} tool */
39const shortName = tool => tool.replace(/^mcp__ather-lanes__/, '')
40const TICK_MS = 30 * 1000
41const CLOSED_EVERY_MS = 60 * 1000
42const STATUS_EVERY_MS = 2 * 60 * 1000
43const PRS_EVERY_MS = 5 * 60 * 1000
44// A timer tick lands a little early or late: a read due within this much of its time runs on this tick, not the next.
45const SLACK_MS = 5 * 1000
46const NOT_HERE = 'This session is not in a git checkout, so there are no lanes to read. Open it in an S2 checkout or a lane.'
47
48/** @type {{ -readonly [K in keyof Sources]: Sources[K] }} */
49let sources = { ...NO_SOURCES }
50/** @type {Place | null} */
51let place = null
52// When each source was last asked for; -Infinity: never, so the first refresh asks for all.
53let statusAt = -Infinity
54let prsAt = -Infinity
55let filesAt = -Infinity
56// When the pane was last refreshed; null before the first.
57/** @type {number | null} */
58let refreshedAt = null
59/** @type {Full | null} */
60let full = null
61let isBusy = false
62/** @type {Record<string, string>} */
63let runErrors = {}
64/** @type {import('claude-code').Timer | null} */
65let timer = null
66/** @type {Opened | null} */
67let opened = null
68let isFoldOpen = false
69/** @type {string | undefined} the status line as last set */
70let shownLine
71
72/** @param {Engine} $ @param {string} path */
73const read = ($, path) => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null)
74/** @param {Engine} $ @param {string} path */
75const exists = ($, path) => $.fs.exists(path).catch(() => false)
76/** @param {Engine} $ @param {string} path */
77const list = ($, path) => $.fs.list(path).catch(() => [])
78// The store as closures. Its `exists` passes a failed look through (unlike `exists` above), and the store keeps a key
79// whose folder it could not look at.
80/** @param {Engine} $ @returns {import('./lanes-store.mjs').StoreIo} */
81const storeOf = $ => ({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), keys: () => $.store.keys(), remove: key => $.store.delete(key), exists: path => $.fs.exists(path) })
82/** @param {string} path */
83const parentOf = path => path.replace(/\/[^/]*$/, '')
84/** @param {number} ms */
85const clockText = ms => new Date(ms).toTimeString().slice(0, 5)
86
87// Where the lanes are, from files alone (no process): the checkout the session runs in and lane 0 (Ather's
88// workspace.mjs), the lanes folder beside lane 0 (as lanes.ps1 finds it), the machine folder and the scripts (D6).
89/** @param {Engine} $ @param {Record<string, unknown>} options @returns {Promise<Place>} */
90const placeOf = async ($, options) => {
91 const files = { read: (/** @type {string} */ path) => read($, path), exists: (/** @type {string} */ path) => exists($, path) }
92 const root = await checkoutOf(files, await $.session.root())
93 if (root === null) return { root: '', main: '', lanesRoot: '', machineDir: '', scriptsDir: '', repo: '', isMachine: false, errors: [NOT_HERE] }
94 const main = laneZeroOf((await gitDirOf(files, root)) ?? `${root}/.git`)
95 const lanesRoot = `${parentOf(main)}/lanes`
96 const machineDir = normalFolder(String(options.machineDir ?? '')) || `${lanesRoot}/_machine`
97 const scriptsDir = normalFolder(String(options.scriptsDir ?? '')) || `${root}/.agents/skills/s2-agent-lanes/scripts`
98 // Status would create the machine folder: the mod never runs it where there is none (D6).
99 const isMachine = await exists($, machineDir)
100 const errors = []
101 if (!(await exists($, `${scriptsDir}/lanes.ps1`))) errors.push(`No lanes.ps1 in ${scriptsDir}: set Ather Lanes' scriptsDir to the S2 lane scripts.`)
102 if (!isMachine) errors.push(`No machine folder at ${machineDir}, so no lanes here.`)
103 return { root, main, lanesRoot, machineDir, scriptsDir, repo: readOrigin(await read($, `${main}/.git/config`)), isMachine, errors }
104}
105
106// One process, logged to the debug log so its runs can be counted (A4).
107/** @param {Engine} $ @param {readonly string[]} argv @param {string} cwd @param {string} what */
108const run = async ($, argv, cwd, what) => {
109 $.ui.log(`Ather Lanes: spawn ${what}`, { to: 'debug' })
110 return $.process.run(argv, { cwd, env: GIT_ENV, timeoutMs: 60000 }).catch(error => ({ exitCode: -1, stdout: '', stderr: String(error) }))
111}
112
113/** @param {string} what @param {{ exitCode: number, stderr: string }} ran */
114const failed = (what, ran) => `${what} gave nothing to read (exit ${ran.exitCode}): ${(ran.stderr || '').trim().split(/\r?\n/)[0] ?? ''}`
115
116/** @param {string} path */
117const windows = path => path.replace(/\//g, '\\')
118
119// The files of D2.2 and the reflog, no process: slots, lane and session heartbeats.
120/** @param {Engine} $ @param {Place} at @param {number} now */
121const readFiles = async ($, at, now) => {
122 const machineDir = normalFolder(sources.status?.machineDir || at.machineDir)
123 const minutes = new Date(now).getHours() * 60 + new Date(now).getMinutes()
124 const lanes = sources.status?.lanes ?? []
125 const [slotFiles, beatFiles] = await Promise.all([list($, `${machineDir}/slots`), list($, `${machineDir}/heartbeats`)])
126 const beatNames = new Set(beatFiles.map(one => one.name))
127 const [slots, laneTexts, sessionTexts, reflog] = await Promise.all([
128 Promise.all(slotFiles.filter(one => one.kind === 'file' && one.name.endsWith('.txt')).map(async one => readSlot(one.name.replace(/\.txt$/, ''), await read($, `${machineDir}/slots/${one.name}`), minutes))),
129 Promise.all(lanes.filter(lane => beatNames.has(`${lane.name}.json`)).map(async lane => /** @type {const} */ ([lane.name, await read($, `${machineDir}/heartbeats/${lane.name}.json`)]))),
130 Promise.all(
131 lanes
132 .filter(lane => !lane.missing && lane.path)
133 .map(async lane => {
134 const dir = `${normalFolder(lane.path)}/${unreal.localDir}/lanes`
135 const texts = await Promise.all((await list($, dir)).filter(one => one.name.endsWith('.json')).map(one => read($, `${dir}/${one.name}`)))
136 return /** @type {const} */ ([lane.path, texts])
137 }),
138 ),
139 read($, `${at.main}/.git/logs/refs/remotes/origin/main`),
140 ])
141 sources = { ...sources, slots: slots.filter(one => one !== null), ...beatsOf(sources.status, new Map(laneTexts), new Map(sessionTexts)), mainMovedAt: readReflogTime(reflog) }
142}
143
144// D2.5: each registered lane's intent, from its own docs/intent folder.
145/** @param {Engine} $ @param {Status} status */
146const readIntents = async ($, status) => {
147 const registered = status.lanes.filter(one => one.sidecar === 'registered' && one.intent && !one.missing)
148 const intents = await Promise.all(
149 registered.map(async lane => {
150 const dir = `${normalFolder(lane.path)}/docs/intent/${lane.intent}`
151 const [prompt, progress, findings] = await Promise.all(['prompt.md', 'progress.md', 'findings.md'].map(name => read($, `${dir}/${name}`)))
152 return /** @type {const} */ ([lane.name, intentOf(lane.intent, { prompt, progress, findings })])
153 }),
154 )
155 sources = { ...sources, intents: new Map(intents) }
156}
157
158// D2.1, D2.3 and D2.5: Status, the one for-each-ref over the lane branches, and each registered lane's intent.
159/** @param {Engine} $ @param {Place} at @param {Record<string, unknown>} options */
160const readLanes = async ($, at, options) => {
161 const machine = options.machineDir ? ['-MachineDir', windows(at.machineDir)] : []
162 const ran = await run($, ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', windows(`${at.scriptsDir}/lanes.ps1`), '-Action', 'Status', '-Json', '-Repo', windows(at.root), ...machine], at.root, 'powershell lanes.ps1 -Action Status')
163 const status = readStatus(ran.stdout)
164 runErrors.status = status ? '' : failed('lanes.ps1 -Action Status', ran)
165 if (!status) return
166 sources = { ...sources, status }
167 const branches = laneBranches(status)
168 if (branches.length > 0) {
169 const refs = await run($, ['git', '-C', at.main, 'for-each-ref', `--format=${REF_FORMAT}`, ...branches.map(branch => `refs/heads/${branch}`)], at.main, 'git for-each-ref')
170 runErrors.refs = refs.exitCode === 0 ? '' : failed('git for-each-ref', refs)
171 if (refs.exitCode === 0) sources = { ...sources, refs: readRefs(refs.stdout) }
172 }
173 await readIntents($, status)
174}
175
176// D8, the pane closed: the lanes from files alone (records.mjs statusOfFiles), no process. The sidecars, the lanes
177// folder's one listing (a folder with a `.git` in it is a worktree), the last full read in the store (any session's,
178// read again each time) for the rest and for its git and gh figures; then each registered lane's intent.
179/** @param {Engine} $ @param {Place} at */
180const readLaneFiles = async ($, at) => {
181 full = (await readFull(storeOf($), at.main)) ?? full
182 const [sidecarFiles, entries] = await Promise.all([list($, `${at.machineDir}/lanes`), list($, at.lanesRoot)])
183 const sidecars = (await Promise.all(sidecarFiles.filter(one => one.kind === 'file' && one.name.endsWith('.json')).map(one => read($, `${at.machineDir}/lanes/${one.name}`)))).map(readSidecar)
184 const folders = entries.filter(one => one.kind === 'dir' && folderKey(`${at.lanesRoot}/${one.name}`) !== folderKey(at.machineDir))
185 const worktrees = await Promise.all(folders.map(async one => ((await exists($, `${at.lanesRoot}/${one.name}/.git`)) ? one.name : '')))
186 const status = statusOfFiles({ kept: full?.status ?? null, main: at.main, lanesRoot: at.lanesRoot, machineDir: at.machineDir, sidecars: sidecars.filter(one => one !== null), entries: worktrees.filter(Boolean) })
187 sources = { ...sources, status, refs: full?.refs ?? sources.refs, prs: full?.prs ?? sources.prs }
188 await readIntents($, status)
189}
190
191// The full read just made, kept for the closed pane and for other sessions (lanes-store.mjs).
192/** @param {Engine} $ @param {Place} at @param {number} now */
193const keepRead = async ($, at, now) => {
194 if (!sources.status) return
195 full = { at: now, status: sources.status, refs: sources.refs, prs: sources.prs }
196 await keepFull(storeOf($), at.main, full)
197}
198
199// gh's answer for some lane branches: the one another session kept in the last 5 minutes, else one gh run from the
200// machine folder (outside any checkout), kept for the next.
201/** @param {Engine} $ @param {Place} at @param {string[]} branches @param {number} now */
202const ghPrs = async ($, at, branches, now) => {
203 const store = storeOf($)
204 const kept = await readKeptPrs(store, at.main)
205 if (kept && now - kept.at < PRS_EVERY_MS && branches.every(branch => kept.branches.includes(branch))) return kept.stdout
206 const ran = await run($, ['gh', ...prListArgs(at.repo, branches)], at.machineDir, 'gh pr list')
207 runErrors.prs = ran.exitCode === 0 ? '' : failed('gh pr list', ran)
208 if (ran.exitCode !== 0) return ''
209 await keepPrs(store, at.main, { at: now, branches, stdout: ran.stdout })
210 return ran.stdout
211}
212
213// D2.4: the lane branches' PRs; none to ask for without a GitHub origin or a lane branch.
214/** @param {Engine} $ @param {Place} at @param {number} now */
215const readPrList = async ($, at, now) => {
216 const branches = laneBranches(sources.status, { forPrs: true })
217 const prs = at.repo && branches.length > 0 ? readPrs(await ghPrs($, at, branches, now), at.repo, branches) : new Map()
218 if (prs) sources = { ...sources, prs }
219}
220
221/** @param {Engine} $ */
222const isShown = async $ => (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE_ID && pane.isShown)
223
224// The board from what the last refresh left, this session's lane marked (D10).
225const currentBoard = () => boardOf(laneRecords(sources), sources.slots, laneOf(sources.status, place?.root ?? ''))
226
227// The status line (D8): set when it changes; none off a lanes machine.
228/** @param {Engine} $ */
229const showLine = $ => {
230 const text = place?.isMachine ? lineText(currentBoard()) : undefined
231 if (text === shownLine) return
232 shownLine = text
233 $.ui.status(text)
234}
235
236// The pane shown, with the scripts (D3): Status when due, the files each time, gh when due, the full read kept.
237// Until a Status run gives the lanes, they are read from files as the closed pane reads them.
238/** @param {Engine} $ @param {Place} at @param {Record<string, unknown>} options @param {number} now @param {boolean} isStatusDue */
239const readShown = async ($, at, options, now, isStatusDue) => {
240 if (isStatusDue) {
241 statusAt = now
242 await readLanes($, at, options)
243 }
244 if (sources.status === null) await readLaneFiles($, at)
245 await readFiles($, at, now)
246 const isPrsDue = now - prsAt >= PRS_EVERY_MS - SLACK_MS
247 if (isPrsDue) {
248 prsAt = now
249 await readPrList($, at, now)
250 }
251 if (isStatusDue || isPrsDue) await keepRead($, at, now)
252}
253
254// The pane closed, or shown without the scripts (D8): files only, no process.
255/** @param {Engine} $ @param {Place} at @param {number} now */
256const readClosed = async ($, at, now) => {
257 await readLaneFiles($, at)
258 await readFiles($, at, now)
259}
260
261// One refresh: shown with the scripts, readShown; else readClosed, at most once a minute while closed. The lanes are
262// found again (files only) for each closed read and when Status is due, so a machine folder that went away stops
263// every read. Skipped while another runs.
264/** @param {Engine} $ @param {Record<string, unknown>} options @param {boolean} [force] */
265const refresh = async ($, options, force = false) => {
266 if (isBusy) return
267 isBusy = true
268 let isRead = false
269 try {
270 const now = await $.clock.now()
271 const isOpen = await isShown($)
272 if (!isOpen && now - filesAt < CLOSED_EVERY_MS - SLACK_MS) return
273 isRead = true
274 const isStatusDue = force || now - statusAt >= STATUS_EVERY_MS - SLACK_MS
275 if (!isOpen || isStatusDue || place === null) place = await placeOf($, options)
276 if (!place.isMachine) {
277 sources = { ...NO_SOURCES }
278 runErrors = {}
279 } else if (isOpen && place.errors.length === 0) await readShown($, place, options, now, isStatusDue)
280 else await readClosed($, place, now)
281 filesAt = now
282 refreshedAt = await $.clock.now()
283 } catch (error) {
284 $.ui.log(`Ather Lanes: the refresh failed: ${String(error)}`, { to: 'debug' })
285 } finally {
286 isBusy = false
287 if (isRead) {
288 showLine($)
289 $.ui.invalidate('ui.render')
290 }
291 }
292}
293
294// The one timer; each tick decides what is due.
295/** @param {Engine} $ @param {Record<string, unknown>} options */
296const ensureTimer = ($, options) => {
297 timer ??= $.clock.every(TICK_MS, () => void refresh($, options))
298}
299
300// /lanes and lanes_open: the pane opened, read at once after the caller returns (nothing on its path waits for a
301// process).
302/** @param {Engine} $ @param {Record<string, unknown>} options */
303const openPane = async ($, options) => {
304 const shown = await $.ui.open({ id: PANE_ID, title: 'LANES', focus: true, closeOnEscape: true, rows: 24 })
305 ensureTimer($, options)
306 $.clock.after(0, () => void refresh($, options, true))
307 return shown
308}
309
310// lanes_open (D9): the pane, and a lane opened in place from the first section that lists it.
311/** @param {Engine} $ @param {Record<string, unknown>} options @param {string} name */
312const openTool = async ($, options, name) => {
313 const shown = await openPane($, options)
314 let said = 'Lanes opened.'
315 if (name !== '') {
316 const from = sectionOf(currentBoard(), name)
317 opened = from === null ? null : { name, from }
318 // A lane listed only in the fold opens it; an unknown one leaves the pane folded (D9).
319 isFoldOpen = from === 'fold' || (from !== null && isFoldOpen)
320 said = from === null ? `No lane named ${name} here; Lanes opened, folded.` : `Lanes opened, with ${name} open in place.`
321 $.ui.invalidate('ui.render')
322 }
323 return shown.isPlaced ? said : `${said} It waits for room on screen: /lanes places it.`
324}
325
326// lanes_summary (D9): closed, the files are read first (at most once a minute, no process); shown, what the pane has.
327/** @param {Engine} $ @param {Record<string, unknown>} options */
328const summaryTool = async ($, options) => {
329 if (!(await isShown($))) await refresh($, options)
330 if (!place?.isMachine) return place?.errors.at(-1) ?? NOT_HERE
331 return summaryText(currentBoard(), { now: await $.clock.now(), readAt: full?.at ?? null, clock: clockText })
332}
333
334/** @param {Engine} $ */
335const registerTools = async $ => {
336 await $.tool.register({
337 name: shortName(SUMMARY_TOOL),
338 description: "Ather Lanes: which S2 agent lane this session is in (name, branch, intent, tier, slot, PR) and what on this machine's lanes waits for the user (For you: decisions) or needs a look (A look: failing checks, far behind main, slots, unregistered). Read-only text.",
339 inputSchema: { type: 'object', properties: {} },
340 isDeferred: false,
341 })
342 await $.tool.register({
343 name: shortName(OPEN_TOOL),
344 description: 'Ather Lanes: open the Lanes pane beside the chat, as /lanes does, for the user to see the lanes; with `lane`, that lane opened in place. Read-only.',
345 inputSchema: { type: 'object', properties: { lane: { type: 'string', description: 'A lane to open in place, by its name' } } },
346 isDeferred: false,
347 })
348}
349
350// A press: ↻ reads again now; the fold opens or closes (and with it a lane opened from it); a lane opens in place,
351// or closes when it was the one open there.
352/** @param {Engine} $ @param {Record<string, unknown>} options @param {Press} press */
353const onPress = ($, options, press) => {
354 if (press.kind === 'refresh') void refresh($, options, true)
355 else if (press.kind === 'fold') {
356 isFoldOpen = !isFoldOpen
357 if (opened?.from === 'fold') opened = null
358 } else opened = opened?.name === press.lane.name && opened.from === press.lane.from ? null : press.lane
359 $.ui.invalidate('ui.render')
360}
361
362// A tool hook that failed answers the call with what went wrong, never leaves it unanswered.
363/** @param {unknown} _$ @param {unknown} _e @param {import('claude-code').Caught} next */
364const failedTool = (_$, _e, next) => ({ result: `Ather Lanes could not answer: ${next.error.message ?? next.error.kind}` })
365
366/** @param {import('claude-code').On} on @param {Record<string, unknown>} options */
367export function register(on, options) {
368 // On a lanes machine (files only): the tools, the timer, and one closed read after the start returns (D8).
369 on('session.start', async ($, e, next) => {
370 await $.command.register({ name: 'lanes', description: 'The machine’s lanes: what needs you, what needs a look, and every lane' }).catch(error => $.ui.log(`Ather Lanes: /lanes not registered: ${String(error)}`, { to: 'debug' }))
371 try {
372 place = await placeOf($, options)
373 if (place.isMachine) {
374 await registerTools($)
375 ensureTimer($, options)
376 $.clock.after(0, () => void refresh($, options))
377 }
378 } catch (error) {
379 $.ui.log(`Ather Lanes: not started: ${String(error)}`, { to: 'debug' })
380 }
381 return next(e)
382 })
383
384 on('command.run', { command: 'lanes' }, async $ => {
385 try {
386 await openPane($, options)
387 return { text: 'Lanes opened.' }
388 } catch (error) {
389 return { text: `Ather Lanes: ${String(error)}` }
390 }
391 })
392
393 // The two tools' own names only, so these hooks never see another tool's call (F-12). A failure answers in words.
394 on('tool.call', { tool: SUMMARY_TOOL }, async $ => ({ result: await summaryTool($, options) })).catch(failedTool)
395 on('tool.call', { tool: OPEN_TOOL }, async ($, e) => {
396 const input = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (e))
397 return { result: await openTool($, options, typeof input.lane === 'string' ? input.lane.trim() : '') }
398 }).catch(failedTool)
399
400 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
401 const el = $.ui.resolve(e)
402 try {
403 // A module reloaded while its pane stayed up starts its timer again, and reads once, outside this hook.
404 if (!timer) {
405 ensureTimer($, options)
406 if (refreshedAt === null) $.clock.after(0, () => void refresh($, options, true))
407 }
408 const isDesktop = e.surface !== 'terminal'
409 const errors = [...(place?.errors ?? []), ...Object.values(runErrors).filter(Boolean)]
410 const view = { width: Math.max(40, Number(e.props?.bodyColumns ?? 72)), isDesktop, now: await $.clock.now(), clock: clockText, opened, isFoldOpen, refreshedAt, errors }
411 return drawPane(el, paneLines(currentBoard(), view), { isDesktop, onPress: press => onPress($, options, press) })
412 } catch (error) {
413 $.ui.log(`Ather Lanes: the pane failed to draw: ${String(error)}`, { to: 'debug' })
414 return el.Text({ children: 'Ather Lanes could not draw. /lanes tries again.' })
415 }
416 })
417}
418hooks/ather/packs/unreal.mjs 350 lines1// @ts-check
2// Ather Automata, the Unreal pack: what is particular to the S2 game project.
3// Roles and their proof, the evidence rungs and how tool output shows them,
4// the Editor owner lock, held Editor actions, known traps, the Create catalog
5// and the words the session is asked with. Pure: no `$`.
6
7import { isPiped, withoutHeredocs } from '../shell.mjs'
8
9/** @typedef {import('./index.mjs').Pack} Pack */
10/** @typedef {import('./index.mjs').Rung} Rung */
11
12export const ROLES = /** @type {const} */ (['engineer', 'techart', 'designer'])
13export const ROLE_LABELS = { engineer: 'Engineer', techart: 'Tech artist', designer: 'Designer' }
14export const OWNERS = 'Tin Nguyen, CanhNguyen and VuTruong'
15const RUNG_LABELS = { build: 'a build that succeeded', automation: 'passing tests', readback: 'a read-back check', pie: 'a PIE proof', editor: 'your own Editor check' }
16const PROOF_WORDS = { build: 'build', automation: 'tests', readback: 'read-back', pie: 'PIE', editor: 'Editor check' }
17
18// The studio's intent areas (docs/intent/README.md#areas, decided 2026-10-03).
19export const AREAS = ['Tools', 'Combat', 'AI', 'Enemies', 'Bosses', 'Characters & Animation', 'VFX', 'World & Levels', 'Audio', 'UI', 'Pipeline', 'Optimization']
20
21/** @param {string} text @returns {(typeof ROLES)[number] | null} */
22export const parseRole = text => {
23 const words = text.toLowerCase()
24 if (/design/.test(words)) return 'designer'
25 if (/tech\s*-?\s*art/.test(words)) return 'techart'
26 if (/engineer|programmer|coder|developer/.test(words)) return 'engineer'
27 return null
28}
29
30/** @param {string} text */
31export const normalizeArea = text => AREAS.find(area => area.toLowerCase() === text.trim().toLowerCase()) ?? 'Unsorted'
32
33/** @typedef {Record<'build' | 'automation' | 'readback' | 'pie' | 'editor', Rung>} Evidence */
34
35/** @returns {Evidence} */
36export const emptyEvidence = () => ({
37 build: { state: 'none', detail: '' },
38 automation: { state: 'none', detail: '' },
39 readback: { state: 'none', detail: '' },
40 pie: { state: 'none', detail: '' },
41 editor: { state: 'none', detail: '' },
42})
43
44/** @param {string} role @returns {string[]} */
45const requiredRungs = role => (role === 'engineer' ? ['build', 'automation'] : role === 'techart' ? ['editor', 'pie'] : ['pie'])
46
47// With no role set, any role's proof counts: a PIE proof, or a build that succeeded with passing tests.
48/** @param {Record<string, Rung>} evidence @param {string} role */
49const isProven = (evidence, role) =>
50 role === '' ? evidence.pie?.state === 'pass' || (evidence.build?.state === 'pass' && evidence.automation?.state === 'pass') : requiredRungs(role).every(rung => evidence[rung]?.state === 'pass')
51
52// ---------------------------------------------------------------- the Editor lock
53
54/**
55 * @typedef {{ state: 'free' | 'held' | 'unknown', holder: string, until: string, isStale: boolean, raw: string, session: string }} EditorLock `session`: the first 8 hex of the Claude session named in it, or ''
56 * @param {string | null} raw @param {number} nowMinutes @returns {EditorLock}
57 */
58export const parseEditorLock = (raw, nowMinutes) => {
59 const text = (raw ?? '').trim()
60 const session = /\bsession\s+([0-9a-f]{8})/i.exec(text)?.[1]?.toLowerCase() ?? ''
61 if (text === '') return { state: 'unknown', holder: '', until: '', isStale: false, raw: text, session }
62 const free = /free\s+since\s+(\d{1,2}:\d{2})/i.exec(text)
63 // "…launched by Claude session b3ebb2cb; free for Tin to use; no agent holds it since 20:40" is free too.
64 if (free || /^free\b/i.test(text) || /\bfree (for|to use)\b|\bno (agent|one|lane) holds it\b/i.test(text)) return { state: 'free', holder: '', until: free?.[1] ?? /holds it since\s+(\d{1,2}:\d{2})/i.exec(text)?.[1] ?? '', isStale: false, raw: text, session }
65 const until = /until\s+(\d{1,2}:\d{2})/i.exec(text)?.[1] ?? ''
66 const named = /(?:holder|owner)\s*[:=]\s*([^,;\n]+)|held by\s+([^,;\n]+)/i.exec(text)
67 const holder = (named?.[1] ?? named?.[2] ?? text.split(/\r?\n/)[0] ?? '').replace(/\buntil\b.*$/i, '').trim()
68 const end = /^(\d{1,2}):(\d{2})$/.exec(until)
69 const endMinutes = end ? Number(end[1]) * 60 + Number(end[2]) : null
70 const isStale = endMinutes !== null && nowMinutes - endMinutes > 30 && nowMinutes - endMinutes < 12 * 60
71 return { state: 'held', holder: holder.slice(0, 60), until, isStale, raw: text, session }
72}
73
74// A worker's waiting line, when its long command names the lock file (a wait loop on the Editor):
75// the lock file's first line as written, cut. Nothing else is inferred from the command.
76/** @param {string} command @param {string} raw */
77export const editorLockLine = (command, raw) => {
78 if (!/EDITOR_OWNER/i.test(command)) return ''
79 const first = raw.trim().split(/\r?\n/)[0]?.trim() ?? ''
80 return first.length <= 50 ? first : `${first.slice(0, 49)}…`
81}
82
83// ---------------------------------------------------------------- evidence from tool output
84
85/** @param {string} command */
86export const isBuildCommand = command => /Build\.(bat|sh|cmd)\b|UnrealBuildTool|RunUAT|Wait-ForS2EditorCloseAndBuild|\bbuild\.cmd\b/i.test(withoutHeredocs(command))
87
88// Only a build of the Editor target is evidence for an intent; a tool or shader build is not.
89/** @param {string} command */
90export const isEditorBuild = command => isBuildCommand(command) && /\bS2Editor\b/i.test(command)
91
92/** @param {string} text @returns {'pass' | 'fail' | null} */
93export const buildResult = text => {
94 const lines = [...text.matchAll(/Result:\s*(Succeeded|Failed[^\r\n]*)/g)]
95 const last = lines[lines.length - 1]?.[1]
96 return last === undefined ? null : last.startsWith('Succeeded') ? 'pass' : 'fail'
97}
98
99/** @param {string} command */
100export const isAutomationCommand = command => /Automation\s+RunTests|RunAutomationTests|ExecCmds=.{0,40}Automation|ue-run-automation|Run-S2Automation/i.test(command)
101
102/** @param {string} text @returns {'pass' | 'fail' | null} */
103// A pass needs tests that ran and passed; no tests, any failure or any non-zero exit is a fail.
104export const automationResult = text => {
105 if (/EXIT CODE:\s*-?[1-9]|Result=\{?Fail|\b[1-9]\d* (tests? )?failed\b|\bno (automation )?tests? (were )?(found|matched|run)\b|\b0 tests? (found|ran|run|executed|passed)\b/i.test(text)) return 'fail'
106 if (/\b[1-9]\d* (tests? )?passed\b|\b([1-9]\d*)\/\1 (tests? )?pass|\ball [1-9]\d* tests? passed\b/i.test(text)) return 'pass'
107 return null
108}
109
110// Reading a build's log file, where its Result line is.
111/** @param {string} command */
112export const isLogRead = command => /\.log\b/i.test(command) && !isBuildCommand(command)
113
114/** @typedef {import('./index.mjs').ShellReading} ShellReading */
115
116// What a finished shell command proved, in the order watch.mjs records it.
117/** @param {string} command @param {string} text @param {{ isError?: boolean }} ran @returns {ShellReading} */
118export const readShell = (command, text, ran) => {
119 /** @type {ShellReading} */
120 const out = { rungs: [], context: [], toasts: [], bumps: [] }
121 if (isBuildCommand(command)) {
122 const result = buildResult(text)
123 if (result && isEditorBuild(command)) out.rungs.push({ rung: 'build', value: { state: result, detail: result === 'pass' ? 'Result: Succeeded' : 'Result: Failed' } })
124 if (isPiped(command) && result === 'fail' && ran.isError !== true) {
125 out.context.push('Ather Automata: the build printed "Result: Failed" although the command exited 0. The exit code is the pipe\'s, not the build\'s. Treat this build as failed.')
126 out.toasts.push({ text: 'Ather: piped build reported success but its Result line says Failed.', timeoutMs: 10000 })
127 out.bumps.push('buildsCorrected')
128 } else if (isPiped(command) && result === null && ran.isError !== true) {
129 out.context.push('Ather Automata: this build was piped through a filter, so its exit code is the filter\'s. Read the build\'s own "Result:" line from the log before claiming the build passed.')
130 }
131 }
132 if (isLogRead(command) && /\bS2Editor\b/.test(text)) {
133 const result = buildResult(text)
134 if (result) out.rungs.push({ rung: 'build', value: { state: result, detail: result === 'pass' ? 'Result: Succeeded (from the log)' : 'Result: Failed (from the log)' } })
135 }
136 if (isAutomationCommand(command)) {
137 // Every run replaces the last result: a run whose outcome cannot be read is no evidence.
138 const result = automationResult(text)
139 out.rungs.push({ rung: 'automation', value: { state: result ?? 'none', detail: result === 'pass' ? 'tests passed' : result === 'fail' ? 'tests failed or none ran' : 'no test result read' } })
140 }
141 return out
142}
143
144/** @param {string} input an MCP call's arguments as text */
145export const isAssetSave = input => /save_assets|save_asset\b|save_actor|save_level|SaveAssets|SavePackage/i.test(input)
146
147// What an MCP call is evidence of. PIE counts only when PIE or a test run is started,
148// never for any console command.
149/** @param {string} input @returns {'write' | 'read' | 'pie' | null} */
150export const mcpKind = input => {
151 if (/StartPIE|PlayInEditor|RunTestSimulation|Sipher\.Bench\.Combo\.Start\b/i.test(input)) return 'pie'
152 if (/\b(set_|create_|connect_|break_|add_|delete|remove_|compile|save_|write_|update_|SetRowField)/i.test(input)) return 'write'
153 if (/\b(get_|read_|find_|list_|exists|describe|GetPIEStatus)/i.test(input)) return 'read'
154 return null
155}
156
157// ---------------------------------------------------------------- held actions
158
159const HELD_LABELS = { 'editor-restart': 'Editor restarts', 'asset-save': 'Asset saves through MCP' }
160const HELD_NOUNS = { 'editor-restart': 'an Editor restart', 'asset-save': 'an asset save' }
161
162/** @param {string} segment @param {readonly string[]} held @returns {string | null} */
163const heldSegment = (segment, held) => (held.includes('editor-restart') && /(Stop-Process|taskkill|kill)\b.*UnrealEditor|Start-Process.*UnrealEditor|UnrealEditor(\.exe)?\s+.*\.uproject/i.test(segment) ? 'editor-restart' : null)
164
165// ---------------------------------------------------------------- known traps
166
167// `rule`: where the repository already prevents the trap, as a file under the checkout and a phrase
168// only that rule contains. Once the phrase is there, the trap is not offered as a rule again.
169export const GOTCHAS = [
170 { id: 'live-coding', pattern: /Unable to build while Live Coding is active/i, title: 'A running Editor blocks the build (Live Coding)', fix: 'Close the Editor, or use Wait-ForS2EditorCloseAndBuild.ps1 when another lane holds it. UHT success at the top of the log means nothing here.', rule: { file: '.agents/skills/s2-unreal-engine/SKILL.md', text: 'A running Editor with Live Coding blocks Build.bat' } },
171 { id: 'port-8000', pattern: /HttpListener unable to bind/i, title: 'MCP port 8000 still held (TIME_WAIT)', fix: 'Stop the Editor, wait until Get-NetTCPConnection -LocalPort 8000 returns nothing in any state, then relaunch and check the listener PID.', rule: { file: '.agents/skills/unreal-mcp/SKILL.md', text: 'Get-NetTCPConnection -LocalPort 8000' } },
172 { id: 'restore-packages', pattern: /Restore Packages/i, title: 'Restore Packages dialog blocks startup', fix: 'Stop the Editor and move Saved/Autosaves/PackageRestoreData.json aside (rename, never delete) before relaunching.', rule: { file: '.agents/skills/unreal-mcp/SKILL.md', text: 'PackageRestoreData.json' } },
173 { id: 'mcp-session', pattern: /Unknown session id|no session id/i, title: 'Stale MCP session id', fix: 'Delete the cached MCP session file after an Editor relaunch, and run MCP clients one at a time.', rule: { file: '.agents/skills/unreal-mcp/SKILL.md', text: 'Unknown session id' } },
174 { id: 'asset-missing', pattern: /Asset does not exist/i, title: '"Asset does not exist" from every Editor tool', fix: 'Check McpPieTools GetPIEStatus first: a harness that threw before StopPIE leaves PIE running. In a fresh Editor verify saves by mtime and git.', rule: { file: '.agents/skills/unreal-mcp/SKILL.md', text: 'GetPIEStatus' } },
175 { id: 'index-lock', pattern: /index\.lock/i, title: 'Git index.lock in the shared checkout', fix: 'Find the holder with Get-CimInstance Win32_Process git.exe. Only a stale lock may be renamed aside, in the same command as the next git call.', rule: { file: 'docs/skills/git-lfs-traffic-control.md', text: 'Get-CimInstance Win32_Process' } },
176 { id: 'disk', pattern: /No space left on device/i, title: 'Disk allowance spent', fix: 'Load .agents/skills/s2-free-disk-space/SKILL.md; never fall back to mutating the shared tree.' },
177 { id: 'mixed-tree', pattern: /\bLNK20(01|19)\b/, title: 'Unresolved externals (possible mixed tree)', fix: 'If files were just restored, verify every path against HEAD before building; restore in one batched checkout.' },
178 { id: 'ps-redirect', pattern: /NativeCommandError/, title: 'PowerShell wrapped native stderr as an error', fix: 'Do not redirect 2>&1 on native executables in Windows PowerShell 5.1; read the exit code instead.', rule: { file: 'AGENTS.md', text: 'do not redirect `2>&1` on native executables' } },
179]
180
181// ---------------------------------------------------------------- skills and Create
182
183// The skills worth one press, by what they are for. Only those present in .agents/skills show.
184// A name is a folder under .agents/skills; a path names one elsewhere (the manual library).
185export const SKILL_GROUPS = [
186 { group: 'Review and proof', names: ['thermo-nuclear-code-quality-review', 'editor-video-walkthrough'] },
187 { group: 'Authoring', names: ['unreal-editor-agent-authoring'] },
188 { group: 'Agentic testing', names: ['mainchar-test-simulation', 'talab', 'unreal-pie-character-measurement', 'unreal-agent-feature-harness', 'unreal-test-harness', 'unreal-insights-pie-frame-capture'] },
189 { group: 'Git and pull requests', names: ['pr-to-main', 'babysit-pr', 's2-github-pr-review', 's2-worktree-cleanup', 'git-poller-storm'] },
190 { group: 'Explain it to me', names: ['bro', '.agents/skill-library/visuals/show-me'] },
191]
192
193// Making things in the Editor: the skills that build content through MCP, grouped by what is made,
194// each led by what it does. The first three of a group show; the rest wait behind More.
195export const CREATE_GROUPS = [
196 { group: 'VFX and look', items: [
197 { name: 'unreal-niagara-mcp', verb: 'Make or tune a Niagara effect' },
198 { name: 'sw-environment-preset-from-reference', verb: 'Build a weather and sky preset from a reference image' },
199 { name: 'metahuman-groom-wardrobe', verb: 'Turn grooms into MetaHuman wardrobe items' },
200 { name: '.agents/skill-library/vfx/arena-pattern-vfx-designer', verb: 'Design arena pattern telegraphs' },
201 ] },
202 { group: 'Characters and animation', items: [
203 { name: 'author-child-visual-montage-sync', verb: 'Sync a child visual montage to its parent' },
204 { name: 'author-curve3d-motion-profiles', verb: 'Author a motion profile (Curve3D)' },
205 { name: '.agents/skill-library/animation/ninetails-tail-shape-authoring', verb: 'Shape the NineTails tails' },
206 { name: '.agents/skill-library/animation/ninetails-tail-animation-bake', verb: 'Bake NineTails tail animation' },
207 ] },
208 { group: 'AI and encounters', items: [
209 { name: 'bt-graph', verb: 'Edit a Behavior Tree' },
210 { name: 'boss-bt-authoring', verb: 'Design a boss or elite fight' },
211 { name: 'team-move-rule-book-authoring', verb: 'Make a Team Move rule book for a squad' },
212 { name: 's2-goai-config-authoring', verb: 'Set up GOAI NPC behaviour' },
213 { name: 'qte-content-authoring', verb: 'Add a QTE' },
214 { name: '.agents/skill-library/ai-enemy/behavior-tree-pattern-template-authoring', verb: 'Make a reusable Behavior Tree pattern' },
215 { name: '.agents/skill-library/testing/map-session-test-setup', verb: 'Set up a boss-room test session' },
216 ] },
217 { group: 'Enemies', items: [
218 { name: '.agents/skill-library/ai-enemy/enemy-qualification-verifier', verb: 'Check an enemy against its GDD' },
219 { name: 'maintain-sipher-montage-tools', verb: 'Tune enemy montage frame data' },
220 ] },
221 { group: 'Levels and cinematics', items: [
222 { name: 'level-cinematics-authoring', verb: 'Direct a Level Cinematic' },
223 { name: 'traversal-module-authoring', verb: 'Build traversal modules' },
224 { name: 'unreal-demo-gym-authoring', verb: 'Make a demo or test gym level' },
225 { name: 'sipher-navmesh-bake', verb: 'Bake navmesh for a level' },
226 { name: '.agents/skill-library/authoring/sipher-smart-object-mcp-workflow', verb: 'Author Smart Objects' },
227 ] },
228 { group: 'Audio', items: [{ name: '.agents/skill-library/audio/ability-beat-audio-authoring', verb: 'Time combat SFX to ability beats' }] },
229 { group: 'Anything else', items: [{ name: 'unreal-mcp', verb: 'Edit any live asset' }] },
230]
231
232// Which groups come first, by role: what each role makes most.
233const CREATE_ORDER = {
234 techart: ['VFX and look', 'Characters and animation', 'Levels and cinematics', 'AI and encounters', 'Enemies', 'Audio', 'Anything else'],
235 designer: ['AI and encounters', 'Enemies', 'Levels and cinematics', 'Characters and animation', 'VFX and look', 'Audio', 'Anything else'],
236 engineer: ['AI and encounters', 'Levels and cinematics', 'VFX and look', 'Characters and animation', 'Enemies', 'Audio', 'Anything else'],
237}
238
239/** @param {string} name */
240const skillFolder = name => (name.includes('/') ? name : `.agents/skills/${name}`)
241
242/** @param {string} verb @param {string} name @param {{ isHeld: boolean, holder: string, until: string }} editor */
243const createPrompt = (verb, name, editor) =>
244 `I want to ${verb.charAt(0).toLowerCase()}${verb.slice(1)} in the Unreal Editor. Use the ${name.split('/').pop()} skill (${skillFolder(name)}/SKILL.md). First ask me what I want, one question at a time and at most three. Then record it as an intent with the intent skill, take the Editor owner lock as AGENTS.md says${editor.isHeld ? ` (it is held by ${editor.holder || 'another lane'}${editor.until ? ` until ${editor.until}` : ''}: ask that lane for a window first)` : ''}, build it in the Editor, and show me the result.`
245
246/** @param {{ isHeld: boolean, isFree: boolean, holder: string, until: string }} editor */
247const createMeta = editor => (editor.isHeld ? `Editor held by ${editor.holder || 'another lane'}${editor.until ? ` until ${editor.until}` : ''}: the session asks for a window first.` : editor.isFree ? 'Editor free: the session takes the lock and starts.' : 'The session checks the Editor lock first.')
248
249// ---------------------------------------------------------------- what the session is asked
250
251/** @param {string} role @param {string} slug */
252const briefPrompt = (role, slug) =>
253 role !== 'techart' && role !== 'designer'
254 ? `Dispatch a background Opus worker for intent ${slug} using .agents/skills/intent/assets/worker-brief.md: exact paths, the acceptance checks it must prove, the shared-tree rule, no commits in the shared checkout.`
255 : `Dispatch one background Opus worker for intent ${slug} as the only Editor MCP user: take the Editor owner lock first, read back every write, never save a package that does not compile, release the lock when done. Use .agents/skills/intent/assets/worker-brief.md.`
256
257/** @param {string} role @param {string} slug */
258const provePrompt = (role, slug) =>
259 role === '' || role === 'engineer'
260 ? `Prove intent ${slug}: build S2Editor Development with the output written to a log file, report the build's own Result: line, then run the relevant automation tests and report the counts.`
261 : role === 'techart'
262 ? `Prove intent ${slug}: record a PIE proof with the map and capture path, and tell me which asset the change never touched to open in the Editor for my own check.`
263 : `Prove intent ${slug} in PIE with the MainChar test simulation templates (.agents/skills/mainchar-test-simulation/SKILL.md) and record the proof path.`
264
265/** @param {string} role @param {string} slug */
266const shipPrompt = (role, slug) =>
267 role === 'designer'
268 ? `Summarise intent ${slug} for an owner to land: what changed, the evidence for each checklist item, and what is still owed.`
269 : `Prepare intent ${slug} for landing: extract the change onto a clean branch in its own worktree, audit the diff and every binary asset for lost edits, open the PR from .github/pull_request_template.md, then wait for my go before merging.`
270
271export const TOUR_PROMPT = 'Give me the Ather tour: follow .agents/skills/ather-tour/SKILL.md step by step.'
272
273/** @param {string} question */
274const askPrompt = question =>
275 `I asked Ather: "${question}" Answer briefly in plain words. Ather is this studio's Claude Code mod (docs/design-docs/s2-director-kit.md, section 5): /ather shows what needs me and the next step, /ather tour walks a newcomer through the workflow, /away hands over while I am away. If the question is about my work instead, answer from this checkout.`
276
277/** @type {Pack} */
278export const unreal = {
279 id: 'unreal',
280 roles: ROLES,
281 roleLabels: ROLE_LABELS,
282 roleDescriptions: { engineer: 'Builds and automation tests.', techart: 'A PIE proof and your own Editor check.', designer: 'A PIE proof.' },
283 roleWords: 'designer, tech artist or engineer',
284 roleHelp: 'Which role? /ather role designer, /ather role tech artist or /ather role engineer.',
285 roleFallback: 'Tour skipped. Say your role any time with /ather role designer, tech artist or engineer.',
286 roleKey: '',
287 parseRole,
288 owners: OWNERS,
289 // Owner lines that name a team, not a person: each shown with its lead.
290 teams: { Cinematic: 'Tien Dang' },
291 // How the studio's git names read (worklist.mjs tidyName): role and studio words at the end, and family names.
292 names: {
293 suffix: /^(art|artist|vfx|ta|ge|gd|tech|techart|sipher|ather|atherlabs|labs|producer|game|design|designer|engineer|dev|qa)$/i,
294 studio: /(sipher|atherlabs|ather)$/i,
295 families: ['nguyen', 'huynh', 'hoang', 'truong', 'duong', 'trinh', 'luong', 'tran', 'pham', 'phan', 'dang', 'dinh', 'doan', 'quach', 'than', 'bui', 'ngo', 'lam', 'mai', 'cao', 'le', 'vo', 'vu', 'do', 'ho', 'ly'],
296 },
297 areas: AREAS,
298 normalizeArea,
299 rungLabels: RUNG_LABELS,
300 proofWords: PROOF_WORDS,
301 emptyEvidence,
302 requiredRungs,
303 isProven,
304 anyProofText: 'a PIE proof, or a build that succeeded with passing tests',
305 localDir: 'Saved/AtherAutomata',
306 debriefPath: slug => `Saved/AtherAutomata/debriefs/${slug}.md`,
307 lockFile: 'Saved/EDITOR_OWNER.txt',
308 parseLock: parseEditorLock,
309 lockLine: editorLockLine,
310 lockRoles: ['techart', 'designer'],
311 ownCheck: { role: 'techart', after: 'pie', rung: 'editor', label: 'I checked it in the Editor', hint: 'PIE proof ✓ · your own Editor check is the last proof.', proveHint: ' After your own Editor check, type /ather checked.', detail: 'checked by you in the Editor', reply: 'Recorded: you checked it in the Editor.' },
312 traps: GOTCHAS,
313 held: { labels: HELD_LABELS, nouns: HELD_NOUNS, kinds: ['editor-restart', 'asset-save'], defaults: ['merge', 'push-main'] },
314 heldSegment,
315 mergePolicy: 'hold',
316 isAssetSave,
317 readShell,
318 mcpKind,
319 binaryAssets: /\.(uasset|umap)$/i,
320 briefPaths: /[A-Za-z]:[\\/]|\b(Source|Content|Plugins|Config|docs|tools|scripts)\/|\/Game\/|\.(cpp|h|cs|py|md|uasset|umap|ini)\b/,
321 skillGroups: SKILL_GROUPS,
322 createGroups: CREATE_GROUPS,
323 createOrder: CREATE_ORDER,
324 createTitle: 'Make it in the Editor',
325 createMeta,
326 createPrompt,
327 prompts: {
328 brief: briefPrompt,
329 prove: provePrompt,
330 ship: shipPrompt,
331 shipHint: role => (role === 'designer' ? 'Summarises the work so an owner can land it.' : 'A clean PR; nothing merges without your go.'),
332 briefHint: 'A background agent does the work, briefed the studio way for your role.',
333 tour: TOUR_PROMPT,
334 tourToast: 'Ather: new here? Type /ather tour for a six-step tour of how S2 works with Claude Code.',
335 ask: askPrompt,
336 },
337 mandate: {
338 flags: 'keep behaviour changes behind a CVar that defaults to the current behaviour',
339 allowed: 'push branches, open draft PRs, open PRs to main',
340 merge: 'never merge',
341 away: 'The session may push branches and open PRs; nothing merges until you are back.',
342 pane: 'The session keeps working; merges and pushes to main wait for your review.',
343 panes: 'The sessions keep working; merges and pushes to main wait for your review.',
344 },
345 statusWhat: 'this S2 session',
346 notHere: 'Ather Automata works in S2 checkouts (a docs/intent folder); none here.',
347 gates: [],
348 production: null,
349}
350hooks/ather/team.mjs 312 lines1// @ts-check
2// Ather Automata: the team's intents as origin/main has them (or origin/<base>, where the team
3// merges into another branch), each dated by its folder's last commit there, merged with the
4// folders only this checkout has (D1-D3). Pure: git
5// and the files come through a Repo of closures built where `$` lives (console.mjs).
6// Every git call here reads, and never the working tree or the index; the fetch writes
7// only the remote-tracking ref.
8
9import { branchNamed } from './shell.mjs'
10
11/**
12 * `isAborted`: the app took the run back before it ended (it never ran to an exit code).
13 * @typedef {{ exitCode: number, stdout: string, stderr?: string, isAborted?: boolean }} Ran
14 * @typedef {{
15 * git: (args: readonly string[], options?: { stdin?: string, timeoutMs?: number }) => Promise<Ran>,
16 * read: (path: string) => Promise<string | null>,
17 * list: (path: string) => Promise<readonly { name: string, kind: string }[]>,
18 * mtime: (path: string) => Promise<number>,
19 * real?: (folder: string) => Promise<string>,
20 * }} Repo `git` runs in the checkout with GIT_ENV; one that could not start or ran out of time answers exit code -1, the reason in stderr.
21 * `real`: the folder a path really lands in, behind any symbolic link; without it folders are compared as given
22 * @typedef {{ files: string[], at: number, firstAuthor: string, prompt: string, progress: string, findings: string }} MainFolder `at`: the folder's last commit, ms
23 * @typedef {{ sha: string, folders: Map<string, MainFolder> }} MainSnapshot what origin/main held at `sha`
24 * @typedef {{ key: string, dirty: Set<string>, committed: Set<string> }} LocalState which folders are uncommitted, and which this branch committed since main, as of `key`
25 * @typedef {{ main: MainSnapshot | null, local: LocalState | null }} TeamCache what the last read learned; each part is read again only when what it depends on moved
26 * @typedef {import('./model.mjs').IntentFiles} IntentFiles
27 */
28
29// Every git call Ather makes reads without taking the index lock, and never asks for a password (D2).
30export const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
31// The remote-tracking ref of the branch a checkout's team merges into (its base).
32/** @param {string} base */
33const refOf = base => `origin/${base}`
34export const MAIN = refOf('main')
35export const INTENTS = 'docs/intent'
36// The fetch (D2): origin's base into origin/<base> by an explicit refspec (a narrowed remote.origin.fetch
37// would not move it otherwise), no tags, no FETCH_HEAD, no submodules, and no automatic gc or
38// maintenance: a background fetch in a shared checkout must not start a repack.
39// Several branches in one fetch, each into its own remote-tracking ref (the overlap check's, overlap.mjs).
40/** @param {...string} branches */
41export const fetchArgs = (...branches) => ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false', 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', 'origin', ...branches.map(branch => `+refs/heads/${branch}:refs/remotes/${refOf(branch)}`)]
42export const FETCH_ARGS = fetchArgs('main')
43export const FETCH_EVERY_MS = 10 * 60 * 1000
44// As long as the engine lets a process run: a slow fetch is not a failed one.
45export const FETCH_TIMEOUT_MS = 10 * 60 * 1000
46export const EMPTY_CACHE = /** @type {TeamCache} */ ({ main: null, local: null })
47
48// The branch a checkout's team merges into: `baseBranch` in its .ather/profile.json when it reads as a branch name, else the default
49// branch its clone knows (`defaultBranch`: packs/index.mjs choosePack; '' when it cannot be told), else main.
50/** @param {Record<string, any> | null} profile @param {string} defaultBranch */
51export const baseOf = (profile, defaultBranch) => branchNamed(profile?.baseBranch) || branchNamed(defaultBranch) || 'main'
52
53/** @param {string} text */
54const lf = text => text.replace(/\r\n/g, '\n')
55
56// The intent folder a repository path is in: "docs/intent/lead-vfx/prompt.md" → "lead-vfx".
57/** @param {string} path */
58export const slugOf = path => /^docs\/intent\/([^/]+)\/./.exec(path.trim())?.[1] ?? ''
59
60// `git ls-tree -r --name-only` under docs/intent: each folder with the names of its files.
61/** @param {string} text @returns {Map<string, string[]>} */
62export const parseTree = text => {
63 /** @type {Map<string, string[]>} */
64 const folders = new Map()
65 for (const line of lf(text).split('\n')) {
66 const slug = slugOf(line)
67 if (!slug) continue
68 folders.set(slug, [...(folders.get(slug) ?? []), line.trim().slice(`${INTENTS}/${slug}/`.length)])
69 }
70 return folders
71}
72
73// `git log --format=%x00%ct%x09%an --name-only` under docs/intent, newest first: each folder's
74// last commit time (ms) and the author of its first commit.
75/** @param {string} text @returns {Map<string, { at: number, firstAuthor: string }>} */
76export const parseLog = text => {
77 /** @type {Map<string, { at: number, firstAuthor: string }>} */
78 const folders = new Map()
79 for (const record of lf(text).split('\0').slice(1)) {
80 const [head = '', ...paths] = record.split('\n')
81 const [seconds = '', author = ''] = head.split('\t')
82 const at = Number(seconds) * 1000
83 if (!Number.isFinite(at) || at <= 0) continue
84 for (const slug of new Set(paths.map(slugOf).filter(Boolean))) folders.set(slug, { at: folders.get(slug)?.at ?? at, firstAuthor: author.trim() })
85 }
86 return folders
87}
88
89// `git cat-file --batch` output, one entry per object asked for: its text, or null when missing.
90// Sizes count bytes, so the text is walked as UTF-8.
91/** @param {string} text @param {number} count @returns {(string | null)[]} */
92export const parseBatch = (text, count) => {
93 const bytes = new TextEncoder().encode(text)
94 const decoder = new TextDecoder()
95 /** @type {(string | null)[]} */
96 const out = []
97 let at = 0
98 while (out.length < count && at < bytes.length) {
99 const end = bytes.indexOf(10, at)
100 if (end < 0) break
101 const head = decoder.decode(bytes.subarray(at, end))
102 at = end + 1
103 const blob = / blob (\d+)$/.exec(head)
104 if (!blob) {
105 out.push(null)
106 continue
107 }
108 const size = Number(blob[1])
109 out.push(decoder.decode(bytes.subarray(at, at + size)))
110 at += size + 1
111 }
112 while (out.length < count) out.push(null)
113 return out
114}
115
116// `git status --porcelain=v1 -z` under docs/intent: the folders with uncommitted or untracked files.
117/** @param {string} text */
118export const parseStatus = text => new Set(text.split('\0').map(entry => slugOf(entry.replace(/^.. /, ''))).filter(Boolean))
119
120/** @param {string} prompt */
121const isCompleted = prompt => /^\s*-\s*Status:\s*completed/im.test(prompt)
122
123// The text of each file at `sha`, in order; null for one that is missing (one `git cat-file --batch`).
124/** @param {Repo} repo @param {string} sha @param {readonly string[]} paths */
125export const blobs = async (repo, sha, paths) => {
126 if (paths.length === 0) return []
127 const ran = await repo.git(['cat-file', '--batch'], { stdin: paths.map(path => `${sha}:${path}\n`).join('') })
128 return ran.exitCode === 0 ? parseBatch(ran.stdout, paths.length) : paths.map(() => null)
129}
130
131/** @param {string} a @param {string} b */
132const isSamePath = (a, b) => {
133 const norm = (/** @type {string} */ path) => path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
134 return norm(a) === norm(b)
135}
136
137/** @param {Repo} repo @param {string} ref */
138const shaOf = async (repo, ref) => {
139 const ran = await repo.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
140 return ran.exitCode === 0 ? ran.stdout.trim() : ''
141}
142
143// What origin/main (origin/<base>) holds under docs/intent: every folder's files, last commit and first
144// author, its prompt.md, and the progress and findings of open ones. Read again only when the ref moved:
145// `previous` is returned as it is while the ref is still at its commit. null: no such ref.
146/** @param {Repo} repo @param {MainSnapshot | null} previous @param {string} [base] @returns {Promise<MainSnapshot | null>} */
147export const readMain = async (repo, previous, base = 'main') => {
148 const sha = await shaOf(repo, refOf(base))
149 if (!sha) return null
150 if (previous?.sha === sha) return previous
151 const [tree, log] = await Promise.all([repo.git(['ls-tree', '-r', '--name-only', sha, '--', INTENTS]), repo.git(['log', sha, '--format=%x00%ct%x09%an', '--name-only', '--', INTENTS])])
152 if (tree.exitCode !== 0 || log.exitCode !== 0) return previous
153 const files = parseTree(tree.stdout)
154 const dates = parseLog(log.stdout)
155 const slugs = [...files.keys()].filter(slug => files.get(slug)?.includes('prompt.md'))
156 const prompts = await blobs(repo, sha, slugs.map(slug => `${INTENTS}/${slug}/prompt.md`))
157 const open = slugs.filter((_, index) => !isCompleted(prompts[index] ?? ''))
158 const more = await blobs(repo, sha, open.flatMap(slug => [`${INTENTS}/${slug}/progress.md`, `${INTENTS}/${slug}/findings.md`]))
159 /** @type {Map<string, MainFolder>} */
160 const folders = new Map()
161 slugs.forEach((slug, index) => {
162 const at = open.indexOf(slug)
163 folders.set(slug, {
164 files: files.get(slug) ?? [],
165 at: dates.get(slug)?.at ?? 0,
166 firstAuthor: dates.get(slug)?.firstAuthor ?? '',
167 prompt: lf(prompts[index] ?? ''),
168 progress: at < 0 ? '' : lf(more[at * 2] ?? ''),
169 findings: at < 0 ? '' : lf(more[at * 2 + 1] ?? ''),
170 })
171 })
172 return { sha, folders }
173}
174
175// Which local folders are uncommitted, and which this branch committed since main. Both calls load
176// the whole index of a large checkout, so readTeam asks only when `key` (main, HEAD and the intent
177// files' times) moved.
178/** @param {Repo} repo @param {string} sha @param {string} key @returns {Promise<LocalState>} */
179const readLocal = async (repo, sha, key) => {
180 const [status, ahead] = await Promise.all([repo.git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', INTENTS]), repo.git(['log', `${sha}..HEAD`, '--format=%x00%ct%x09%an', '--name-only', '--', INTENTS])])
181 return { key, dirty: parseStatus(status.exitCode === 0 ? status.stdout : ''), committed: new Set(parseLog(ahead.exitCode === 0 ? ahead.stdout : '').keys()) }
182}
183
184// The checkout's copy of an intent main has too wins when it says something else and is newer:
185// uncommitted, or committed on this branch since main (D1). Main's progress and findings count only
186// where main kept them (for open intents).
187/** @param {{ prompt: string, progress: string, findings: string }} local @param {MainFolder} onMain @param {boolean} isNewer */
188export const localWins = (local, onMain, isNewer) =>
189 isNewer && (local.prompt !== onMain.prompt || (onMain.progress !== '' && local.progress !== onMain.progress) || (onMain.findings !== '' && local.findings !== onMain.findings))
190
191// The intents to show: origin/main's, and the checkout's own folders. A folder on both is read from
192// main unless the checkout's copy wins (localWins); the tracked one (`pinned`) always from the
193// checkout, where its session writes. Main's are dated by their last commit there, the checkout's by
194// their files (D3). `isRepo` false: not a git checkout of its own, so only its folders are read, untagged.
195// `base`: the branch the checkout's team merges into, when that is not main; "main" above is then origin/<base>.
196/**
197 * @param {Repo} repo @param {string} root
198 * @param {{ cache: TeamCache, pinned: string | null, base?: string }} options
199 * @returns {Promise<{ isRepo: boolean, cache: TeamCache, intents: IntentFiles[] }>}
200 */
201export const readTeam = async (repo, root, { cache, pinned, base = 'main' }) => {
202 const top = await repo.git(['rev-parse', '--show-toplevel'])
203 // Git names the checkout's real folder; the session may have reached it through a link (macOS /var, /tmp).
204 const isRepo = top.exitCode === 0 && (isSamePath(top.stdout, root) || (repo.real !== undefined && isSamePath(top.stdout, await repo.real(root).catch(() => root))))
205 const main = isRepo ? await readMain(repo, cache.main, base) : null
206 const folders = []
207 for (const entry of await repo.list(`${root}/${INTENTS}`).catch(() => [])) {
208 if (entry.kind !== 'dir') continue
209 const dir = `${root}/${INTENTS}/${entry.name}`
210 const prompt = await repo.read(`${dir}/prompt.md`)
211 if (prompt === null) continue
212 const isOpen = !isCompleted(prompt)
213 const [progress, findings] = await Promise.all([repo.read(`${dir}/progress.md`), repo.read(`${dir}/findings.md`)])
214 const times = await Promise.all(['prompt.md', 'progress.md', 'findings.md', 'log.md'].map(name => repo.mtime(`${dir}/${name}`)))
215 const text = { prompt: lf(prompt), progress: isOpen || entry.name === pinned ? lf(progress ?? '') : '', findings: isOpen ? lf(findings ?? '') : '' }
216 folders.push({ slug: entry.name, dir, text, times })
217 }
218 const key = main ? [main.sha, await shaOf(repo, 'HEAD'), ...folders.map(one => `${one.slug}:${one.times.join(',')}`)].join('|') : ''
219 const local = !main ? null : cache.local?.key === key ? cache.local : await readLocal(repo, main.sha, key)
220 /** @type {Map<string, IntentFiles>} */
221 const out = new Map()
222 for (const [slug, folder] of main?.folders ?? []) {
223 out.set(slug, { slug, prompt: folder.prompt, progress: folder.progress, findings: folder.findings, files: folder.files, hasDebrief: false, updatedAt: folder.at, source: 'main', firstAuthor: folder.firstAuthor })
224 }
225 for (const { slug, dir, text, times } of folders) {
226 const onMain = main?.folders.get(slug)
227 if (onMain && slug !== pinned && !localWins(text, onMain, Boolean(local && (local.dirty.has(slug) || local.committed.has(slug))))) continue
228 const [prompt = 0, progress = 0, , log = 0] = times
229 out.set(slug, {
230 slug,
231 ...text,
232 files: slug === pinned ? (await repo.list(dir).catch(() => [])).map(one => one.name) : [],
233 hasDebrief: false,
234 updatedAt: Math.max(prompt, progress, log),
235 source: 'local',
236 firstAuthor: onMain?.firstAuthor ?? '',
237 })
238 }
239 return { isRepo, cache: { main, local }, intents: [...out.values()] }
240}
241
242/**
243 * Where the background fetch stands, for the sync line. `lock`: the git lock a failed fetch ran into.
244 * `base`: the branch fetched, when the checkout's team does not merge into main.
245 * @typedef {{ isRepo: boolean, hasMain: boolean, isFetching: boolean, triedAt: number, fetchedAt: number, failedAt: number, error: string, lock: string, base?: string }} Sync
246 */
247
248/** @type {Sync} */
249export const NO_SYNC = { isRepo: false, hasMain: false, isFetching: false, triedAt: 0, fetchedAt: 0, failedAt: 0, error: '', lock: '' }
250
251// A fetch may start on its own: a git checkout, none running, the last try at least ten minutes ago (or never).
252/** @param {Sync} sync @param {number} now */
253export const isFetchDue = (sync, now) => sync.isRepo && !sync.isFetching && (sync.triedAt === 0 || now - sync.triedAt >= FETCH_EVERY_MS)
254
255// ↻ fetches at once, except after a fetch that ran into a git lock: that one waits for its next due time.
256/** @param {Sync} sync @param {number} now */
257export const canFetchNow = (sync, now) => sync.isRepo && !sync.isFetching && (!sync.lock || isFetchDue(sync, now))
258
259// The lock a failed git call names ("refs/remotes/origin/main.lock", "index.lock"), inside .git; '' when none.
260/** @param {string} text */
261export const lockOf = text => {
262 const path = (/([^\s'"]+\.lock)\b/.exec(text)?.[1] ?? '').replace(/\\/g, '/')
263 return path.includes('/.git/') ? path.slice(path.lastIndexOf('/.git/') + 6) : path.split('/').pop() ?? ''
264}
265
266// What a git run that failed says: its last line of complaint, else its exit code.
267/** @param {Ran} ran @param {string} command */
268export const complaintOf = (ran, command) => ((ran.stderr ?? '').trim().split('\n').pop() || `${command} exited with ${ran.exitCode}`).slice(0, 200)
269
270// Fetches origin's main, or the `base` given. Synced means git said so and origin/<base> resolves after it (`moved`: it moved).
271// On a failure: git's last line of complaint, and the lock it ran into, if any. `isAborted`: the app took the
272// run back, so nothing was tried.
273/** @param {Repo} repo @param {string} [base] @returns {Promise<{ error: string, lock: string, moved: boolean, isAborted?: true }>} */
274export const fetchMain = async (repo, base = 'main') => {
275 const before = await shaOf(repo, refOf(base))
276 const ran = await repo.git(base === 'main' ? FETCH_ARGS : fetchArgs(base), { timeoutMs: FETCH_TIMEOUT_MS })
277 const after = await shaOf(repo, refOf(base))
278 if (ran.exitCode === 0 && after) return { error: '', lock: '', moved: after !== before }
279 return { error: complaintOf(ran, 'git fetch'), lock: lockOf(ran.stderr ?? ''), moved: false, ...(ran.isAborted ? { isAborted: /** @type {const} */ (true) } : {}) }
280}
281
282// One sync line for several checkouts: a failed fetch in any of them, else one running, else the least
283// recently synced (never synced counts as the least). One checkout's is its own.
284/** @param {readonly Sync[]} syncs @returns {Sync} */
285export const syncSummary = syncs => {
286 const repos = syncs.filter(one => one.isRepo)
287 const failed = repos.find(one => one.failedAt > one.fetchedAt && !one.isFetching)
288 return failed ?? repos.find(one => one.isFetching) ?? [...repos].sort((a, b) => a.fetchedAt - b.fetchedAt)[0] ?? syncs[0] ?? NO_SYNC
289}
290
291/** @param {number} ms */
292const agoText = ms => {
293 const minutes = Math.floor(Math.max(0, ms) / 60000)
294 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
295}
296
297// "synced 4 min ago ↻": how fresh the list is. A failed fetch says so, naming the git lock it ran
298// into, and keeps the last good time.
299/** @param {Sync} sync @param {number} now */
300export const syncText = (sync, now) => {
301 if (!sync.isRepo) return ''
302 if (sync.isFetching) return 'syncing…'
303 const synced = sync.fetchedAt ? `synced ${agoText(now - sync.fetchedAt)}` : ''
304 if (sync.failedAt > sync.fetchedAt) return `${sync.lock ? `sync waits on ${sync.lock}` : 'sync failed'}${synced ? ` · ${synced}` : ''} ↻`
305 if (synced) return `${synced} ↻`
306 return sync.hasMain ? 'not synced yet ↻' : `no ${refOf(sync.base ?? 'main')} ↻`
307}
308
309// What git said when the last fetch failed, for the line under "sync failed"; a named lock says enough on its own line.
310/** @param {Sync} sync */
311export const syncError = sync => (sync.failedAt > sync.fetchedAt && !sync.isFetching && !sync.lock ? sync.error : '')
312hooks/ather/workspace.mjs 189 lines1// @ts-check
2// Ather Automata: the checkouts a session works with. The checkout holding the
3// session folder, then the worktrees of its clone. A session opened in a folder
4// that is in no checkout works with none. Also the ids a repository and a checkout
5// are kept under in the store (repoId, checkoutId).
6// Pure apart from the files it is given.
7
8/**
9 * @typedef {{
10 * read: (path: string) => Promise<string | null>, exists: (path: string) => Promise<boolean>,
11 * worktrees?: (root: string) => Promise<string | null>, real?: (folder: string) => Promise<string>
12 * }} Files `worktrees`: what `git worktree list --porcelain` prints in the checkout at `root` ('' when git refused), or
13 * null when git could not say (the run was aborted, ran out of time, could not start).
14 * `real`: the folder a path really lands in, behind any symbolic link.
15 */
16
17// A store key holds 256 characters at most (the engine's limit), and a folder is one part of it, beside a
18// session's id, a repository and an intent's name. A longer folder is kept as a digest of the whole and
19// its last characters: still one id per folder, and still told apart by eye.
20const FOLDER_MAX = 120
21// A 53-bit digest of a text (cyrb53), in base 36: the same on every machine and in every engine.
22/** @param {string} text */
23const digest = text => {
24 let high = 0xdeadbeef
25 let low = 0x41c6ce57
26 for (let at = 0; at < text.length; at += 1) {
27 const code = text.charCodeAt(at)
28 high = Math.imul(high ^ code, 2654435761)
29 low = Math.imul(low ^ code, 1597334677)
30 }
31 high = Math.imul(high ^ (high >>> 16), 2246822507) ^ Math.imul(low ^ (low >>> 13), 3266489909)
32 low = Math.imul(low ^ (low >>> 16), 2246822507) ^ Math.imul(high ^ (high >>> 13), 3266489909)
33 return (4294967296 * (2097151 & low) + (high >>> 0)).toString(36)
34}
35
36// A folder as an id holds it: normalised and lowercased, so it reads the same from every session.
37/** @param {string} root */
38const folderId = root => {
39 const folder = root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
40 if (folder.length <= FOLDER_MAX) return folder
41 const mark = `~${digest(folder)}~`
42 return mark + folder.slice(mark.length - FOLDER_MAX)
43}
44
45// A repository's id from its origin URL: owner/repo, lowercased, whatever the protocol, so every
46// clone and worktree of one repository shares what is kept for it. Without an origin, the checkout's folder.
47// git@github.com:AskTinNguyen/han-viet.git, https://github.com/AskTinNguyen/han-viet → asktinnguyen/han-viet
48// D:\Mirrors\Sipher\S2.git (a local origin, as Windows writes it) → sipher/s2
49/** @param {string} url @param {string} root */
50export const repoId = (url, root) => {
51 const path = url
52 .trim()
53 .replace(/\\/g, '/')
54 .replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i, '')
55 .replace(/^[^@/\s]+@[^:/\s]+:/, '')
56 .replace(/\.git\/?$/i, '')
57 .replace(/\/+$/, '')
58 const parts = path.split('/').filter(Boolean)
59 if (parts.length >= 2) return parts.slice(-2).join('/').toLowerCase()
60 return `path:${folderId(root)}`
61}
62
63// A checkout's id: its repository's and its folder, so two clones or worktrees of one repository keep their
64// own proof, changes and "Continue …". Without an origin the repository's id is the folder already; without
65// a repository (an Io without `repo`) there is none.
66// sipher/s2 in D:\S2 → sipher/s2@d:/s2
67/** @param {string} repo from repoId @param {string} root the checkout's top folder */
68export const checkoutId = (repo, root) => (repo === '' || repo.startsWith('path:') ? repo : `${repo}@${folderId(root)}`)
69
70// How far up a folder is walked to find its checkout, and how many checkouts one session works with,
71// its clone's worktrees included.
72const MAX_DEPTH = 12
73const MAX_WITH_WORKTREES = 24
74
75// A folder as compared and joined: forward slashes, no trailing slash, `.` and `..` folded away
76// so that walking up from `<root>/../web` walks up from `web`, not from `..`.
77/** @param {string} folder */
78export const normalFolder = folder => {
79 const slashed = folder.replace(/\\/g, '/')
80 const lead = /^([A-Za-z]:)?\/?/.exec(slashed)?.[0] ?? ''
81 /** @type {string[]} */
82 const parts = []
83 for (const part of slashed.slice(lead.length).split('/')) {
84 if (part === '' || part === '.') continue
85 if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
86 else if (part !== '..' || lead === '') parts.push(part)
87 }
88 const joined = lead + parts.join('/')
89 return joined.length > 1 ? joined.replace(/\/$/, '') : joined
90}
91
92/** @param {string} folder */
93export const isAbsolute = folder => /^([A-Za-z]:)?[\\/]/.test(folder)
94
95// The git directory of a checkout's top folder: its .git folder, or the gitdir a worktree's .git file names.
96/** @param {Files} files @param {string} folder @returns {Promise<string | null>} */
97export const gitDirOf = async (files, folder) => {
98 if (await files.exists(`${folder}/.git/HEAD`)) return `${folder}/.git`
99 return /gitdir:\s*(.+)/.exec((await files.read(`${folder}/.git`)) ?? '')?.[1]?.trim() || null
100}
101
102// The checkout holding a folder, or null: `cd Plugins/X && git push` pushes the checkout's branch.
103/** @param {Files} files @param {string} folder @returns {Promise<string | null>} */
104export const checkoutOf = async (files, folder) => {
105 for (let at = normalFolder(folder), depth = 0; at !== '' && depth < MAX_DEPTH; depth += 1) {
106 if (await gitDirOf(files, at)) return at
107 const parent = at.replace(/\/[^/]*$/, '')
108 at = parent === at || parent === '' ? '' : parent
109 }
110 return null
111}
112
113// `git worktree list --porcelain`: a block per worktree (`worktree <folder>`, then HEAD, `branch` or `detached`,
114// and `bare`, `locked` or `prunable` when it is). The folders of those that are checkouts, in git's order: not
115// bare, not prunable.
116/** @param {string} text @returns {string[]} */
117export const parseWorktrees = text =>
118 text
119 .replace(/\r\n/g, '\n')
120 .split(/\n{2,}/)
121 .map(block => block.split('\n'))
122 .filter(lines => lines[0]?.startsWith('worktree ') && !lines.some(line => /^(bare|prunable)( |$)/.test(line)))
123 .map(lines => (lines[0] ?? '').slice('worktree '.length))
124
125/**
126 * The session's checkouts, in order and each once: the one holding the session folder (none when it is in
127 * no checkout), then the worktrees git names in its clone (parseWorktrees) that are still checkouts. A
128 * checkout is the same one by the folder its path really lands in, under its first name: git names real
129 * paths, the session folder may be a link.
130 * `left`: the worktrees the limit left out. `isSure` false: git could not say (`worktrees` gave null), so
131 * worktrees may be missing: worth reading again.
132 * @param {Files} files @param {string} sessionFolder
133 * @returns {Promise<{ roots: string[], left: string[], isSure: boolean }>}
134 */
135export const readWorkspace = async (files, sessionFolder) => {
136 const own = await checkoutOf(files, sessionFolder)
137 if (own === null) return { roots: [], left: [], isSure: true }
138 // A folder as two names for it compare: where it really lands, whatever the case.
139 /** @param {string} folder */
140 const landing = async folder => normalFolder(files.real ? await files.real(folder).catch(() => folder) : folder).toLowerCase()
141 // Where each checkout found so far lands.
142 const lands = new Set([await landing(own)])
143 const roots = [own]
144 /** @type {string[]} */
145 const left = []
146 let isSure = true
147 if (files.worktrees) {
148 const text = await files.worktrees(own).catch(() => null)
149 if (text === null) isSure = false
150 for (const listed of parseWorktrees(text ?? '')) {
151 const folder = normalFolder(listed)
152 const at = await landing(folder)
153 if (lands.has(at) || !(await gitDirOf(files, folder))) continue
154 lands.add(at)
155 if (roots.length < MAX_WITH_WORKTREES) roots.push(folder)
156 else left.push(folder)
157 }
158 }
159 return { roots, left, isSure }
160}
161
162/**
163 * Each checkout's short name, in the order given and each different: the first (the session's checkout) by the
164 * last part of its repository's id ("han-viet"), else of its folder; each after it (a worktree of its clone) by
165 * its folder. A name already taken gets `-2`, `-3`, … in order. A name holds only what `<name>#<number>` is read
166 * with (letters, digits, `_`, `.`, `-`): anything else becomes `-`.
167 * `outside`: checkouts named after those, each taking a name none has (its repository's, else its folder's,
168 * else numbered) and renaming none, so the names of `checkouts` do not depend on them.
169 * @param {{ root: string, repo: string }[]} checkouts @param {{ root: string, repo: string }[]} [outside] @returns {string[]}
170 */
171export const checkoutNames = (checkouts, outside = []) => {
172 /** @param {string} text */
173 const lastPart = text => (text.split('/').pop() ?? '').replace(/[^\w.-]/g, '-')
174 /** @param {{ root: string, repo: string }} one */
175 const folderOf = one => lastPart(normalFolder(one.root))
176 /** @param {{ root: string, repo: string }} one */
177 const shortOf = one => (one.repo ? lastPart(one.repo) : folderOf(one))
178 /** @type {Set<string>} */
179 const taken = new Set()
180 /** @param {string} base */
181 const free = base => {
182 let unique = base
183 for (let count = 2; taken.has(unique); count += 1) unique = `${base}-${count}`
184 taken.add(unique)
185 return unique
186 }
187 return [...checkouts.map((one, at) => free(at === 0 ? shortOf(one) : folderOf(one))), ...outside.map(one => free(taken.has(shortOf(one)) ? folderOf(one) : shortOf(one)))]
188}
189hooks/board.mjs 229 lines1// @ts-check
2// Ather Lanes: what the pane shows (D7, design.md), from the lane records and the held slots. Pure: the pane
3// (pane.mjs) only draws what this returns. At rest: For you (each waiting decision under its lane), A look (one
4// line per lane in trouble, its reasons worst first) and the fold (all lanes, the pool, the Editor's holder).
5// Opened, the fold lists every lane one line each and the held slots.
6
7import { ageText } from './ather/worklist.mjs'
8import { plural } from './ather/model.mjs'
9import { KIND_LANE, KIND_LANE_0, isPool, isPoolReady } from './sources.mjs'
10
11/**
12 * @typedef {import('./records.mjs').LaneRecord} LaneRecord
13 * @typedef {import('./records.mjs').Decision} Decision
14 * @typedef {import('./sources.mjs').Slot} Slot
15 * @typedef {{ text: string, short: string }} Reason one trouble: its words in A look, and its short form in the opened fold
16 * @typedef {{ ids: string[], label: string, holder: string, kind: string, until: string, pastMinutes: number, leftMinutes: number, waiting: string[] }} Hold
17 * one hold: a slot, or every slot a perf capture took (`label` "every slot"); `waiting`: the lanes that need a slot of its
18 * kind and hold none (design.md: "needs a slot that another lane holds"; how many slots there are is F-8's to report)
19 * @typedef {{ lane: LaneRecord, reasons: Reason[] }} Row
20 * @typedef {{ forYou: { lane: LaneRecord, decisions: Decision[] }[], look: Row[], rows: Row[],
21 * pool: { ready: number, building: number }, count: number, holds: Hold[], editor: Hold | null, here: LaneRecord | null }} Board
22 * `rows`: every lane but the pool's, lane 0 first, then this session's lane, then by name (D10); `look`: those of them
23 * with a reason, in Status' order; `here`: this session's lane, null for none
24 */
25
26// A lane this far behind `main` or more is worth a look (design.md: one constant in one place).
27export const BEHIND_LIMIT = 25
28
29// The kind of slot an id names ("light-1" is a light slot, whoever took it and why).
30/** @param {string} id */
31const slotKindOf = id => id.replace(/-\d+$/, '')
32
33// "6m", "2h", "3d": a span of minutes, in the steps Ather's ages take (worklist.mjs ageText).
34/** @param {number} minutes */
35export const minutesText = minutes => (minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`)
36
37// The Editor slot, in the words the board uses for a slot's kind.
38/** @param {string} kind */
39export const kindWord = kind => (kind === 'full' ? 'editor' : kind)
40
41// The held slots, a perf capture's as one hold, each with the lanes that wait for it: the one place that rule lives.
42/** @param {readonly Slot[]} slots @param {readonly LaneRecord[]} records @returns {Hold[]} */
43const holdsOf = (slots, records) => {
44 /** @type {Hold[]} */
45 const holds = []
46 for (const slot of [...slots].sort((a, b) => a.id.localeCompare(b.id))) {
47 const perf = slot.kind === 'perf' ? holds.find(one => one.kind === 'perf' && one.holder === slot.holder) : undefined
48 if (perf) perf.ids.push(slot.id)
49 else holds.push({ ids: [slot.id], label: slot.kind === 'perf' ? 'every slot' : slot.id, holder: slot.holder, kind: slot.kind || slotKindOf(slot.id), until: slot.until, pastMinutes: slot.pastMinutes, leftMinutes: slot.leftMinutes, waiting: [] })
50 }
51 for (const hold of holds) {
52 const kinds = new Set(hold.ids.map(slotKindOf))
53 hold.waiting = records.filter(one => one.name !== hold.holder && kinds.has(one.slotNeed) && !one.slotHeld.some(slot => slotKindOf(slot.id) === one.slotNeed)).map(one => one.name)
54 }
55 return holds
56}
57
58// A lane's reasons for A look, worst first (D7, design.md's list and nothing else); none for a pool lane.
59/** @param {LaneRecord} lane @param {readonly Hold[]} holds @returns {Reason[]} */
60const reasonsOf = (lane, holds) => {
61 if (isPool(lane.kind)) return []
62 /** @type {Reason[]} */
63 const reasons = []
64 if (lane.pr && lane.pr.state === 'open' && lane.pr.checks === 'failing') reasons.push({ text: `#${lane.pr.number} checks failing`, short: 'checks failing' })
65 if (lane.behind !== null && lane.behind >= BEHIND_LIMIT) reasons.push({ text: `${lane.behind} behind main`, short: `${lane.behind} behind main` })
66 for (const hold of holds.filter(one => one.holder === lane.name && one.pastMinutes > 0)) reasons.push({ text: `${hold.kind === 'perf' ? 'perf hold on every slot' : hold.label} past its end ${minutesText(hold.pastMinutes)}`, short: 'slot past its end' })
67 const holders = [...new Set(holds.filter(hold => hold.waiting.includes(lane.name)).map(hold => hold.holder))]
68 if (holders.length > 0) reasons.push({ text: `needs ${kindWord(lane.slotNeed)}, held by ${holders.join(', ')}`, short: `waits for ${kindWord(lane.slotNeed)}` })
69 if (lane.kind === KIND_LANE && lane.sidecar === 'unregistered') reasons.push({ text: 'unregistered, so no intent or claims', short: 'unregistered' })
70 return reasons
71}
72
73// The opened fold's order (D10): lane 0, then this session's lane, then the rest.
74/** @param {LaneRecord} lane @param {string} hereName */
75const rankOf = (lane, hereName) => (lane.kind === KIND_LANE_0 ? 0 : hereName !== '' && lane.name === hereName ? 1 : 2)
76
77/** @param {readonly LaneRecord[]} records @param {readonly Slot[]} slots @param {string} [hereName] this session's lane (records.mjs laneOf), '' for none @returns {Board} */
78export const boardOf = (records, slots, hereName = '') => {
79 const holds = holdsOf(slots, records)
80 const oldest = (/** @type {Decision[]} */ list) => Math.min(...list.map(one => one.since || Number.MAX_SAFE_INTEGER))
81 const forYou = records
82 .filter(lane => lane.decisions.length > 0)
83 .map(lane => ({ lane, decisions: lane.decisions }))
84 .sort((a, b) => oldest(a.decisions) - oldest(b.decisions) || a.lane.name.localeCompare(b.lane.name))
85 const lanes = records.filter(lane => !isPool(lane.kind)).map(lane => ({ lane, reasons: reasonsOf(lane, holds) }))
86 const pools = records.filter(lane => isPool(lane.kind))
87 return {
88 forYou,
89 look: lanes.filter(one => one.reasons.length > 0),
90 rows: [...lanes].sort((a, b) => rankOf(a.lane, hereName) - rankOf(b.lane, hereName) || a.lane.name.localeCompare(b.lane.name)),
91 pool: { ready: pools.filter(lane => isPoolReady(lane.kind)).length, building: pools.filter(lane => !isPoolReady(lane.kind)).length },
92 count: lanes.length,
93 holds,
94 editor: holds.find(hold => hold.ids.some(id => slotKindOf(id) === 'full')) ?? null,
95 here: hereName === '' ? null : (records.find(lane => lane.name === hereName) ?? null),
96 }
97}
98
99// ---------------------------------------------------------------- words
100
101// The quiet mark on this session's lane (D10).
102export const HERE = 'this session'
103
104/** @param {Board} board @param {LaneRecord} lane */
105export const isHere = (board, lane) => board.here?.name === lane.name
106
107// The section a lane is first listed in (For you, A look, else the opened fold), for lanes_open (D9); null for a lane
108// the board does not list (an unknown name, a pool lane).
109/** @param {Board} board @param {string} name @returns {'you' | 'look' | 'fold' | null} */
110export const sectionOf = (board, name) => {
111 const listed = (/** @type {{ lane: LaneRecord }[]} */ rows) => rows.some(one => one.lane.name === name)
112 return listed(board.forYou) ? 'you' : listed(board.look) ? 'look' : listed(board.rows) ? 'fold' : null
113}
114
115/** @param {Board} board */
116export const decisionCount = board => board.forYou.reduce((sum, one) => sum + one.decisions.length, 0)
117
118// The status line (D8): this session's lane, then For you's and A look's counts, each only when not zero.
119/** @param {Board} board */
120export const lineText = board => {
121 const decisions = decisionCount(board)
122 return [whereText(board.here), decisions > 0 ? `◆ ${decisions} for you` : '', board.look.length > 0 ? `${board.look.length} to look at` : ''].filter(Boolean).join(' · ')
123}
124
125// Where this session is, in the status line's and the summary's words.
126/** @param {LaneRecord | null} lane */
127const whereText = lane => (lane === null ? 'not in a lane' : lane.kind === KIND_LANE_0 ? 'lane 0' : `lane ${lane.name}`)
128
129/** @param {{ ready: number, building: number }} pool */
130export const poolText = pool => `${pool.ready} ready, ${pool.building} building`
131
132// "34m left", "past its end 6m", '' when the end is unknown.
133/** @param {Hold} hold */
134export const timeText = hold => (hold.pastMinutes > 0 ? `past its end ${minutesText(hold.pastMinutes)}` : hold.leftMinutes > 0 ? `${minutesText(hold.leftMinutes)} left` : hold.until ? `until ${hold.until}` : '')
135
136// The fold's line (design.md): how many lanes, the pool, and the Editor slot's holder with its time.
137/** @param {Board} board */
138export const foldText = board => {
139 const editor = board.editor ? `Editor: ${board.editor.holder}${timeText(board.editor) ? `, ${timeText(board.editor).replace(/ left$/, '')}` : ''}` : 'Editor free'
140 return `all ${plural(board.count, 'lane')} · pool ${poolText(board.pool)} · ${editor}`
141}
142
143// One held slot in the opened fold: holder, kind, time, and who waits for it.
144/** @param {Hold} hold */
145export const holdText = hold => [hold.holder, kindWord(hold.kind), timeText(hold), hold.waiting.length > 0 ? `then ${hold.waiting.join(', ')}` : ''].filter(Boolean).join(' · ')
146
147// A lane's one phrase in the opened fold: its decisions, else its first reason, else nothing (F-10: in ink).
148/** @param {Row} row */
149export const phraseOf = ({ lane, reasons }) => (lane.decisions.length > 0 ? plural(lane.decisions.length, 'decision') : (reasons[0]?.short ?? ''))
150
151// The glyph before a lane in the opened fold: lane 0, a registered lane, any other worktree.
152/** @param {LaneRecord} lane */
153export const glyphOf = lane => (lane.kind === KIND_LANE_0 ? '◆' : lane.sidecar === 'registered' ? '●' : '○')
154
155// ---------------------------------------------------------------- an opened lane (D4: its order)
156
157/** @typedef {{ label: string, text: string, link?: string }} Detail */
158
159/** @param {import('./sources.mjs').Pr | null | undefined} pr */
160const prText = pr => (pr === undefined ? 'not read yet' : pr === null ? 'none yet' : `#${pr.number} ${pr.isDraft ? 'draft' : pr.state}${pr.checks === 'none' ? '' : ` · checks ${pr.checks}`}`)
161
162/** @param {LaneRecord} lane */
163const mainText = lane => {
164 if (lane.isDetached) return 'detached, no count'
165 if (lane.behind === null || lane.ahead === null) return 'not read yet'
166 return `${lane.behind === 0 ? 'up to date' : `${lane.behind} behind`}${lane.ahead > 0 ? ` · ${lane.ahead} ahead` : ''}`
167}
168
169/** @param {LaneRecord} lane @param {Hold[]} holds */
170const slotText = (lane, holds) => {
171 const mine = holds.filter(hold => hold.holder === lane.name)
172 const held = mine.map(hold => `${hold.kind === 'perf' ? 'perf hold on every slot' : `holds ${hold.label}`}${timeText(hold) ? ` · ${timeText(hold)}` : ''}`)
173 const need = lane.slotNeed ? `needs ${kindWord(lane.slotNeed)}` : 'needs none'
174 const blocking = mine.length === 0 && lane.slotNeed ? holds.filter(hold => hold.waiting.includes(lane.name)).map(hold => `${hold.label} held by ${hold.holder}${timeText(hold) ? `, ${timeText(hold)}` : ''}`) : []
175 return [...held, need, ...blocking].join(' · ')
176}
177
178/** @param {LaneRecord} lane @param {number} now */
179const movedText = (lane, now) => {
180 const parts = [...lane.beats.map(beat => ({ label: beat.source, at: beat.at, state: beat.state })), ...(lane.lastCommit ? [{ label: 'last commit', at: lane.lastCommit, state: '' }] : [])].sort((a, b) => b.at - a.at)
181 const words = parts.map(one => `${one.label} ${ageText(one.at, now) || '0m'} ago${one.state ? ` (${one.state})` : ''}`)
182 return [...words, ...(lane.beats.length === 0 ? ['no heartbeat here'] : [])].join(' · ')
183}
184
185// The register line an unregistered worktree's owner copies (design.md); the scripts write the sidecar, never the mod.
186/** @param {LaneRecord} lane */
187export const registerLine = lane => `lanes.ps1 -Action Register -Name ${lane.name} -Intent <slug> -Tier <T0|T1|T2>`
188
189// The lane's decisions, full titles, and where to answer them; none for a lane with none.
190/** @param {LaneRecord} lane @returns {Detail[]} */
191const decisionDetails = lane => (lane.decisions.length > 0 ? [...lane.decisions.map(one => ({ label: one.id, text: one.full || one.title })), { label: '', text: `answer them in the session that tracks ${lane.intent}` }] : [])
192
193// The lane's own lines: PR, main, slot, moved, lane (this session's marked, D10).
194/** @param {LaneRecord} lane @param {Board} board @param {number} now @returns {Detail[]} */
195export const laneDetails = (lane, board, now) => [
196 { label: 'PR', text: prText(lane.pr), ...(lane.pr ? { link: lane.pr.url } : {}) },
197 { label: 'main', text: mainText(lane) },
198 { label: 'slot', text: slotText(lane, board.holds) },
199 { label: 'moved', text: movedText(lane, now) },
200 { label: 'lane', text: [isHere(board, lane) ? HERE : '', lane.branch, lane.intentTitle ? `${lane.intent} (${lane.intentTitle})` : lane.intent, lane.tier, lane.path].filter(Boolean).join(' · ') },
201]
202
203// The opened lane's lines, in design.md's order: decisions, PR, main, slot, moved, lane.
204/** @param {LaneRecord} lane @param {Board} board @param {number} now @returns {Detail[]} */
205export const detailsOf = (lane, board, now) => [...decisionDetails(lane), ...laneDetails(lane, board, now)]
206
207// ---------------------------------------------------------------- the summary a tool gives (D9)
208
209// lanes_summary's text: this session's lane with its details (the opened lane's lines), then every For you and A
210// look item, or "Nothing needs you."; `readAt`: when git and gh were last read (the PR and behind-main reasons), null
211// for never.
212/** @param {Board} board @param {{ now: number, readAt: number | null, clock: (ms: number) => string }} at */
213export const summaryText = (board, { now, readAt, clock }) => {
214 const { here } = board
215 const lines = [`This session: ${whereText(here)}${here?.sidecar === 'unregistered' ? `, unregistered (register it with: ${registerLine(here)})` : ''}.`]
216 if (here) lines.push(...laneDetails(here, board, now).map(one => ` ${one.label}: ${one.text}`))
217 if (board.forYou.length > 0) {
218 lines.push('', `For you (${plural(decisionCount(board), 'decision')}):`)
219 for (const { lane, decisions } of board.forYou) for (const call of decisions) lines.push(`- ${lane.name} ${call.id}: ${call.full || call.title}`)
220 }
221 if (board.look.length > 0) {
222 lines.push('', `A look (${plural(board.look.length, 'lane')}):`)
223 for (const { lane, reasons } of board.look) lines.push(`- ${lane.name}: ${reasons.map(one => one.text).join(' · ')}`)
224 }
225 if (board.forYou.length === 0 && board.look.length === 0) lines.push('', 'Nothing needs you.')
226 lines.push('', `${foldText(board)}.`, readAt === null ? 'PR checks and behind-main counts are not read yet: the pane reads them while it is open.' : `PR checks and behind-main counts as of ${clock(readAt)}.`, 'The pane: /lanes, or the lanes_open tool.')
227 return lines.join('\n')
228}
229hooks/lanes-store.mjs 63 lines1// @ts-check
2// Ather Lanes' store: the one home of what the mod keeps. The store is the plugin's, one JSON file for every session
3// on the machine, so each kind of key says who deletes it. Two kinds, both caches of one lane 0 (the checkout the
4// lanes hang off) that any session on it rebuilds, each value naming that lane 0's folder:
5// - `full:<lane 0>`: the last full read (Status, refs, PRs), for the closed pane, the status line and lanes_summary;
6// - `prs:<lane 0>`: gh's answer for the lane branches, reused for 5 minutes.
7// Who deletes: every write deletes the keys that name a lane 0 whose folder is gone. No session can be writing those
8// (a session's checkout is its lane 0 or a worktree of it), so a delete never races another session's write; any
9// doubt keeps the key (isStale). Every write is a whole value and nothing is read to be changed and
10// written back, so the writes need no queue. Takes the store as closures (Io), never `$`.
11
12import { fullData, fullOf } from './records.mjs'
13import { folderKey } from './sources.mjs'
14
15/**
16 * @typedef {{ get: (key: string) => Promise<unknown>, set: (key: string, value: unknown) => Promise<void>, keys: () => Promise<readonly string[]>, remove: (key: string) => Promise<void>, exists: (path: string) => Promise<boolean> }} StoreIo
17 * @typedef {import('./records.mjs').Full} Full
18 * @typedef {{ at: number, branches: string[], stdout: string }} KeptPrs gh's answer and the branches it was asked for
19 */
20
21const FULL = 'full:'
22const PRS = 'prs:'
23const KINDS = [FULL, PRS]
24
25/** @param {StoreIo} io @param {string} key */
26const get = (io, key) => io.get(key).catch(() => undefined)
27
28// A key of either kind goes only when it names its lane 0 and that folder is surely gone. One that names none (a
29// value from before 0.2.1, which a session not yet reloaded may still be writing), one that cannot be read, and one
30// whose folder cannot be looked at all stay. A lane 0 cloned again at the same path in the moment between the look
31// and the delete loses one cache, which its next full read rebuilds.
32/** @param {StoreIo} io @param {string} key */
33const isStale = async (io, key) => {
34 const main = /** @type {{ main?: unknown } | undefined} */ (await get(io, key))?.main
35 return typeof main === 'string' && (await io.exists(main).catch(() => true)) === false
36}
37
38/** @param {StoreIo} io @param {string} key @param {{ main: string }} value */
39const keep = async (io, key, value) => {
40 try {
41 await io.set(key, value)
42 for (const other of await io.keys()) if (other !== key && KINDS.some(kind => other.startsWith(kind)) && (await isStale(io, other))) await io.remove(other)
43 } catch {
44 // A store that fails keeps nothing; the next full read rebuilds it.
45 }
46}
47
48/** @param {StoreIo} io @param {string} main lane 0's folder @returns {Promise<Full | null>} */
49export const readFull = async (io, main) => fullOf(await get(io, FULL + folderKey(main)))
50
51/** @param {StoreIo} io @param {string} main @param {Full} full */
52export const keepFull = (io, main, full) => keep(io, FULL + folderKey(main), { main, ...fullData(full) })
53
54/** @param {StoreIo} io @param {string} main @returns {Promise<KeptPrs | null>} */
55export const readKeptPrs = async (io, main) => {
56 const data = /** @type {any} */ (await get(io, PRS + folderKey(main)))
57 const isKept = typeof data?.at === 'number' && typeof data.stdout === 'string' && Array.isArray(data.branches) && data.branches.every((/** @type {unknown} */ one) => typeof one === 'string')
58 return isKept ? { at: data.at, branches: data.branches, stdout: data.stdout } : null
59}
60
61/** @param {StoreIo} io @param {string} main @param {KeptPrs} kept */
62export const keepPrs = (io, main, kept) => keep(io, PRS + folderKey(main), { main, ...kept })
63hooks/pane.mjs 220 lines1// @ts-check
2// Ather Lanes: the pane as design.md draws it (D7). `paneLines` is pure: it lays the board (board.mjs) out as
3// lines of segments, each with its words, colour and press, so every line's width and colour can be checked;
4// `drawPane` turns those lines into the surface's elements. Lime only for For you's label and count, amber only
5// on A look's reasons; everything else ink or quiet. No columns, no legend; one hint line on the terminal.
6
7import { AMBER, INK, LIME, QUIET, cells, fit, padCells } from './ather/rows.mjs'
8import { plural } from './ather/model.mjs'
9import { ageText } from './ather/worklist.mjs'
10import { HERE, decisionCount, detailsOf, foldText, glyphOf, holdText, isHere, minutesText, phraseOf, poolText, registerLine } from './board.mjs'
11
12/**
13 * @typedef {import('./board.mjs').Board} Board
14 * @typedef {import('./records.mjs').LaneRecord} LaneRecord
15 * @typedef {{ name: string, from: 'you' | 'look' | 'fold' }} Opened a lane opened in place, and the section it was opened from
16 * @typedef {{ kind: 'refresh' } | { kind: 'fold' } | { kind: 'lane', lane: Opened }} Press what pressing a segment does
17 * @typedef {{ key: string, text: string, color?: string, bold?: boolean, press?: Press, link?: string, hotkey?: string }} Segment
18 * @typedef {{ key: string, segments: Segment[], marginTop?: number }} Line
19 * @typedef {{ width: number, isDesktop: boolean, now: number, clock: (ms: number) => string, opened: Opened | null, isFoldOpen: boolean,
20 * refreshedAt: number | null, errors: readonly string[] }} View
21 */
22
23/** @param {string} name @param {Opened['from']} from @returns {Press} */
24const laneAt = (name, from) => ({ kind: 'lane', lane: { name, from } })
25
26/** @param {View} view @param {string} name @param {Opened['from']} from */
27const isOpenAt = (view, name, from) => view.opened?.name === name && view.opened.from === from
28
29// A lane's name keeps this many cells in a terminal line (design.md's mockups).
30const NAME_CELLS = 16
31const INDENT = ' '
32const DETAIL_INDENT = ' '
33const LABEL_CELLS = 6
34
35/** @param {string} key @param {Segment[]} segments @param {number} [marginTop] @returns {Line} */
36const line = (key, segments, marginTop) => ({ key, segments: segments.filter(one => one.text !== ''), ...(marginTop ? { marginTop } : {}) })
37
38// The cells a segment takes as the terminal draws it: a button's hotkey adds "r: " before its words.
39/** @param {Segment} one */
40const segmentCells = one => cells(one.text) + (one.hotkey ? cells(one.hotkey) + 2 : 0)
41
42// A line with words on the left and on the right, the gap between filled so it is `width` wide.
43/** @param {string} key @param {Segment[]} left @param {Segment} right @param {number} width @param {number} [marginTop] */
44const spread = (key, left, right, width, marginTop) => {
45 const used = left.reduce((sum, one) => sum + segmentCells(one), 0)
46 const room = width - used - 1 - (right.hotkey ? cells(right.hotkey) + 2 : 0)
47 const tail = { ...right, text: room >= 4 ? fit(right.text, room) : '' }
48 return line(key, [...left, { key: `${key}-gap`, text: ' '.repeat(Math.max(1, width - used - segmentCells(tail))) }, tail], marginTop)
49}
50
51// Words wrapped to `width` cells, each piece a line of its own; a word longer than the width is cut.
52/** @param {string} text @param {number} width */
53const wrap = (text, width) => {
54 /** @type {string[]} */
55 const out = []
56 let current = ''
57 for (const word of text.split(/\s+/).filter(Boolean)) {
58 const next = current ? `${current} ${word}` : word
59 if (cells(next) <= width) current = next
60 else {
61 if (current) out.push(current)
62 current = fit(word, width)
63 }
64 }
65 return current ? [...out, current] : out
66}
67
68/** @param {string} text */
69const spaced = text => text.toUpperCase().split('').join(' ')
70
71// The quiet "this session" a desktop item, or an unregistered lane's opened lines, carry after its words (D10).
72/** @param {string} key @param {string} [lead] @returns {Segment} */
73const hereMark = (key, lead = ' ') => ({ key: `${key}-here`, text: `${lead}${HERE}`, color: QUIET })
74
75// An item's line, with "this session" quiet at its right end when it is this session's lane (D10): the words are cut
76// to leave it room.
77/** @param {string} key @param {Segment[]} left @param {boolean} here @param {number} width */
78const marked = (key, left, here, width) => {
79 if (!here) return line(key, left)
80 const room = width - cells(HERE) - 2
81 let used = 0
82 const cut = left.map(one => {
83 const text = fit(one.text, Math.max(0, room - used))
84 used += cells(text)
85 return { ...one, text }
86 })
87 return spread(key, cut, { key: `${key}-here`, text: HERE, color: QUIET }, width)
88}
89
90// The opened lane's lines (D4, design.md's order), under the item it was opened from.
91/** @param {LaneRecord} lane @param {Board} board @param {View} view @returns {Line[]} */
92const openedLines = (lane, board, view) => {
93 const indent = view.isDesktop ? '' : DETAIL_INDENT
94 const room = Math.max(20, view.width - cells(indent) - LABEL_CELLS)
95 const here = isHere(board, lane) ? [line(`open-${lane.name}-here`, [hereMark(`open-${lane.name}`, indent)])] : []
96 if (lane.sidecar === 'unregistered') {
97 const command = registerLine(lane)
98 // Too wide for the pane: broken where PowerShell's line continuation (a backtick) keeps it one command to copy.
99 const commandLines = view.isDesktop || cells(indent) + cells(command) <= view.width ? [command] : [command.replace(/ -Intent .*$/, ' `'), ` ${command.replace(/^.* (-Intent .*)$/, '$1')}`]
100 return [
101 ...here,
102 ...wrap('No sidecar, so the board knows no intent, tier or claims for this worktree. Register it with:', view.width - cells(indent)).map((text, index) => line(`open-${lane.name}-why-${index}`, [{ key: `open-${lane.name}-why-${index}-text`, text: `${indent}${text}`, color: QUIET }])),
103 ...commandLines.map((text, index) => line(`register-${lane.name}-${index}`, [{ key: `register-${lane.name}-${index}-text`, text: `${indent}${fit(text, view.width - cells(indent))}` }])),
104 ]
105 }
106 return detailsOf(lane, board, view.now).flatMap((detail, index) => {
107 const key = `detail-${lane.name}-${index}`
108 const pieces = view.isDesktop ? [detail.text] : wrap(detail.text, room)
109 const link = detail.link ? [{ key: `${key}-link`, text: ' · open on GitHub ↗', link: detail.link, color: QUIET }] : []
110 return pieces.map((text, piece) =>
111 line(`${key}-${piece}`, [
112 { key: `${key}-${piece}-label`, text: `${indent}${padCells(piece === 0 ? detail.label : '', LABEL_CELLS)}`, color: QUIET },
113 { key: `${key}-${piece}-text`, text },
114 ...(piece === pieces.length - 1 && (view.isDesktop || cells(indent) + LABEL_CELLS + cells(text) + cells(link[0]?.text ?? '') <= view.width) ? link : []),
115 ]),
116 )
117 })
118}
119
120// The mark before an item: ▾ while its lane is open from here.
121/** @param {View} view @param {string} name @param {Opened['from']} from */
122const markOf = (view, name, from) => (isOpenAt(view, name, from) ? '▾ ' : INDENT)
123
124/** @param {Board} board @param {View} view @returns {Line[]} */
125export const paneLines = (board, view) => {
126 const { width, isDesktop } = view
127 /** @type {Line[]} */
128 const out = []
129 const since = view.refreshedAt === null ? null : view.now - view.refreshedAt
130 const fresh = [since === null ? 'not read yet' : '', board.rows[0]?.lane.mainMovedAt ? `main as of ${view.clock(board.rows[0].lane.mainMovedAt)}` : '', since === null ? '' : `↻ ${since < 60000 ? `${Math.max(0, Math.round(since / 1000))}s` : minutesText(Math.floor(since / 60000))}`].filter(Boolean).join(' · ')
131 out.push(spread('header', [{ key: 'header-label', text: isDesktop ? 'LANES' : spaced('lanes'), color: INK, bold: true }], { key: 'refresh', text: isDesktop ? `${fresh} · ↻ Refresh` : fresh || '↻', color: QUIET, press: { kind: 'refresh' }, hotkey: isDesktop ? undefined : 'r' }, width))
132 for (const [index, error] of view.errors.entries()) out.push(line(`error-${index}`, [{ key: `error-${index}-text`, text: fit(error, width * 2), color: QUIET }]))
133
134 if (board.forYou.length === 0 && board.look.length === 0) out.push(line('nothing', [{ key: 'nothing-text', text: 'Nothing needs you.', color: INK }], 1))
135
136 if (board.forYou.length > 0) {
137 const count = plural(decisionCount(board), 'decision')
138 out.push(spread('you', [{ key: 'you-label', text: isDesktop ? 'FOR YOU' : spaced('for you'), color: LIME, bold: true }], { key: 'you-count', text: count, color: LIME }, width, 1))
139 for (const { lane, decisions } of board.forYou) {
140 if (isDesktop) out.push(line(`you-${lane.name}`, [{ key: `you-${lane.name}-name`, text: lane.name, bold: true, press: laneAt(lane.name, 'you') }, ...(isHere(board, lane) ? [hereMark(`you-${lane.name}`)] : [])]))
141 for (const [index, call] of decisions.entries()) {
142 const lead = isDesktop ? '' : `${index === decisions.length - 1 ? markOf(view, lane.name, 'you') : INDENT}${padCells(index === 0 ? fit(lane.name, NAME_CELLS - 2) : '', NAME_CELLS)}`
143 out.push(marked(`you-${lane.name}-${call.id}`, [{ key: `you-${lane.name}-${call.id}-press`, text: fit(`${lead}${padCells(call.id, 5)}${call.title}`, width), press: laneAt(lane.name, 'you') }], !isDesktop && index === 0 && isHere(board, lane), width))
144 }
145 if (isOpenAt(view, lane.name, 'you')) out.push(...openedLines(lane, board, view))
146 }
147 }
148
149 if (board.look.length > 0) {
150 out.push(spread('look', [{ key: 'look-label', text: isDesktop ? 'A LOOK' : spaced('a look'), color: INK, bold: true }], { key: 'look-count', text: String(board.look.length), color: INK }, width, 1))
151 for (const { lane, reasons } of board.look) {
152 const lead = isDesktop ? lane.name : `${markOf(view, lane.name, 'look')}${padCells(fit(lane.name, NAME_CELLS - 2), NAME_CELLS)}`
153 const words = reasons.map(one => one.text).join(' · ')
154 const why = { key: `look-${lane.name}-why`, text: isDesktop ? ` ${words}` : fit(words, width - cells(lead)), color: AMBER }
155 const here = isHere(board, lane)
156 out.push(isDesktop ? line(`look-${lane.name}`, [{ key: `look-${lane.name}-press`, text: lead, press: laneAt(lane.name, 'look'), bold: true }, ...(here ? [hereMark(`look-${lane.name}`)] : []), why]) : marked(`look-${lane.name}`, [{ key: `look-${lane.name}-press`, text: lead, press: laneAt(lane.name, 'look') }, why], here, width))
157 if (isOpenAt(view, lane.name, 'look')) out.push(...openedLines(lane, board, view))
158 }
159 }
160
161 out.push(...foldLines(board, view))
162 if (!isDesktop) out.push(spread('hint', [], { key: 'hint-text', text: 'enter opens a lane or the fold · r ↻', color: QUIET }, width))
163 return out
164}
165
166/** @param {Board} board @param {View} view @returns {Line[]} */
167const foldLines = (board, view) => {
168 const { width, isDesktop } = view
169 if (!view.isFoldOpen) {
170 if (!isDesktop) return [line('fold', [{ key: 'fold-press', text: fit(`▸ ${foldText(board)}`, width), color: QUIET, press: { kind: 'fold' } }], 1)]
171 const editor = board.editor ? `Editor slot: ${holdText(board.editor)}` : 'Editor slot: free'
172 return [line('fold', [{ key: 'fold-press', text: `All ${plural(board.count, 'lane')} · pool ${poolText(board.pool)}`, press: { kind: 'fold' } }], 1), line('fold-editor', [{ key: 'fold-editor-text', text: editor, color: QUIET }])]
173 }
174 /** @type {Line[]} */
175 const out = [line('fold', [{ key: 'fold-press', text: fit(`▾ all ${plural(board.count, 'lane')} · pool ${poolText(board.pool)}`, width), color: QUIET, press: { kind: 'fold' } }], 1)]
176 for (const row of board.rows) {
177 const { lane } = row
178 const isOpen = isOpenAt(view, lane.name, 'fold')
179 const phrase = phraseOf(row)
180 const age = lane.lastMoved ? ageText(lane.lastMoved.at, view.now) || '0m' : ''
181 const right = [isHere(board, lane) ? HERE : '', age].filter(Boolean).join(' · ')
182 const lead = `${isOpen ? '▾ ' : INDENT}${glyphOf(lane)} ${padCells(fit(lane.name, NAME_CELLS - 2), NAME_CELLS)}`
183 out.push(spread(`row-${lane.name}`, [{ key: `row-${lane.name}-press`, text: isDesktop ? `${glyphOf(lane)} ${lane.name}` : lead, press: laneAt(lane.name, 'fold') }, { key: `row-${lane.name}-phrase`, text: isDesktop ? ` ${phrase}` : fit(phrase, Math.max(0, width - cells(lead) - Math.max(6, cells(right) + 2))) }], { key: `row-${lane.name}-age`, text: right, color: QUIET }, width))
184 if (isOpen) out.push(...openedLines(lane, board, view))
185 }
186 if (board.pool.ready + board.pool.building > 0) out.push(line('row-pool', [{ key: 'row-pool-text', text: fit(`${INDENT} ${padCells('pool', NAME_CELLS)}${board.pool.ready} ready · ${board.pool.building} building`, width), color: QUIET }]))
187 if (board.holds.length === 0) out.push(line('holds-none', [{ key: 'holds-none-text', text: `${INDENT}all slots free`, color: QUIET }]))
188 for (const hold of board.holds) out.push(line(`hold-${hold.ids[0]}`, [{ key: `hold-${hold.ids[0]}-text`, text: fit(`${INDENT}${padCells(hold.label, 9)}${holdText(hold)}`, width), color: QUIET }]))
189 return out
190}
191
192// ---------------------------------------------------------------- drawing
193
194/** @param {any} el @param {Line[]} lines @param {{ isDesktop: boolean, onPress: (press: Press) => void }} draw */
195export const drawPane = (el, lines, { isDesktop, onPress }) =>
196 el.Box({
197 key: 'lanes',
198 flexDirection: 'column',
199 width: '100%',
200 children: lines.map(one =>
201 el.Box({
202 key: one.key,
203 flexDirection: 'row',
204 width: '100%',
205 ...(one.marginTop ? { marginTop: one.marginTop } : {}),
206 ...(isDesktop ? { flexWrap: 'wrap' } : {}),
207 children: one.segments.map(segment => {
208 const press = segment.press
209 if (segment.link) return el.Link({ key: segment.key, href: segment.link, label: segment.text })
210 if (press) return el.Button({ key: segment.key, label: segment.text, plain: true, hotkey: segment.hotkey, dimColor: segment.color === QUIET ? true : undefined, onPress: () => onPress(press) })
211 return el.Text({ key: segment.key, color: segment.color, bold: segment.bold ? true : undefined, wrap: isDesktop ? 'wrap' : undefined, children: segment.text })
212 }),
213 }),
214 ),
215 })
216
217// A line's width in cells, as the terminal draws it.
218/** @param {Line} one */
219export const lineCells = one => one.segments.reduce((sum, segment) => sum + segmentCells(segment), 0)
220hooks/records.mjs 184 lines1// @ts-check
2// Ather Lanes: the lane records (A1). One pure function turns what the sources gave (sources.mjs) into one record
3// per worktree Status lists, in Status' order, its `kind` and `sidecar` as Status gave them. Nothing here decides
4// what a lane is: the S2 scripts do (D2.1); a field no source gave is unknown (null), never guessed.
5
6import { directorCalls, parseIntent } from './ather/model.mjs'
7import { KIND_LANE_0, folderKey, isPool, kindOfEntry, readLaneBeat, readSessionBeat, statusOf } from './sources.mjs'
8
9/**
10 * @typedef {import('./sources.mjs').Status} Status
11 * @typedef {import('./sources.mjs').Slot} Slot
12 * @typedef {import('./sources.mjs').Beat} Beat
13 * @typedef {import('./sources.mjs').Ref} Ref
14 * @typedef {import('./sources.mjs').Pr} Pr
15 * @typedef {import('./sources.mjs').StatusLane} StatusLane
16 * @typedef {import('./sources.mjs').Sidecar} Sidecar
17 * @typedef {import('./ather/model.mjs').Intent} Intent
18 * @typedef {{ id: string, title: string, full: string, since: number }} Decision `since`: the date in its heading (ms), 0 for none
19 * @typedef {{
20 * name: string, path: string, kind: string, sidecar: string, branch: string, head: string, isDetached: boolean, isMissing: boolean,
21 * intent: string, intentTitle: string, tier: string, slotNeed: string, slotHeld: Slot[],
22 * ahead: number | null, behind: number | null, lastCommit: number, mainMovedAt: number,
23 * beats: Beat[], lastMoved: { source: string, at: number } | null,
24 * pr: Pr | null | undefined, decisions: Decision[],
25 * }} LaneRecord
26 * `pr`: undefined while gh has not been read, null for no PR; `beats`: newest first; `lastMoved`: the newest of the
27 * last commit and the beats, with its source.
28 * @typedef {{
29 * status: Status | null, slots: readonly Slot[], laneBeats: ReadonlyMap<string, Beat>, sessionBeats: ReadonlyMap<string, Beat>,
30 * refs: ReadonlyMap<string, Ref> | null, prs: ReadonlyMap<string, Pr> | null, intents: ReadonlyMap<string, Intent | null>, mainMovedAt: number,
31 * }} Sources
32 * `sessionBeats` and `intents` are keyed by worktree path and lane name; an intent's null is a sidecar intent with no folder.
33 * @typedef {{ at: number, status: Status, refs: Sources['refs'], prs: Sources['prs'] }} Full
34 * the last full read (Status, the for-each-ref and gh), kept in the store for the closed pane and other sessions
35 */
36
37/** A source that gave nothing: what a session with no machine folder, or before its first refresh, has. */
38/** @type {Sources} */
39export const NO_SOURCES = Object.freeze({ status: null, slots: [], laneBeats: new Map(), sessionBeats: new Map(), refs: null, prs: null, intents: new Map(), mainMovedAt: 0 })
40
41// A lane's intent folder read through Ather's parser (D2.5); null when the sidecar names an intent with no folder
42// (no prompt.md). The fields Ather's list reads from git (age, first author) have no meaning here.
43/** @param {string} slug @param {{ prompt: string | null, progress: string | null, findings: string | null }} files @returns {Intent | null} */
44export const intentOf = (slug, files) => (files.prompt === null ? null : parseIntent({ slug, prompt: files.prompt, progress: files.progress ?? '', findings: files.findings ?? '', files: [], hasDebrief: false, updatedAt: 0, source: 'local', firstAuthor: '' }))
45
46// The heartbeats from their files' text (D2.2): each lane's `_machine` heartbeat by its name, and each worktree's
47// newest Ather session heartbeat on its own branch, by its path.
48/** @param {Status | null} status @param {ReadonlyMap<string, string | null>} laneTexts @param {ReadonlyMap<string, readonly (string | null)[]>} sessionTexts */
49export const beatsOf = (status, laneTexts, sessionTexts) => {
50 /** @type {Map<string, Beat>} */
51 const laneBeats = new Map()
52 /** @type {Map<string, Beat>} */
53 const sessionBeats = new Map()
54 for (const lane of status?.lanes ?? []) {
55 const own = readLaneBeat(laneTexts.get(lane.name) ?? null)
56 if (own) laneBeats.set(lane.name, own)
57 const newest = (sessionTexts.get(lane.path) ?? []).map(text => readSessionBeat(text, lane.branch)).filter(/** @returns {one is Beat} */ one => one !== null).sort((a, b) => b.at - a.at)[0]
58 if (newest) sessionBeats.set(lane.path, newest)
59 }
60 return { laneBeats, sessionBeats }
61}
62
63// "## F-2 (2026-10-24, rev 1) | ..." gives 2026-10-24; 0 for a heading with no date.
64/** @param {string} source */
65const sinceOf = source => {
66 const day = /\((\d{4}-\d{2}-\d{2})\b/.exec(source.split(/\r?\n/)[0] ?? '')?.[1]
67 const at = day ? Date.parse(`${day}T00:00:00Z`) : NaN
68 return Number.isFinite(at) ? at : 0
69}
70
71/** @param {Intent | null | undefined} intent @returns {Decision[]} */
72const decisionsOf = intent => directorCalls(intent ?? undefined).map(one => ({ id: one.id, title: one.title, full: one.full, since: sinceOf(one.source) }))
73
74/** @param {Sources} sources @returns {LaneRecord[]} */
75export const laneRecords = sources => {
76 const lanes = sources.status?.lanes ?? []
77 return lanes.map(lane => {
78 const isDetached = lane.branch === '(detached)' || lane.branch === ''
79 const ref = isDetached ? undefined : sources.refs?.get(lane.branch)
80 const beats = [sources.laneBeats.get(lane.name), sources.sessionBeats.get(lane.path)].filter(/** @returns {one is Beat} */ one => one !== undefined).sort((a, b) => b.at - a.at)
81 const lastCommit = ref?.committedAt ?? 0
82 const newest = beats[0]
83 const lastMoved = newest && newest.at > lastCommit ? { source: newest.source, at: newest.at } : lastCommit > 0 ? { source: 'last commit', at: lastCommit } : null
84 const intent = sources.intents.get(lane.name)
85 return {
86 name: lane.name,
87 path: lane.path,
88 kind: lane.kind,
89 sidecar: lane.sidecar,
90 branch: lane.branch,
91 head: lane.head,
92 isDetached,
93 isMissing: lane.missing,
94 intent: lane.intent,
95 intentTitle: intent?.title ?? '',
96 tier: lane.tier,
97 slotNeed: lane.slotNeed === 'none' ? '' : lane.slotNeed,
98 slotHeld: sources.slots.filter(slot => slot.holder === lane.name),
99 ahead: ref ? ref.ahead : null,
100 behind: ref ? ref.behind : null,
101 lastCommit,
102 mainMovedAt: sources.mainMovedAt,
103 beats,
104 lastMoved,
105 pr: isDetached ? null : sources.prs === null ? undefined : (sources.prs.get(lane.branch) ?? null),
106 decisions: decisionsOf(intent),
107 }
108 })
109}
110
111// The branches the `for-each-ref` and gh ask about (D2.3, D2.4): every worktree's named branch but the pool's;
112// gh leaves lane 0's out too (`head:main` matches every fork's main).
113/** @param {Status | null} status @param {{ forPrs?: boolean }} [options] */
114export const laneBranches = (status, { forPrs = false } = {}) => [
115 ...new Set(
116 (status?.lanes ?? [])
117 .filter(lane => !isPool(lane.kind) && !(forPrs && lane.kind === KIND_LANE_0))
118 .map(lane => lane.branch)
119 .filter(branch => branch !== '' && branch !== '(detached)' && /^[\w./-]+$/.test(branch)),
120 ),
121]
122
123// The last part of a folder's path, as Status names an unregistered worktree.
124/** @param {string} path */
125const leafOf = path => path.replace(/[\\/]+$/, '').replace(/^.*[\\/]/, '')
126
127// The lanes as files alone show them while the pane is closed (D8): no process, so no `git worktree list`. Lane 0;
128// each worktree folder of the lanes folder (`entries`, one listing), with its sidecar when it has one, else
129// unregistered; the worktrees Status last listed elsewhere (harness and other worktrees) as it listed them. A
130// worktree Status has listed keeps its kind, branch and head; one it has not yet takes its kind by its name
131// (kindOfEntry) and no branch, until the pane opens and Status runs. Lane 0 first, then Status' order, new folders
132// after.
133/**
134 * @param {{ kept: Status | null, main: string, lanesRoot: string, machineDir: string, sidecars: readonly Sidecar[], entries: readonly string[] }} files
135 * @returns {Status}
136 */
137export const statusOfFiles = ({ kept, main, lanesRoot, machineDir, sidecars, entries }) => {
138 const known = kept?.lanes ?? []
139 const keptBy = new Map(known.map(lane => [folderKey(lane.path), lane]))
140 const sidecarBy = new Map(sidecars.map(one => [folderKey(one.path), one]))
141 const inLanes = `${folderKey(lanesRoot)}/`
142 /** @param {string} path @param {string} kind @returns {StatusLane} */
143 const unknown = (path, kind) => ({ name: leafOf(path), path, branch: '', head: '', kind, missing: false, sidecar: 'unregistered', intent: '', tier: '', slotNeed: '' })
144 const zero = keptBy.get(folderKey(main)) ?? { ...unknown(main, KIND_LANE_0), sidecar: '(lane 0, no sidecar)' }
145 const folders = entries.map(name => {
146 const path = `${lanesRoot}/${name}`
147 // Found on disk with its `.git` just now, so not missing, whatever the last Status said.
148 const base = { ...(keptBy.get(folderKey(path)) ?? unknown(path, kindOfEntry(name))), missing: false }
149 const sidecar = sidecarBy.get(folderKey(path))
150 return sidecar
151 ? { ...base, name: sidecar.name, branch: sidecar.branch || base.branch, sidecar: 'registered', intent: sidecar.intent, tier: sidecar.tier, slotNeed: sidecar.slotNeed }
152 : { ...base, name: leafOf(path), sidecar: 'unregistered', intent: '', tier: '', slotNeed: '' }
153 })
154 const elsewhere = known.filter(lane => lane.kind !== KIND_LANE_0 && !folderKey(lane.path).startsWith(inLanes))
155 const order = (/** @type {StatusLane} */ lane) => {
156 const at = known.findIndex(one => folderKey(one.path) === folderKey(lane.path))
157 return lane === zero ? -1 : at < 0 ? known.length : at
158 }
159 const lanes = [zero, ...elsewhere, ...folders].sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name))
160 return { machineDir: kept?.machineDir || machineDir, lanes }
161}
162
163// This session's lane (D10): the worktree whose folder is the session's checkout (`root`: Ather's checkoutOf, which
164// takes a folder nested in a worktree up to it), case and slashes aside. Only that folder: a checkout inside another
165// worktree (a harness worktree inside lane 0) is not that worktree. '' for none.
166/** @param {Status | null} status @param {string} root */
167export const laneOf = (status, root) => {
168 const at = folderKey(root)
169 return at === '' ? '' : ((status?.lanes ?? []).find(lane => folderKey(lane.path) === at)?.name ?? '')
170}
171
172// A full read as the store keeps it (plain JSON: the maps as their entries), and back; fullOf gives null for none or
173// a shape not understood.
174/** @param {Full} full */
175export const fullData = full => ({ at: full.at, status: full.status, refs: full.refs ? [...full.refs] : null, prs: full.prs ? [...full.prs] : null })
176
177/** @param {any} data @returns {Full | null} */
178export const fullOf = data => {
179 const status = statusOf(data?.status)
180 if (!status || typeof data.at !== 'number') return null
181 const mapOf = (/** @type {unknown} */ entries) => (Array.isArray(entries) ? new Map(entries) : null)
182 return { at: data.at, status, refs: mapOf(data.refs), prs: mapOf(data.prs) }
183}
184hooks/sources.mjs 210 lines1// @ts-check
2// Ather Lanes: one pure reader per source (D2). Each takes what the source gave (a script's stdout, a file's
3// text) and returns plain values; none throws, and a missing, empty or half-written input reads as unknown.
4// No `$` here: index.mjs runs the processes and reads the files, and hands their text in.
5
6import { parseEditorLock } from './ather/packs/unreal.mjs'
7import { normalFolder, repoId } from './ather/workspace.mjs'
8
9/**
10 * One worktree as `lanes.ps1 -Action Status -Json` lists it, its fields taken as they are (D2.1).
11 * @typedef {{ name: string, path: string, branch: string, head: string, kind: string, missing: boolean, sidecar: string,
12 * intent: string, tier: string, slotNeed: string }} StatusLane
13 * @typedef {{ machineDir: string, lanes: StatusLane[] }} Status
14 */
15
16/** @param {unknown} value */
17const text = value => (typeof value === 'string' ? value : '')
18
19/** @param {string} raw @returns {any} */
20const json = raw => {
21 try {
22 return JSON.parse(raw.replace(/^/, ''))
23 } catch {
24 return null
25 }
26}
27
28// Status' JSON (D2.1); null when it is not that JSON (a script error, a cut output).
29/** @param {string} stdout @returns {Status | null} */
30export const readStatus = stdout => statusOf(json(stdout))
31
32// Status' shape checked on a value already parsed (Status' JSON, or a full read kept in the store); null when it is
33// not that shape.
34/** @param {any} data @returns {Status | null} */
35export const statusOf = data => {
36 if (!data || typeof data !== 'object' || !Array.isArray(data.lanes)) return null
37 const lanes = data.lanes
38 .filter((/** @type {any} */ one) => one && typeof one === 'object' && text(one.name) !== '')
39 .map((/** @type {any} */ one) => ({ name: text(one.name), path: text(one.path), branch: text(one.branch), head: text(one.head), kind: text(one.kind), missing: one.missing === true, sidecar: text(one.sidecar), intent: text(one.intent), tier: text(one.tier), slotNeed: text(one.slotNeed) }))
40 return { machineDir: text(data.machineDir), lanes }
41}
42
43// Status' kinds, as `lanes.ps1` writes them; the mod shows them, it does not decide them, with one exception:
44// kindOfEntry below, for a folder Status has not listed yet (D8), held to Status by the contract test.
45export const KIND_LANE_0 = 'main checkout (lane 0)'
46export const KIND_LANE = 'lane'
47const KIND_POOL_READY = 'pool (ready)'
48const KIND_POOL_BUILDING = 'pool (building)'
49/** @param {string} kind */
50export const isPool = kind => kind === KIND_POOL_READY || kind === KIND_POOL_BUILDING
51/** @param {string} kind */
52export const isPoolReady = kind => kind === KIND_POOL_READY
53
54// A folder as two spellings of it compare: slashes one way, no trailing one, lowercased (D10).
55/** @param {string} folder */
56export const folderKey = folder => normalFolder(folder).toLowerCase()
57
58/**
59 * A sidecar `_machine\lanes\<name>.json` (D8), as `lanes.ps1` writes it and Status reads it: the lane's name, its
60 * worktree, branch, intent, tier and slot need.
61 * @typedef {{ name: string, path: string, branch: string, intent: string, tier: string, slotNeed: string }} Sidecar
62 */
63
64/** @param {string | null} raw @returns {Sidecar | null} */
65export const readSidecar = raw => {
66 const data = json(raw ?? '')
67 if (!data || typeof data !== 'object' || text(data.name) === '' || text(data.path) === '') return null
68 return { name: text(data.name), path: text(data.path), branch: text(data.branch), intent: text(data.intent), tier: text(data.tier), slotNeed: text(data.slot) }
69}
70
71// The kind Status gives a folder of the lanes folder by its name (lanes.ps1: `_pool-*`, `_poolbuild-*`, else a lane),
72// for a worktree Status has not listed yet while the pane is closed (D8). Status' own kind wins once it has run.
73/** @param {string} name */
74export const kindOfEntry = name => (name.startsWith('_pool-') ? KIND_POOL_READY : name.startsWith('_poolbuild-') ? KIND_POOL_BUILDING : KIND_LANE)
75
76/**
77 * One slot file `slots\<id>.txt` (D2.2): its first line through Ather's `parseEditorLock` (holder, end, past its
78 * end by its 30-minute rule), plus the first line's `kind <k>`; `pastMinutes`: how long since its end, 0 before it.
79 * @typedef {{ id: string, holder: string, until: string, kind: string, isStale: boolean, pastMinutes: number, leftMinutes: number }} Slot
80 */
81
82// "HH:MM" to minutes of the day; null for none.
83/** @param {string} clock */
84const minutesOf = clock => {
85 const match = /^(\d{1,2}):(\d{2})$/.exec(clock)
86 return match ? Number(match[1]) * 60 + Number(match[2]) : null
87}
88
89// The minutes from now to `until` (negative once passed), read in the window parseEditorLock reads an end in
90// (packs/unreal.mjs: an end less than 12 hours ago has passed, else it is the next day's). Keep the two the same.
91/** @param {string} until @param {number} nowMinutes */
92const minutesTo = (until, nowMinutes) => {
93 const end = minutesOf(until)
94 if (end === null) return null
95 const ago = (nowMinutes - end + 1440) % 1440
96 return ago > 0 && ago < 12 * 60 ? -ago : (end - nowMinutes + 1440) % 1440
97}
98
99/** @param {string} id @param {string | null} raw @param {number} nowMinutes @returns {Slot | null} */
100export const readSlot = (id, raw, nowMinutes) => {
101 const first = (raw ?? '').trim().split(/\r?\n/)[0] ?? ''
102 const lock = parseEditorLock(first, nowMinutes)
103 if (lock.state !== 'held') return null
104 const to = minutesTo(lock.until, nowMinutes)
105 return { id, holder: lock.holder, until: lock.until, kind: /\bkind\s+([a-z]+)/i.exec(first)?.[1]?.toLowerCase() ?? '', isStale: lock.isStale, pastMinutes: to !== null && to < 0 ? -to : 0, leftMinutes: to !== null && to > 0 ? to : 0 }
106}
107
108/**
109 * A heartbeat, either source (D2.2): `source` says whose, `at` when (ms), `state` what it said ('' for none).
110 * @typedef {{ source: 'lane heartbeat' | 'session heartbeat', at: number, state: string }} Beat
111 */
112
113// `_machine\heartbeats\<lane>.json`, written by `lanes.ps1 -Action Heartbeat` ({ lane, state, at: ISO }).
114/** @param {string | null} raw @returns {Beat | null} */
115export const readLaneBeat = raw => {
116 const data = json(raw ?? '')
117 const at = Date.parse(text(data?.at))
118 return Number.isFinite(at) ? { source: 'lane heartbeat', at, state: text(data?.state) } : null
119}
120
121// An Ather Automata session heartbeat (`<worktree>\<pack local folder>\lanes\<session>.json`), used only when
122// its branch is the worktree's (D2.2): another branch's says nothing of this lane. An ended session reads "ended".
123/** @param {string | null} raw @param {string} branch @returns {Beat | null} */
124export const readSessionBeat = (raw, branch) => {
125 const data = json(raw ?? '')
126 const at = Number(data?.updatedAt)
127 if (!data || !branch || text(data.branch) !== branch || !Number.isFinite(at) || at <= 0) return null
128 return { source: 'session heartbeat', at, state: data.hasEnded === true ? 'ended' : '' }
129}
130
131/**
132 * One lane branch from the `for-each-ref` (D2.3): ahead of and behind `origin/main`, and its last commit (ms).
133 * @typedef {{ ahead: number, behind: number, committedAt: number }} Ref
134 */
135
136// The format the `for-each-ref` asks for; readRefs reads it.
137export const REF_FORMAT = '%(refname:short) %(ahead-behind:origin/main) %(committerdate:unix)'
138
139/** @param {string} stdout @returns {Map<string, Ref>} */
140export const readRefs = stdout => {
141 const refs = new Map()
142 for (const line of stdout.split(/\r?\n/)) {
143 const match = /^(\S+) (\d+) (\d+) (\d+)$/.exec(line.trim())
144 if (match) refs.set(match[1], { ahead: Number(match[2]), behind: Number(match[3]), committedAt: Number(match[4]) * 1000 })
145 }
146 return refs
147}
148
149// When `origin/main` last moved here: the last line of its reflog (`.git\logs\refs\remotes\origin\main`), its
150// time field; 0 when unread. The mod never fetches (D2.3).
151/** @param {string | null} raw */
152export const readReflogTime = raw => {
153 const last = (raw ?? '').trim().split(/\r?\n/).pop() ?? ''
154 const match = /^[0-9a-f]+ [0-9a-f]+ .*?> (\d+) [+-]\d{4}\t/.exec(last)
155 return match ? Number(match[1]) * 1000 : 0
156}
157
158// The GitHub `owner/repo` of `remote "origin"` in a `.git\config`, as Ather names a repository (workspace.mjs
159// repoId); '' for none or another host, which gh cannot ask (D2.4).
160/** @param {string | null} raw */
161export const readOrigin = raw => {
162 const block = /\[remote "origin"\]([^[]*)/.exec(raw ?? '')?.[1] ?? ''
163 const url = /^\s*url\s*=\s*(\S+)/m.exec(block)?.[1] ?? ''
164 return /github\.com[:/]/i.test(url) ? repoId(url, '') : ''
165}
166
167// Lane 0, the main checkout, from a checkout's git directory (workspace.mjs gitDirOf): a worktree's is
168// `<main>/.git/worktrees/<id>`, the main checkout's own `<main>/.git`.
169/** @param {string} gitDir */
170export const laneZeroOf = gitDir => normalFolder(gitDir).replace(/\/\.git(\/worktrees\/[^/]+)?$/, '')
171
172/**
173 * A lane branch's PR (D2.4): its number and link, state, draft or not, and its checks rolled up.
174 * @typedef {{ number: number, url: string, state: string, isDraft: boolean, checks: 'failing' | 'pending' | 'passing' | 'none' }} Pr
175 */
176
177const FAILED = /^(FAILURE|ERROR|CANCELLED|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE)$/
178const DONE = /^(SUCCESS|NEUTRAL|SKIPPED)$/
179
180/** @param {unknown} rollup @returns {Pr['checks']} */
181const checksOf = rollup => {
182 const list = Array.isArray(rollup) ? rollup : []
183 if (list.length === 0) return 'none'
184 const states = list.map(one => text(one?.conclusion) || text(one?.state))
185 if (states.some(state => FAILED.test(state))) return 'failing'
186 return states.every(state => DONE.test(state)) ? 'passing' : 'pending'
187}
188
189// `gh pr list --json number,headRefName,state,isDraft,statusCheckRollup` for the lane branches: each branch's PR,
190// an open one first, else the newest. Branches another fork also has are filtered out by the caller's set.
191/** @param {string} stdout @param {string} repo @param {readonly string[]} branches @returns {Map<string, Pr> | null} */
192export const readPrs = (stdout, repo, branches) => {
193 const data = json(stdout)
194 if (!Array.isArray(data)) return null
195 const prs = new Map()
196 for (const one of data) {
197 const branch = text(one?.headRefName)
198 const number = Number(one?.number)
199 if (!branches.includes(branch) || !Number.isInteger(number)) continue
200 const pr = { number, url: `https://github.com/${repo}/pull/${number}`, state: text(one.state).toLowerCase(), isDraft: one.isDraft === true, checks: checksOf(one.statusCheckRollup) }
201 const kept = prs.get(branch)
202 if (!kept || (pr.state === 'open') > (kept.state === 'open') || ((pr.state === 'open') === (kept.state === 'open') && pr.number > kept.number)) prs.set(branch, pr)
203 }
204 return prs
205}
206
207// The `gh pr list` arguments for some lane branches (D2.4): one search, the branches OR'd.
208/** @param {string} repo @param {readonly string[]} branches */
209export const prListArgs = (repo, branches) => ['pr', 'list', '-R', repo, '--state', 'all', '--limit', String(Math.max(30, 4 * branches.length)), '--search', branches.map(branch => `head:${branch}`).join(' '), '--json', 'number,headRefName,state,isDraft,statusCheckRollup']
210hooks/ather/shell.mjs 95 lines1// @ts-check
2// Ather Automata: how a shell command line reads, for every pack. Its commands
3// (split outside quotes, heredoc bodies dropped), the folder each runs in, and
4// where a push sends. Pure: no `$`.
5
6// A heredoc's body is text being written, not a command being run.
7/** @param {string} command */
8export const withoutHeredocs = command => command.replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\1\b/g, '')
9
10// A filter after a pipe makes the exit the filter's, unless `pipefail` hands the build's back.
11/** @param {string} command */
12export const isPiped = command => /\|\s*(tail|head|grep|Select-String|Select-Object|findstr|tee|sls)\b/i.test(command) && !/\bpipefail\b/.test(command)
13
14// The commands of a chain: split only outside quotes, heredoc bodies dropped, quotes removed.
15// `git commit -m "fix; git push origin main"` is one commit command, not a push.
16/** @param {string} command */
17export const segments = command => {
18 const text = withoutHeredocs(command)
19 const out = []
20 let current = ''
21 let quote = ''
22 for (let at = 0; at < text.length; at += 1) {
23 const char = text[at] ?? ''
24 if (quote !== '') {
25 if (char === quote) quote = ''
26 else current += char
27 } else if (char === '"' || char === "'") quote = char
28 else if (text.startsWith('&&', at) || text.startsWith('||', at)) {
29 out.push(current)
30 current = ''
31 at += 1
32 } else if (char === ';' || char === '|' || char === '\n') {
33 out.push(current)
34 current = ''
35 } else current += char
36 }
37 out.push(current)
38 return out.map(part => part.trim()).filter(Boolean)
39}
40
41// Each segment with the folder it runs in: its own `git -C`, else the last `cd` before it, else null (the session's folder).
42/** @param {string} command */
43export const withFolders = command => {
44 /** @type {string | null} */
45 let cwd = null
46 return segments(command).map(segment => {
47 const cd = /^(?:cd|Set-Location|pushd)\s+(\S+)/i.exec(segment)
48 if (cd?.[1]) cwd = cd[1]
49 return { segment, folder: /^git\s+-C\s+(\S+)/i.exec(segment)?.[1] ?? cwd }
50 })
51}
52
53// The folders a command's git segments run in, for the caller to look up their branches.
54/** @param {string} command */
55export const gitFolders = command => [...new Set(withFolders(command).filter(one => /^git\b/i.test(one.segment)).map(one => one.folder))]
56
57export const MAIN = /^(?:refs\/heads\/)?(main|master)$/
58
59// A branch name as a profile or a ref gives it; '' when it does not read as one.
60/** @param {unknown} value */
61export const branchNamed = value => {
62 if (typeof value !== 'string') return ''
63 const name = value.trim().replace(/^refs\/heads\//, '')
64 return name.length <= 100 && /^[A-Za-z0-9._\/-]+$/.test(name) ? name : ''
65}
66
67// A branch a team merges into: main, master, or the checkout's own base when it has another (develop).
68/** @param {string} branch @param {string} [base] */
69export const isBaseBranch = (branch, base = 'main') => MAIN.test(branch) || (branch !== '' && branch.replace(/^refs\/heads\//, '') === base)
70
71// Where a `git push` sends: its refspec's destination, or the current branch when it names none.
72/** @param {string} segment @param {string} branch */
73export const pushTarget = (segment, branch) => {
74 const words = segment.split(/\s+/)
75 const refspec = words.slice(words.indexOf('push') + 1).filter(word => !word.startsWith('-'))[1]
76 if (refspec === undefined) return branch
77 const destination = refspec.replace(/^\+/, '').split(':').pop() ?? ''
78 return destination === 'HEAD' ? branch : destination
79}
80
81// Searches over source and docs: a trap's text in their output is someone looking it up, not hitting it.
82// Reading a build log is different: that is where the traps show up.
83const SEARCHERS = /^(grep|rg|ag|ack|findstr|Select-String|sls)\b/i
84
85/** @param {string} command */
86export const isSearchCommand = command => segments(command).every(segment => SEARCHERS.test(segment))
87
88// A segment with leading env assignments (`FOO=1 BAR=x cmd`) and runners (`npx`, `npm exec`, `pnpm dlx`) taken off.
89/** @param {string} segment */
90export const bareCommand = segment =>
91 segment
92 .replace(/^(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '')
93 .replace(/^(?:npx(?:\s+--yes|\s+-y)?|npm\s+exec(?:\s+--)?|pnpm\s+(?:dlx|exec)|yarn\s+dlx|bunx)\s+/i, '')
94 .trim()
95hooks/ather/packs/index.mjs 129 lines1// @ts-check
2// Ather Automata: which pack serves a repository, and the shape every pack has.
3// The core (intents, Plan → Build → Prove → Ship, Next, away windows, issues,
4// the squad) is the same everywhere; a pack says what proof, traps, held
5// actions and words fit the kind of project. Pure apart from the Io it is given.
6//
7// Selection (D3): the repository's .ather/profile.json, then markers (*.uproject
8// → unreal; package.json or pyproject.toml → web), then the core alone. Read once
9// per session and root, and cached.
10
11import { gitDirOf, isAbsolute, normalFolder } from '../workspace.mjs'
12import { core } from './core.mjs'
13import { unreal } from './unreal.mjs'
14import { makeWebPack } from './web.mjs'
15
16/**
17 * @typedef {{ state: 'none' | 'pass' | 'fail', detail: string, at?: number, by?: string }} Rung `at`, `by`: when, and by which session (its first 8 hex), the record was written
18 * @typedef {{ rungs: { rung: string, value: Rung, gates?: RungGates }[], context: string[], toasts: { text: string, timeoutMs?: number }[], bumps: string[] }} ShellReading
19 * @typedef {{ ran: string[], all: string[] }} RungGates on a rung that gates declare: the gates a command ran (none: a command that is no gate), of all that declare it
20 * @typedef {{ id: string, pattern: RegExp, title: string, fix: string, rule?: { file: string, text: string } }} PackTrap
21 * @typedef {{ name: string, verb: string, isGlobal?: boolean }} CreateItem
22 * @typedef {{ isHeld: boolean, isFree: boolean, holder: string, until: string }} EditorState
23 * @typedef {{ command: string, proofs: string[], id?: string, proves?: string, passOn?: 'exit' | 'counts' }} Gate `passOn`: how its tests and ui proofs pass, on its exit code (also without the field) or on test counts
24 * @typedef {{ host: string, branch: string, deployment: string, url: string, expectStatus: number }} Production
25 * @typedef {{ files?: readonly string[], defaultBranch?: string }} Found what else was read of a repository where its pack is chosen: the names of the files at its root, and the default branch its clone knows
26 * @typedef {{ pack: Pack, held: readonly string[], isProven: boolean, base?: string }} HeldAt what a folder's own checkout holds, when that is not the session's; `base`: the branch its team merges into, when not main
27 * @typedef {{ isProven?: boolean, scripts?: Record<string, string>, base?: string, at?: (folder: string | null) => HeldAt | null }} HeldContext `base`: the branch the session's checkout merges into, when not main
28 * @typedef {{
29 * id: 'unreal' | 'web' | 'core',
30 * roles: readonly string[], roleLabels: Record<string, string>, roleDescriptions: Record<string, string>,
31 * roleWords: string, roleHelp: string, roleFallback: string, roleKey: string, parseRole: (text: string) => string | null,
32 * owners: string, areas: readonly string[], normalizeArea: (text: string) => string,
33 * rungLabels: Record<string, string>, proofWords: Record<string, string>, emptyEvidence: () => Record<string, Rung>,
34 * requiredRungs: (role: string) => string[], isProven: (evidence: Record<string, Rung>, role: string) => boolean, anyProofText: string,
35 * localDir: string, debriefPath: (slug: string) => string,
36 * lockFile: string | null, parseLock: (raw: string | null, nowMinutes: number) => import('./unreal.mjs').EditorLock, lockRoles: readonly string[],
37 * lockLine?: (command: string, raw: string) => string,
38 * ownCheck: { role: string, after: string, rung: string, label: string, hint: string, proveHint: string, detail: string, reply: string } | null,
39 * traps: readonly PackTrap[],
40 * held: { labels: Record<string, string>, nouns: Record<string, string>, kinds: readonly string[], defaults: readonly string[] },
41 * heldSegment: (segment: string, held: readonly string[], context: HeldContext) => string | null,
42 * mergePolicy: 'hold' | 'with-proof', mergeRungs?: readonly string[], rungGates?: Readonly<Record<string, string[]>>,
43 * baseBranch?: string,
44 * isAssetSave: (input: string) => boolean, readShell: (command: string, text: string, ran: { isError?: boolean, deny?: string }) => ShellReading,
45 * mcpKind: (input: string) => 'write' | 'read' | 'pie' | null, binaryAssets: RegExp | null, briefPaths: RegExp,
46 * skillGroups: readonly { group: string, names: readonly string[] }[], createGroups: readonly { group: string, items: readonly CreateItem[] }[],
47 * createOrder: Record<string, readonly string[]>, createTitle: string, createMeta: (editor: EditorState) => string, createPrompt: (verb: string, name: string, editor: EditorState) => string,
48 * prompts: { brief: (role: string, slug: string) => string, prove: (role: string, slug: string) => string, ship: (role: string, slug: string) => string, shipHint: (role: string) => string, briefHint: string, tour: string, tourToast: string, ask: (question: string) => string },
49 * mandate: { flags: string, allowed: string, merge: string, away: string, pane: string, panes: string },
50 * statusWhat: string, notHere: string, gates: readonly Gate[], production: Production | null, scripts?: Record<string, string>,
51 * teams?: Readonly<Record<string, string>>, names?: import('../worklist.mjs').NameRules,
52 * [key: string]: unknown
53 * }} Pack
54 */
55
56/**
57 * What selection reads: closures over the engine, as state.mjs's Io.
58 * @typedef {{ read: (path: string) => Promise<string | null>, exists: (path: string) => Promise<boolean>, list: (path: string) => Promise<{ name: string, kind: string }[]>, sessionId: () => Promise<string> }} PackIo
59 */
60
61/** @param {string | null} text */
62const json = text => {
63 if (!text) return null
64 try {
65 const value = JSON.parse(text)
66 return value && typeof value === 'object' ? value : null
67 } catch {
68 return null
69 }
70}
71
72// The default branch a clone knows: where refs/remotes/origin/HEAD points, in the git folder its worktrees share.
73// Read from the files, with no git call; '' when it cannot be told.
74/** @param {PackIo} io @param {string} root */
75const defaultBranchOf = async (io, root) => {
76 const named = await gitDirOf(io, root)
77 if (!named) return ''
78 // A worktree's .git file may name its git folder from the worktree.
79 const gitDir = named === `${root}/.git` || isAbsolute(named) ? named : normalFolder(`${root}/${named}`)
80 const common = ((await io.read(`${gitDir}/commondir`)) ?? '').trim()
81 const shared = common === '' ? gitDir : isAbsolute(common) ? common : normalFolder(`${gitDir}/${common}`)
82 return /^ref:\s*refs\/remotes\/origin\/(.+)/.exec((await io.read(`${shared}/refs/remotes/origin/HEAD`)) ?? '')?.[1]?.trim() ?? ''
83}
84
85/**
86 * Which pack a repository gets, and why, with the default branch its clone knows ('' when it cannot be told).
87 * Pure over what it reads.
88 * @param {PackIo} io @param {string} root
89 * @returns {Promise<{ pack: Pack, source: 'profile' | 'marker' | 'none', profile: Record<string, any> | null, defaultBranch: string }>}
90 */
91export const choosePack = async (io, root) => {
92 const profile = json(await io.read(`${root}/.ather/profile.json`))
93 const packageJson = json(await io.read(`${root}/package.json`))
94 const defaultBranch = await defaultBranchOf(io, root).catch(() => '')
95 const named = typeof profile?.pack === 'string' ? profile.pack.toLowerCase() : ''
96 if (named === 'unreal') return { pack: unreal, source: 'profile', profile, defaultBranch }
97 if (named === 'core') return { pack: core, source: 'profile', profile, defaultBranch }
98 const entries = await io.list(root).catch(() => [])
99 const files = entries.filter(entry => entry.kind === 'file' && typeof entry.name === 'string').map(entry => entry.name)
100 const web = () => makeWebPack(profile, packageJson, { files, defaultBranch })
101 if (named === 'web') return { pack: web(), source: 'profile', profile, defaultBranch }
102 if (entries.some(entry => entry.kind === 'file' && /\.uproject$/i.test(entry.name))) return { pack: unreal, source: 'marker', profile, defaultBranch }
103 if (packageJson || entries.some(entry => entry.kind === 'file' && /^(package\.json|pyproject\.toml)$/i.test(entry.name))) return { pack: web(), source: 'marker', profile, defaultBranch }
104 return { pack: core, source: 'none', profile, defaultBranch }
105}
106
107/** @type {Map<string, ReturnType<typeof choosePack>>} */
108const chosen = new Map()
109
110// Once per session and root: a profile edited mid-session takes effect in the next session.
111/** @param {PackIo} io @param {string} root */
112export const packFor = async (io, root) => {
113 const key = `${await io.sessionId().catch(() => '')}|${root}`
114 const hit = chosen.get(key)
115 if (hit) return hit
116 const read = choosePack(io, root)
117 chosen.set(key, read)
118 return read
119}
120
121// A repository that got its intents mid-session has a profile now: its pack is chosen again.
122/** @param {Pick<PackIo, 'sessionId'>} io @param {string} root */
123export const forgetPack = async (io, root) => void chosen.delete(`${await io.sessionId().catch(() => '')}|${root}`)
124
125// Tests only: forget what was chosen.
126export const forgetPacks = () => chosen.clear()
127
128export { core, unreal, makeWebPack }
129hooks/ather/model.mjs 705 lines1// @ts-check
2// Ather Automata: what the S2 workflow's files say. Intents, people, time, the
3// Editor lock, the four stages and the one next step. Pure: no `$`.
4
5import { unreal } from './packs/unreal.mjs'
6import { branchNamed } from './shell.mjs'
7
8// The Unreal pack's names, kept here for the modules and tests that read them from the model.
9export { ROLES, ROLE_LABELS, OWNERS, AREAS, parseRole, parseEditorLock } from './packs/unreal.mjs'
10
11/** @typedef {import('./packs/index.mjs').Pack} Pack */
12
13export const STAGE_LABELS = { plan: 'Plan', build: 'Build', prove: 'Prove', ship: 'Ship', close: 'Ready to close', shipped: 'Shipped' }
14
15// The issue an intent names: "#28887", "sipherxyz/S2#28887", a link ending "issues/28887", or "28887".
16/** @param {string} text */
17const issueNumber = text => {
18 const match = /#(\d+)|issues\/(\d+)/.exec(text) ?? /^\s*(\d+)\s*$/.exec(text)
19 return match ? Number(match[1] ?? match[2]) : null
20}
21
22/** @param {string} text @param {Pack} [pack] */
23export const normalizeArea = (text, pack = unreal) => pack.normalizeArea(text)
24
25// The key a person's stored records are filed under (personId). It drops accented letters ("Hoàng Vũ" is
26// "hongv"); kept so, since changing it would lose those records.
27/** @param {string} name */
28const personKey = name => name.toLowerCase().replace(/[^a-z0-9]/g, '')
29
30// The key two names are compared by: accents dropped, the letters kept ("Hoàng Vũ" is "hoangvu").
31/** @param {string} name */
32const matchKey = name => personKey(name.normalize('NFD').replace(/\p{M}/gu, '').replace(/[đĐ]/g, 'd'))
33
34// Other names a person goes by, by match key: git's email name ("vu.le" for "Hoàng Vũ"), learnt from the checkout.
35/** @type {Map<string, Set<string>>} */
36const aliases = new Map()
37
38/** @param {string} name @param {string} alias */
39export const addPersonAlias = (name, alias) => {
40 const key = matchKey(name)
41 const other = matchKey(alias)
42 if (key === '' || other === '' || key === other) return
43 aliases.set(key, new Set([...(aliases.get(key) ?? []), other]))
44}
45
46// "Tin Nguyen" matches "TinNguyen"; "TienPham" matches "TienPhamProducerAther"; "Hoàng Vũ" matches "Hoang Vu",
47// and "vu.le" once git's email says vu.le@… is Hoàng Vũ. An email name matches only whole: "tin@…" is not Tin Nguyen.
48/** @param {string} a @param {string} b */
49export const isSamePerson = (a, b) => {
50 const x = matchKey(a)
51 const y = matchKey(b)
52 return x !== '' && y !== '' && (x.startsWith(y) || y.startsWith(x) || Boolean(aliases.get(x)?.has(y)) || Boolean(aliases.get(y)?.has(x)))
53}
54
55// "a", "a and b", "a, b and c".
56/** @param {readonly string[]} items */
57export const andList = items => (items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`)
58
59/** @param {number} count @param {string} one @param {string} [many] */
60export const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`
61
62// The closest of a few command words to a typo ("tuor" → "tour"), or null.
63/** @param {string} word @param {readonly string[]} words */
64export const closestWord = (word, words) => {
65 // Edits between two words, a swap of neighbours counting as one ("tuor" is one edit from "tour").
66 const distance = (/** @type {string} */ a, /** @type {string} */ b) => {
67 const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
68 const at = (/** @type {number} */ i, /** @type {number} */ j) => d[i]?.[j] ?? 0
69 for (let i = 1; i <= a.length; i += 1) {
70 for (let j = 1; j <= b.length; j += 1) {
71 const cost = a[i - 1] === b[j - 1] ? 0 : 1
72 let best = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + cost)
73 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, at(i - 2, j - 2) + 1)
74 const row = d[i]
75 if (row) row[j] = best
76 }
77 }
78 return at(a.length, b.length)
79 }
80 const ranked = words.map(candidate => ({ candidate, cost: distance(word.toLowerCase(), candidate) })).sort((x, y) => x.cost - y.cost)
81 return ranked[0] && ranked[0].cost <= 1 && word.length >= 3 ? ranked[0].candidate : null
82}
83
84/** @param {string} me */
85export const personId = me => personKey(me) || 'anyone'
86
87// ---------------------------------------------------------------- text
88
89/** @param {string} markdown */
90const plainText = markdown =>
91 markdown
92 .replace(/\*\*(.+?)\*\*/g, '$1')
93 .replace(/`([^`]+)`/g, '$1')
94 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
95 .replace(/\s+/g, ' ')
96 .trim()
97
98// A row title: plain text, no "Found:" label, the first sentence, cut at a word.
99/** @param {string} text @param {number} [max] */
100export const shortTitle = (text, max = 80) => {
101 const plain = plainText(text).replace(/^(found|finding|problem|issue|question|context|summary|note|observed)\s*:\s*/i, '')
102 if (plain === '') return ''
103 const capital = plain.charAt(0).toUpperCase() + plain.slice(1)
104 const sentence = (/^(.+?[.?!])(\s|$)/.exec(capital)?.[1] ?? capital).replace(/\.$/, '')
105 if (sentence.length <= max) return sentence
106 const cut = sentence.slice(0, max)
107 const space = cut.lastIndexOf(' ')
108 return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,;:\s]+$/, '')}…`
109}
110
111// A label cut at a word past `max`, ended with "…" when cut.
112/** @param {string} text @param {number} max */
113export const cutWords = (text, max) => (text.length <= max ? text : `${text.slice(0, max - 1).replace(/\s+\S*$/, '')}…`)
114
115// The same text whole, for a surface that wraps it: no label, every sentence, cut only past `max`.
116/** @param {string} text @param {number} [max] */
117export const fullTitle = (text, max = 400) => {
118 const plain = plainText(text).replace(/^(found|finding|problem|issue|question|context|summary|note|observed)\s*:\s*/i, '').trim()
119 const capital = plain.charAt(0).toUpperCase() + plain.slice(1)
120 return capital.length <= max ? capital : `${capital.slice(0, max - 1)}…`
121}
122
123// ---------------------------------------------------------------- intents
124
125/** @param {string} text @param {string} name */
126const field = (text, name) => new RegExp(`^\\s*-\\s*${name}:\\s*(.+)$`, 'mi').exec(text)?.[1]?.trim() ?? ''
127
128/** @param {string} text @param {string} heading */
129export const section = (text, heading) => {
130 const start = new RegExp(`^##\\s+${heading}\\b.*$`, 'mi').exec(text)
131 if (!start) return ''
132 const rest = text.slice(start.index + start[0].length)
133 const end = /^##\s+/m.exec(rest)
134 return end ? rest.slice(0, end.index) : rest
135}
136
137// Every section headed so, in order, each on its own: "## Acceptance" and a later "## Acceptance, rev 2" both count.
138/** @param {string} text @param {string} heading */
139const sections = (text, heading) =>
140 text
141 .split(/^(?=##\s)/m)
142 .filter(part => new RegExp(`^##\\s+${heading}\\b`, 'i').test(part))
143 .map(part => part.replace(/^.*$/m, ''))
144
145const CLOSED = /\b(accepted|rejected|resolved|closed|superseded|withdrawn|answered)\b/i
146
147// ---------------------------------------------------------------- a finding's options and resolution
148
149/** @typedef {{ letter: string, label: string, text: string, isRecommended: boolean }} FindingOption `label`: its first clause, cut to fit a button; `text`: the whole option, plain */
150
151// A clause that only sets the scene ("In the next C++ build", "After the import"): no action of its own.
152const LEADING = /^(in|on|at|after|before|when|whenever|if|once|until|unless|during|while|by|from|then|first)\b/i
153
154// An option's first clause ("keep it as is, with the confirmation (…)" → "Keep it as is"), at least
155// a few words long ("no, drops only" stays whole), cut at a word past `max`. A clause of fewer than
156// three words, or one that only sets the scene, says too little alone: the whole text is cut instead.
157/** @param {string} text @param {number} [max] */
158export const optionLabel = (text, max = 60) => {
159 const plain = plainText(text)
160 const end = [...plain.matchAll(/[,;:(]|\s[—–-]\s|\.(?=\s|$)/g)].map(match => match.index ?? 0).find(at => at >= 12) ?? plain.length
161 const first = plain.slice(0, end).trim()
162 const clause = LEADING.test(first) || first.split(/\s+/).length < 3 ? plain : first
163 const capital = clause.charAt(0).toUpperCase() + clause.slice(1)
164 if (capital.length <= max) return capital
165 const cut = capital.slice(0, max - 1)
166 const space = cut.lastIndexOf(' ')
167 return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,;:\s]+$/, '')}…`
168}
169
170// "Recommendation: (a)", "Recommendation: B": the letter, upper case, or ''.
171/** @param {string} body */
172const recommendationOf = body => (/\brecommendation\**[ \t]*:\**[ \t]*\(?([a-z])\)?(?![\w])/i.exec(body)?.[1] ?? '').toUpperCase()
173
174// The options a finding writes, in either of the two formats the findings use:
175// **Options:** or inline, in one paragraph or list item that
176// - A (recommended): prove both on a map … says Options or Recommendation:
177// - B: add a lab rig … Options: (a) keep it as is; (b) add a hook …
178// Recommendation: (a).
179// A finding updated later ("**Options now:**") is read from its last options heading. Lettered
180// evidence ("- (a) Before Kawaii: …") with neither word nearby is not options. The recommended one is
181// marked "(recommended)", named by a "Recommendation:" line, or (only when neither says) the one
182// option whose text says "recommended". Fewer than two found: [].
183/** @param {string} body @returns {FindingOption[]} */
184export const parseOptions = body => {
185 const lines = body.split(/\r?\n/)
186 /** @type {{ letter: string, text: string, isMarked: boolean }[]} */
187 let found = []
188 // The list format: an "Options:" line on its own, then "- <Letter>[ (recommended)]: <text>" items and their indented continuations.
189 const head = lines.findLastIndex(line => OPTIONS_HEAD.test(line))
190 if (head >= 0) {
191 for (const line of lines.slice(head + 1)) {
192 const item = /^\s*[-*]\s+\**\s*([A-Za-z])\b\s*(?:\(([^)]*)\))?\s*\**\s*[:.)]\s*\**\s*(.*)$/.exec(line)
193 const last = found[found.length - 1]
194 if (item) found.push({ letter: (item[1] ?? '').toUpperCase(), text: item[3] ?? '', isMarked: /recommended/i.test(item[2] ?? '') })
195 else if (last && /^\s{2,}\S/.test(line) && !/^\s*[-*]\s/.test(line)) last.text += ` ${line.trim()}`
196 else if (found.length > 0 || line.trim() !== '') break
197 }
198 }
199 // The inline format: "(a) … (b) …" in one paragraph or list item, its continuation lines joined.
200 if (found.length === 0) {
201 // Each "(a)" line's paragraph or list item, whole: back to where it starts, on to where the next begins.
202 const starts = (/** @type {string} */ line) => line.trim() === '' || /^\s?[-*]\s|^\s*#|^\s*\*\*\w/.test(line)
203 const paragraphs = lines.flatMap((line, at) => {
204 if (!/\(a\)\s/.test(line)) return []
205 let from = at
206 while (from > 0 && !starts(lines[from] ?? '') && (lines[from - 1] ?? '').trim() !== '') from -= 1
207 let to = at + 1
208 while (to < lines.length && !starts(lines[to] ?? '')) to += 1
209 return [lines.slice(from, to).join(' ').replace(/\s+/g, ' ')]
210 })
211 const paragraph = paragraphs.find(text => /\b(options?|recommendation)\b/i.test(text))
212 if (paragraph) {
213 const from = paragraph.slice(paragraph.indexOf('(a)')).replace(/\s*\brecommendation\**\s*:.*$/i, '')
214 const parts = from.split(/\(([a-z])\)\s+/).slice(1)
215 for (let index = 0; index + 1 < parts.length; index += 2) {
216 const letter = (parts[index] ?? '').toUpperCase()
217 const last = found[found.length - 1]
218 // Letters in order only: a later "(x)" inside an option's text stays in it.
219 if (letter.charCodeAt(0) !== 65 + found.length) {
220 if (last) last.text += ` (${parts[index]}) ${parts[index + 1] ?? ''}`
221 continue
222 }
223 found.push({ letter, text: parts[index + 1] ?? '', isMarked: false })
224 }
225 found = found.map(one => ({ ...one, text: one.text.replace(/[\s;,.]+(or|and)?\s*$/i, '').trim(), isMarked: /\(recommended\)/i.test(one.text) }))
226 }
227 }
228 if (found.length < 2) return []
229 const named = recommendationOf(body)
230 const marked = found.filter(one => one.isMarked)
231 const saying = found.filter(one => /\brecommended\b/i.test(one.text))
232 const pick = named || (marked.length === 1 ? (marked[0]?.letter ?? '') : saying.length === 1 ? (saying[0]?.letter ?? '') : '')
233 return found.map(one => {
234 const text = plainText(one.text.replace(/\s*\(recommended\)/i, ''))
235 return { letter: one.letter, label: optionLabel(text), text: text.charAt(0).toUpperCase() + text.slice(1), isRecommended: one.letter === pick }
236 })
237}
238
239// The intent template's "**Options:**" line, on its own above its lettered list.
240const OPTIONS_HEAD = /^\s*[-*]?\s*\**\s*options(?:\s+\w+)?\s*\**\s*:\s*\**\s*$/i
241
242// A finding's lines about itself: "Status: …", "**Resolution:** …", "- Resolution (orchestrator, 2026-10-05): …".
243const SELF_LINE = /^[ \t]*[-*]?[ \t]*\**[ \t]*(status|resolution)[ \t]*(?:\([^)\n]*\))?[ \t]*\**[ \t]*:[ \t]*\**[ \t]*(.*)$/gim
244
245// What a filled Resolution says when it settles nothing: "open; reported for …", "open (Q3).",
246// "partly addressed …", "not yet", "pending", "tbd", a template's "<pending owner>", "-".
247const UNSETTLED = /^(open|pending|partly|not yet|tbd)\b|^[<\-—–]/i
248
249// Every finding, open or not. Template heading: "## F-<n> (date, rev r) | blocking: yes|no | status: open (owner)".
250/** @param {string} findings @param {string} prompt */
251const readFindings = (findings, prompt) => {
252 const decisions = section(prompt, 'Decisions')
253 return findings
254 .split(/^(?=##\s+F)/m)
255 .filter(part => /^##\s+F[\w-]*\d/.test(part))
256 .map(part => {
257 const heading = /^##\s+(F[\w-]*)\s*(.*)$/m.exec(part)
258 const id = heading?.[1] ?? 'F?'
259 const rest = heading?.[2] ?? ''
260 const body = part.slice((heading?.[0] ?? '').length)
261 const headStatus = /status:\s*(\w+)(?:\s*\(([^)]*)\))?/i.exec(rest)
262 const headBlocking = /blocking:\s*(yes|no)\b/i.exec(rest)
263 const owner = headStatus?.[2]?.trim() ?? ''
264 const said = [...body.matchAll(SELF_LINE)].map(match => ({ kind: (match[1] ?? '').toLowerCase(), text: (match[2] ?? '').replace(/[*_`]/g, '').trim() })).filter(line => line.text !== '')
265 // A settled Resolution closes it, whatever the heading says; an unsettled one ("pending Director",
266 // often left behind) never reopens a heading that says it is closed ("status: resolved (…)").
267 // Without one, the heading's status, else a closing word in the heading or a Status line, else the Decisions.
268 const resolution = said.filter(line => line.kind === 'resolution').at(-1)
269 const statusLine = said.find(line => line.kind === 'status')
270 const isHeadClosed = headStatus !== null && (headStatus[1] ?? '').toLowerCase() !== 'open'
271 const isClosed = resolution
272 ? !UNSETTLED.test(resolution.text) || isHeadClosed
273 : headStatus
274 ? isHeadClosed
275 : CLOSED.test(rest) || (statusLine !== undefined && CLOSED.test(statusLine.text)) || new RegExp(`\\b${id.replace(/-/g, '\\-')}\\b`).test(decisions)
276 // "(open, not blocking)" and "non-blocking for this change" say it does not block.
277 const isBlocking = headBlocking ? headBlocking[1]?.toLowerCase() === 'yes' : /blocking:\s*yes/i.test(part) || (/\bblocking\b/i.test(rest) && !/\b(non|not)[\s-]+blocking\b/i.test(rest))
278 // Without a decider in the status, the template's Options list (written only when the user decides) or
279 // words naming a director's call make it one; inline "(a) … (b) …" choices are the worker's engineering notes.
280 const isDirectorCall = owner ? /director|producer|design|production|user/i.test(owner) : body.split(/\r?\n/).some(line => OPTIONS_HEAD.test(line)) || /director|design lead|production call/i.test(body)
281 const headTitle = shortTitle(rest.replace(/\|\s*(blocking|status):.*$/i, '').replace(/^\([^)]*\)\s*:?\s*/, '').replace(/^:\s*/, ''))
282 const bodyTitle = shortTitle(body.trim().split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('#')) ?? '')
283 const headFull = fullTitle(rest.replace(/\|\s*(blocking|status):.*$/i, '').replace(/^\([^)]*\)\s*:?\s*/, '').replace(/^:\s*/, ''))
284 const bodyFull = fullTitle(body.trim().split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('#')) ?? '')
285 // `source`: the finding as written (capped), for the pane's finding view.
286 return { id, title: headTitle || bodyTitle || id, full: headFull || bodyFull || id, isBlocking, isDirectorCall, options: parseOptions(body), source: part.trim().slice(0, 6000), isOpen: !isClosed }
287 })
288}
289
290/** @param {ReturnType<typeof readFindings>} all */
291const openOnes = all => all.filter(one => one.isOpen).map(({ isOpen: _open, ...one }) => one)
292
293// Open findings.
294/** @param {string} findings @param {string} prompt */
295export const parseFindings = (findings, prompt) => openOnes(readFindings(findings, prompt))
296
297// ---------------------------------------------------------------- acceptance and PRs
298//
299// One writer per fact (.agents/skills/intent/SKILL.md): prompt.md's Acceptance lists the items
300// ("- A1: ...", or rows "| A1 | ... | <proof> |"), progress.md's Acceptance table says which are met,
301// progress.md's "- PR:" line names the PRs. Intents from before that rule tick "- [x]" boxes in
302// prompt.md instead.
303
304// "A1", "B3", "SL15", "A12a": an acceptance id at the start of an item or a table cell.
305const ITEM_ID = /^\**([A-Z]{1,3}[0-9]+[a-z]?)\**(?=[\s:.(]|$)/
306// A verdict that counts as met: its leading word ("met on main", "Pass", "passed", "done", "✓", "✅").
307const MET = /^(met|pass|passed|done|✓|✔|✅)(?![\p{L}\p{N}])/iu
308
309// The table rows among some lines, each as its cells: "| A1 | Snow look. | Unit. |" → ['A1', 'Snow look.', 'Unit.'].
310/** @param {readonly string[]} lines */
311const tableRows = lines => lines.filter(line => /^\s*\|/.test(line)).map(line => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => cell.trim()))
312
313// progress.md's Acceptance table as id → verdict, or null when it has none (or no rows yet).
314// The verdict column is the one headed Verdict, Status or Result, else the second.
315/** @param {string} progress */
316const acceptanceVerdicts = progress => {
317 const rows = tableRows(section(progress, 'Acceptance').split(/\r?\n/)).map(row => row.map(cell => plainText(cell)))
318 const header = rows[0]
319 if (!header) return null
320 const named = header.findIndex(cell => /^(verdict|status|result)$/i.test(cell))
321 const column = named < 0 ? 1 : named
322 /** @type {Map<string, string>} */
323 const verdicts = new Map()
324 for (const row of rows.slice(1)) {
325 const id = ITEM_ID.exec(row[0] ?? '')?.[1]
326 if (id) verdicts.set(id, row[column] ?? '')
327 }
328 return verdicts.size > 0 ? verdicts : null
329}
330
331/** @typedef {{ id: string, text: string, isDone: boolean }} AcceptanceItem `text` is what follows the id ('' id: a legacy box without one) */
332
333// "A2 (owed): Sand look." → { id: 'A2', text: '(owed): Sand look.' }
334/** @param {string} line */
335const splitItem = line => {
336 const match = ITEM_ID.exec(line)
337 return { id: match?.[1] ?? '', text: match ? line.slice(match[0].length).trim() : line }
338}
339
340// The acceptance items and which are done. Ids come from prompt.md's top-level items, or, in a
341// section with no list, from its table's rows (the id in the first cell, the text in the second);
342// each Acceptance section is read on its own, so a table in one and a list in a later one both
343// count. Met-ness comes from progress.md's table, whose rows for ids prompt.md does not list are
344// ignored. Without that table, legacy "- [x]" boxes count as before; without either, every listed
345// item is open.
346/** @param {string} prompt @param {string} progress @returns {AcceptanceItem[]} */
347export const acceptanceItems = (prompt, progress) => {
348 /** @type {Map<string, string>} */
349 const listed = new Map()
350 // What counts without a verdict table once any section has boxes: the boxes, and the rows of the sections written as tables.
351 /** @type {AcceptanceItem[]} */
352 const legacy = []
353 let hasBoxes = false
354 for (const part of sections(prompt, 'Acceptance')) {
355 const lines = part.split(/\r?\n/)
356 const items = lines.map(line => splitItem(/^-\s+(?:\[[ xX]\]\s*)?(.+)$/.exec(line)?.[1] ?? '')).filter(item => item.id !== '')
357 const boxes = lines.flatMap(line => {
358 const box = /^\s*-\s*\[( |x|X)\]\s*(.*)$/.exec(line)
359 return box ? [{ ...splitItem(box[2] ?? ''), isDone: box[1] !== ' ' }] : []
360 })
361 const isTable = items.length === 0 && boxes.length === 0
362 const rows = isTable ? tableRows(lines).map(row => ({ id: ITEM_ID.exec(row[0] ?? '')?.[1] ?? '', text: row[1] ?? '' })).filter(item => item.id !== '') : items
363 for (const item of rows) {
364 if (listed.has(item.id)) continue
365 listed.set(item.id, item.text)
366 if (isTable) legacy.push({ ...item, isDone: false })
367 }
368 legacy.push(...boxes)
369 hasBoxes ||= boxes.length > 0
370 }
371 const verdicts = acceptanceVerdicts(progress)
372 if (verdicts && listed.size > 0) return [...listed].map(([id, text]) => ({ id, text, isDone: MET.test(verdicts.get(id) ?? '') }))
373 if (hasBoxes) return legacy
374 return [...listed].map(([id, text]) => ({ id, text, isDone: false }))
375}
376
377// The text before a file's first "## " heading: where its "- Field:" lines live.
378/** @param {string} text */
379const headerOf = text => text.split(/^##\s/m)[0] ?? ''
380
381// The PR numbers on progress.md's "- PR:" line, then any on a legacy prompt.md one.
382// "- PR: #32372, #32398", "- PRs: sipherxyz/s2#1", "- PR: none yet".
383/** @param {string} progress @param {string} prompt @returns {number[]} */
384export const intentPrs = (progress, prompt) => {
385 const numbers = [progress, prompt].flatMap(text =>
386 [...headerOf(text).matchAll(/^\s*-\s*PRs?\s*:\s*(.+)$/gim)].flatMap(line => [...(line[1] ?? '').matchAll(/#(\d+)|pull\/(\d+)/g)].map(match => Number(match[1] ?? match[2]))),
387 )
388 return [...new Set(numbers)]
389}
390
391/**
392 * @typedef {'MERGED' | 'OPEN' | 'CLOSED' | 'UNREAD'} PrState what gh last said about a PR; UNREAD when it could not say
393 * @typedef {Readonly<Record<string, PrState>>} PrStates PR number (prKey) → its last read state
394 */
395
396// Open, with a checklist whose every item is met: the only intents whose PRs decide anything.
397/** @param {Intent} intent */
398export const isAllMet = intent => intent.status !== 'completed' && intent.acceptanceTotal > 0 && intent.acceptanceDone === intent.acceptanceTotal
399
400// Every item met and every named PR merged, yet not closed: the orchestrator's Close step is owed.
401// An intent with no PR named is not ready: nothing says the work has landed.
402/** @param {Intent} intent @param {PrStates} prs */
403export const isReadyToClose = (intent, prs) => isAllMet(intent) && intent.prs.length > 0 && intent.prs.every(number => prs[prKey(intent, number)] === 'MERGED')
404
405// "#32372 MERGED", "#32398 not read yet": each PR the intent names, with what gh last said.
406/** @param {Intent} intent @param {PrStates} prs */
407export const prStatusList = (intent, prs) =>
408 intent.prs.map(number => {
409 const state = prs[prKey(intent, number)]
410 return `#${number} ${state === 'UNREAD' ? 'could not be read' : (state ?? 'not read yet')}`
411 })
412
413// Where an intent's PR is in a PrStates that holds several checkouts' PRs: its number for the session's
414// own checkout, `<root>#<number>` for another (the same number names another PR in another repository).
415/** @param {{ slug: string, key: string, root: string }} intent @param {number} number */
416export const prKey = (intent, number) => (otherRoot(intent) ? `${otherRoot(intent)}#${number}` : String(number))
417
418/**
419 * @typedef {{ slug: string, prompt: string, findings: string, progress: string, files: readonly string[], hasDebrief: boolean, updatedAt: number, source: 'main' | 'local', firstAuthor: string,
420 * key?: string, root?: string, repo?: string, repoName?: string, base?: string }} IntentFiles
421 * `updatedAt`: when it last changed (its last commit on main, or its files'); `source`: where it was read; `firstAuthor`: who first committed its folder.
422 * Read from one of several checkouts: `key` names it in the pane (its slug in the session's own checkout,
423 * `<repoName>/<slug>` in another), `root` is the checkout holding it, `repo` its repository's id ('' unknown), `repoName` the short name of its repository,
424 * `base` the branch that checkout's team merges into (main unless said).
425 * @typedef {ReturnType<typeof parseIntent>} Intent
426 */
427
428// A "- Current step:" or "- Next step:" line of progress.md, short; '' for none, a template's "<none>" or a dash.
429/** @param {string} text */
430const stepNote = text => (/^(<|none\b|n\/a\b|[-–—]\s*$)/i.test(text) ? '' : shortTitle(text, 110))
431
432/** @param {IntentFiles} input @param {Pack} [pack] */
433export const parseIntent = (input, pack = unreal) => {
434 const { prompt, progress } = input
435 // "parked: weather presets merged…" is a status too: take the leading word.
436 const status = /^[a-z]+/.exec(field(prompt, 'Status').toLowerCase())?.[0] ?? 'unknown'
437 const items = acceptanceItems(prompt, progress)
438 const findings = readFindings(input.findings, prompt)
439 return {
440 slug: input.slug,
441 title: /^#\s+(.+)$/m.exec(prompt)?.[1]?.trim() ?? input.slug,
442 goal: shortTitle(/^(.+?[.!?])(\s|$)/.exec(section(prompt, 'Goal').replace(/\s+/g, ' ').trim())?.[1] ?? section(prompt, 'Goal').replace(/\s+/g, ' ').trim(), 110),
443 area: normalizeArea(field(prompt, 'Area'), pack),
444 owner: field(prompt, 'Owner'),
445 issue: issueNumber(field(prompt, 'Issue')),
446 // The branch its work is on ("- Branch: `intent/login`"); '' for none or a template's placeholder.
447 branch: branchNamed(field(prompt, 'Branch').replace(/`/g, '')),
448 status,
449 // Why it is parked (or blocked): what follows the status word.
450 statusNote: field(prompt, 'Status').replace(/^[a-z]+\s*[:\-–—]?\s*/i, '').trim(),
451 acceptanceDone: items.filter(item => item.isDone).length,
452 acceptanceTotal: items.length,
453 // What the intent's view says of where it stands, with no prompt to the session: the items not yet
454 // met ("A2 (owed): Sand look." and "A2: (owed) Sand look." both read "Sand look"), and progress.md's own
455 // lines for the step in hand and the one after.
456 openItems: items.filter(item => !item.isDone).map(item => ({ id: item.id, text: shortTitle(item.text.replace(/^[\s:]*(?:\([^)]*\)[\s:]*)?/, ''), 90) })),
457 currentStep: stepNote(field(progress, 'Current step')),
458 thenStep: stepNote(field(progress, 'Next step')),
459 prs: intentPrs(progress, prompt),
460 findings: openOnes(findings),
461 // The ids of its findings that are settled here: another checkout's copy may still say open (waitingCalls).
462 settled: findings.filter(one => !one.isOpen).map(one => one.id),
463 hasReview: input.files.some(name => /review/i.test(name)) || /\b(plan|opus|design)[- ]review\b|reviewed by|after (an? )?(opus )?review/i.test(prompt + progress.slice(0, 20000)),
464 hasWorker: /^\s*[-*]?\s*\**worker\**\s*[:=-]\s*\S/im.test(progress) || /^(###\s+S\d+|-\s+S\d+\b)/m.test(progress),
465 hasDebrief: input.hasDebrief,
466 updatedAt: input.updatedAt,
467 source: input.source,
468 firstAuthor: input.firstAuthor,
469 key: input.key ?? input.slug,
470 root: input.root ?? '',
471 repo: input.repo ?? '',
472 repoName: input.repoName ?? '',
473 base: input.base ?? 'main',
474 }
475}
476
477// The checkout an intent lives in when it is not the session's own (its key is not its slug), else ''.
478/** @param {{ slug: string, key: string, root: string }} intent */
479export const otherRoot = intent => (intent.key === intent.slug ? '' : intent.root)
480
481/** @param {Intent | undefined} intent */
482export const directorCalls = intent => (intent && intent.status !== 'completed' ? intent.findings.filter(one => one.isDirectorCall || one.isBlocking) : [])
483
484// The calls that wait, each once however many checkouts hold its intent. Copies of one intent (one repository,
485// one slug: each worktree whose branch changed it shows its own) share their findings: one settled in any copy
486// waits in none, and the rest are offered from the tracked copy, else the session's own checkout's, else the
487// freshest (`intents` comes freshest first).
488/** @param {readonly Intent[]} intents @param {string | null} pinned @returns {{ intent: Intent, finding: Intent['findings'][number] }[]} */
489export const waitingCalls = (intents, pinned) => {
490 const copyOf = (/** @type {Intent} */ one) => `${one.repo || one.root}|${one.slug}`
491 const settled = new Set(intents.flatMap(one => one.settled.map(id => `${copyOf(one)}|${id}`)))
492 const rank = (/** @type {Intent} */ one) => (one.key === pinned ? 0 : otherRoot(one) === '' ? 1 : 2)
493 /** @type {Map<string, { intent: Intent, finding: Intent['findings'][number] }>} */
494 const waiting = new Map()
495 for (const intent of [...intents].sort((a, b) => rank(a) - rank(b))) {
496 for (const finding of directorCalls(intent)) {
497 const id = `${copyOf(intent)}|${finding.id}`
498 if (!settled.has(id) && !waiting.has(id)) waiting.set(id, { intent, finding })
499 }
500 }
501 return [...waiting.values()]
502}
503
504/** @param {{ owner: string }} intent @param {string} me */
505export const isMine = (intent, me) => me !== '' && isSamePerson(intent.owner, me)
506
507// The open intents that are yours, the tracked one (by key) first.
508/** @param {readonly Intent[]} intents @param {string} me @param {string | null} pinned */
509export const ownedIntents = (intents, me, pinned) => {
510 const mine = intents.filter(one => one.status !== 'completed' && isMine(one, me))
511 return [...mine.filter(one => one.key === pinned), ...mine.filter(one => one.key !== pinned)]
512}
513
514// The Owner line of an intent's prompt.md.
515/** @param {string} prompt */
516export const intentOwner = prompt => field(prompt, 'Owner')
517
518// Who to offer first when picking: yours, then your area, then open decisions, then the most recent.
519/** @param {readonly Intent[]} intents @param {string} me @param {string} area */
520export const pickCandidates = (intents, me, area) => {
521 const rank = (/** @type {Intent} */ one) => (me !== '' && isSamePerson(one.owner, me) ? 0 : 4) + (area !== '' && one.area !== area ? 2 : 0) + (directorCalls(one).length > 0 ? 0 : 1)
522 return intents.filter(one => one.status === 'active' || one.status === 'parked').sort((a, b) => rank(a) - rank(b) || b.updatedAt - a.updatedAt)
523}
524
525/** @param {readonly Intent[]} intents @param {string} text */
526export const searchIntents = (intents, text) => {
527 const words = text.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length >= 3)
528 return intents.filter(one => {
529 const hay = `${one.key} ${one.title} ${one.area} ${one.owner}`.toLowerCase().split(/[^a-z0-9]+/)
530 return one.status !== 'completed' && words.length > 0 && words.every(word => hay.some(part => part.startsWith(word)))
531 })
532}
533
534/** @param {Intent} one @param {string} me @param {PrStates} [prs] */
535export const intentLabel = (one, me, prs = {}) => {
536 const mine = isMine(one, me)
537 const calls = mine ? directorCalls(one).length : 0
538 const progress = one.acceptanceTotal > 0 ? `${one.acceptanceDone}/${one.acceptanceTotal}${isReadyToClose(one, prs) ? ' · ready to close' : ''}` : 'no checklist'
539 return `${one.slug} · ${one.area} · ${progress}${calls > 0 ? ` · ${calls} need${calls === 1 ? 's' : ''} you` : ''}${!mine && one.owner ? ` · ${one.owner}` : ''}${one.status === 'parked' ? ` · parked${one.statusNote ? `: ${shortTitle(one.statusNote, 90)}` : ''}` : ''}`
540}
541
542// ---------------------------------------------------------------- time and the Editor lock
543
544/** @param {number} ms @param {number} tz */
545export const localMinutes = (ms, tz) => {
546 const local = new Date(ms + tz * 60000)
547 return local.getUTCHours() * 60 + local.getUTCMinutes()
548}
549
550/** @param {number} ms @param {number} tz */
551export const clockText = (ms, tz) => {
552 const minutes = localMinutes(ms, tz)
553 return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
554}
555
556// A prompt the person sent: typed at the terminal or the desktop (`composer`), or from a phone over Remote Control
557// (`bridge`). Not a schedule, a wakeup, a task's notice, another session, or a plugin's own prompt (an away window's work).
558/** @param {{ kind: string } | undefined} origin */
559export const isPersonOrigin = origin => origin?.kind === 'composer' || origin?.kind === 'bridge'
560
561/** @param {number} ms */
562export const durationText = ms => {
563 const total = Math.max(0, Math.round(ms / 60000))
564 return total >= 60 ? `${Math.floor(total / 60)}h${String(total % 60).padStart(2, '0')}m` : `${total}m`
565}
566
567/** @param {string} text */
568export const parseTzOffset = text => {
569 const match = /([+-])(\d{2}):?(\d{2})/.exec(text.trim())
570 return match ? (match[1] === '-' ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3])) : null
571}
572
573// Evening by the person's clock: when "Heading off?" is offered unasked.
574/** @param {number} ms @param {number} tz */
575export const isEvening = (ms, tz) => {
576 const hour = Math.floor(localMinutes(ms, tz) / 60)
577 return hour >= 20 || hour < 5
578}
579
580/** @typedef {import('./packs/unreal.mjs').EditorLock} EditorLock */
581
582// A session's name from Claude Code's record of it (the lines a grep for its titles found):
583// the last title the person or the session set, else the last one Claude Code generated.
584/** @param {string} lines @returns {string} */
585export const sessionTitle = lines => {
586 const rows = lines.split(/\r?\n/)
587 /** @param {string} kind */
588 const last = kind => {
589 for (let index = rows.length - 1; index >= 0; index -= 1) {
590 const found = new RegExp(`"${kind}":"([^"]*)"`).exec(rows[index] ?? '')
591 if (found) return found[1] ?? ''
592 }
593 return ''
594 }
595 const raw = last('customTitle') || last('aiTitle')
596 let title = raw
597 try {
598 title = JSON.parse(`"${raw}"`)
599 } catch {
600 // an escape grep cut in half: the raw text is close enough
601 }
602 return title.replace(/[\u0000-\u001f]+/g, ' ').trim().slice(0, 60)
603}
604
605// ---------------------------------------------------------------- stages and the next step
606
607/** @typedef {import('./packs/index.mjs').Rung} Rung */
608/** @typedef {Record<string, Rung>} Evidence the pack's rungs; the Unreal pack's are build, automation, readback, pie and editor */
609
610/** @param {Pack} [pack] @returns {Evidence} */
611export const emptyEvidence = (pack = unreal) => pack.emptyEvidence()
612
613// A Status of completed wins; then every item met with every PR merged is ready to close, whatever proof this session saw.
614/** @param {Intent | undefined} intent @param {Evidence} evidence @param {string} role @param {PrStates} [prs] @param {Pack} [pack] @returns {keyof typeof STAGE_LABELS} */
615export const currentStage = (intent, evidence, role, prs = {}, pack = unreal) => {
616 if (intent?.status === 'completed') return intent.hasDebrief ? 'shipped' : 'ship'
617 if (!intent || intent.acceptanceTotal === 0) return 'plan'
618 if (isReadyToClose(intent, prs)) return 'close'
619 if (intent.acceptanceDone < intent.acceptanceTotal) return 'build'
620 return pack.isProven(evidence, role) ? 'ship' : 'prove'
621}
622
623// Asking the session what an intent is and where it stands, changing nothing. One that this checkout
624// does not have (or has as it is on main) is read from origin/main itself, read-only. `root`: the checkout
625// holding it, when that is not the session's own; git and the files are then read there. `base`: the branch
626// its team merges into, read in place of main.
627/** @param {string} slug @param {boolean} fromMain @param {string} [root] @param {string} [base] */
628export const aboutIntentPrompt = (slug, fromMain, root = '', base = 'main') => {
629 const files = ['prompt.md', 'findings.md', 'progress.md', 'log.md']
630 const git = root ? `git -C ${root}` : 'git'
631 const read = fromMain
632 ? `Read it from GitHub ${base}, since this checkout may not have it or may be behind: use \`${git} show origin/${base}:docs/intent/${slug}/<file>\` for ${files.join(', ')} (those that exist) and \`${git} log -5 --format="%cs %an %s" origin/${base} -- docs/intent/${slug}\` for its recent history, with GIT_OPTIONAL_LOCKS=0. Do not fetch, pull, check out, track it or write anything.`
633 : `Read ${root ? `${root}/` : ''}docs/intent/${slug}/ only; change nothing.`
634 return `Tell me about intent ${slug}${root ? ` in the checkout at ${root}` : ''} in under ten lines: what it is for, who owns it, its status and stage (Plan, Build, Prove, Ship) and why, its checklist progress, which decisions are open and whose they are, and what the next step would be. ${read}`
635}
636
637// Closing an intent, step 5 of the intent skill: what was read (every item met, and the PRs it names
638// merged; none named, the items alone) is confirmed first. `commit`: how the close ends.
639/** @param {string} slug @param {readonly number[]} prs @param {string} [commit] */
640export const closePrompt = (slug, prs, commit = 'commit as the skill says') => {
641 const named = andList(prs.map(number => `#${number}`))
642 const merged = prs.length === 0 ? '' : ` and ${prs.length === 1 ? `PR ${named} as` : `PRs ${named} as`} merged`
643 return `Close intent ${slug} as step 5 (Close) of .agents/skills/intent/SKILL.md says. Ather reads every acceptance row in docs/intent/${slug}/progress.md as met${merged}: confirm ${prs.length === 0 ? 'it from the files' : 'both from the files and gh'} first, and stop and tell me if ${prs.length === 0 ? 'it is' : 'either is'} not so. Then set Status: completed in prompt.md, add the changelog line, and ${commit}.`
644}
645
646// Drafting an intent's "done" checklist, shown before any work starts.
647/** @param {string} slug */
648export const checklistPrompt = slug =>
649 `Draft the acceptance checklist for intent ${slug} in docs/intent/${slug}/prompt.md: items with ids (A1, A2, ...), each with the proof that will show it is done, and no checkboxes (whether an item is met lives in progress.md). Show it to me before the worker starts.`
650
651/**
652 * The one next step for the tracked intent and the person's role.
653 * @param {string} role @param {Intent | undefined} intent @param {Evidence} evidence @param {number} workers @param {string} me @param {PrStates} [prs] @param {Pack} [pack]
654 * `isLook`: a step that only reads where the intent stands. Its files answer it, so the pane shows the
655 * intent's view and nothing goes to the session; `prompt` is for a surface that cannot (the Paseo version).
656 * @returns {{ key: string, label: string, prompt: string, hint: string, isDraft?: boolean, isLook?: boolean } | undefined}
657 */
658export const nextStep = (role, intent, evidence, workers, me, prs = {}, pack = unreal) => {
659 if (!intent) return { key: 'start', label: 'Start an intent', prompt: '/intent ', hint: 'Type what you want after /intent; the intent skill takes it from there.', isDraft: true }
660 const slug = intent.slug
661 if (!isMine(intent, me)) {
662 return { key: 'follow', label: 'See where it stands', hint: `${intent.owner || 'Its owner'}'s intent: where it stands, nothing is changed.`, isLook: true, prompt: aboutIntentPrompt(slug, intent.source === 'main', otherRoot(intent), intent.base) }
663 }
664 const stage = currentStage(intent, evidence, role, prs, pack)
665 if (stage === 'close') {
666 const named = andList(intent.prs.map(number => `#${number}`))
667 return {
668 key: 'close',
669 label: 'Close the intent',
670 hint: `Every item is met and ${intent.prs.length === 1 ? `PR ${named} is` : `PRs ${named} are`} merged; the session closes it the intent skill's way.`,
671 prompt: closePrompt(slug, intent.prs),
672 }
673 }
674 if (stage === 'plan') {
675 return { key: 'checklist', label: 'Write the "done" checklist', hint: 'The session drafts it and shows you before any work starts.', prompt: checklistPrompt(slug) }
676 }
677 if (stage === 'build') {
678 if (!intent.hasReview && !intent.hasWorker && intent.acceptanceDone === 0 && workers === 0) {
679 return { key: 'review', label: 'Get the plan checked', hint: 'A second agent looks for gaps and wrong assumptions before anyone builds.', prompt: `Have an Opus agent review the plan for intent ${slug} (docs/intent/${slug}/prompt.md) against the repository before any worker starts: gaps, risks, wrong assumptions. Fold the accepted findings into the intent and show me what changed.` }
680 }
681 if (intent.hasWorker || workers > 0) {
682 return { key: 'progress', label: 'See how the work is going', hint: 'Where it stands against its checklist.', isLook: true, prompt: `Summarise intent ${slug} against its checklist: what is done with evidence, what is next, what is blocked. Five lines.` }
683 }
684 return { key: 'brief', label: 'Start the work', hint: pack.prompts.briefHint, prompt: pack.prompts.brief(role, slug) }
685 }
686 if (stage === 'prove') {
687 const missing = role === '' ? [pack.anyProofText] : pack.requiredRungs(role).filter(rung => evidence[rung]?.state !== 'pass').map(rung => pack.rungLabels[rung] ?? rung)
688 const prompt = pack.prompts.prove(role, slug)
689 const own = pack.ownCheck && role === pack.ownCheck.role ? pack.ownCheck.proveHint : ''
690 return { key: 'prove', label: 'Prove it works', hint: `Still needed: ${andList(missing)}. Ather reads this from tool output, not from what the session says.${own}`, prompt }
691 }
692 if (stage === 'ship' && intent.status === 'completed') {
693 return {
694 key: 'debrief',
695 label: 'Write up what was learned',
696 hint: 'What was proven, lost and decided, and rules worth keeping.',
697 prompt: `Debrief intent ${slug}. List what was proven and with what evidence, what was lost or overwritten (lost optimisation vs broken feature), every decision taken on my behalf, and the gotchas we hit. Write it to ${pack.debriefPath(slug)}, and propose which recurring gotchas should become a skill or AGENTS.md rule for the owners (${pack.owners}).`,
698 }
699 }
700 if (stage === 'ship') {
701 return { key: 'land', label: 'Ship it', hint: pack.prompts.shipHint(role), prompt: pack.prompts.ship(role, slug) }
702 }
703 return undefined
704}
705