SLOPSHOPPER

ather-lanes

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…

newpaneguardcommandstatustool
v0.2.1no licenseupdated 2026-10-26AskTinNguyen/ather-mods/ather-lanes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ather-lanes
│ ┃ LANES ✕ › fix the failing auth test and add an audit log call │ ┃ L A N E S r: │ ┃ This session is not in a git checkout, so ⏺ Read(src/auth.ts) │ ┃ there are no lanes to read. Open it in an S2 ⎿ Read 6 lines │ ┃ checkout or a lane. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Nothing needs you. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▸ all 0 lanes · pool 0 ready, 0 building · E │ ┃ enter opens a lane or th ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lanes │ ⎿ ather-lanes: Lanes opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · LANES
L A N E S r: not read yet This session is not in a git checkout, so there are no lanes to read. Open it in an S2 checkout or a lane. Nothing needs you. ▸ all 0 lanes · pool 0 ready, 0 building · Editor free enter opens a lane or the fold · r ↻
README

Ather Lanes

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.

In 30 seconds

  • /lanes opens the pane. In a terminal it sits beside the chat; on the desktop it opens in the side panel.
  • For you: every decision waiting for you (an open finding of a lane's intent that needs the director), one line each, under its lane's name. The lane whose decision has waited longest comes first.
  • A look: one line per lane in trouble, with its reasons, worst first: its PR's checks are failing; it is 25 or more commits behind main; it holds a slot past that slot's end; it needs a slot that another lane holds; it is not registered.
  • The fold: the last line. It gives how many lanes there are, the pool (ready and building), and who holds the Editor and for how long. Press it (enter in a terminal) to list every lane, one line each, then the held slots, who holds each, and who waits for it.
  • Press a lane to open it in place: its decisions, its PR (with a link), how far it is behind or ahead of 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.
  • With nothing waiting and no trouble, the pane says Nothing needs you. above the fold.
  • r (or ↻) reads again now.
  • The status line under the prompt names this session's lane and what waits, for example 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.
  • Ask Claude: "which lane am I in?" calls the 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's lane is marked 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).

Which lane am I in?

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.

Install

claude plugin install ather-lanes@ather --scope user

Then type /lanes in a session opened in an S2 checkout or one of its lanes.

Where it reads, and when

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:

EveryWhatHow
30 sthe 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 rlanes.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 minone 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 minutesone 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.

Settings

Two settings, both empty by default; /config lists them under Ather Lanes:

  • scriptsDir: the folder holding 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: passed to Status as -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.

Changes

  • 0.2.1 The store's two caches (the last full read, gh's answer) go through 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.
  • 0.2.0 The status line (this session's lane, For you and A look's counts), kept fresh from files while the pane is closed, never a process; the lanes_summary and lanes_open tools; this session's lane marked in the pane and listed after lane 0 in the opened fold.
  • 0.1.0 First release: For you, A look, the fold and every lane opened in place, from lanes.ps1 -Action Status -Json, the slot and heartbeat files, one for-each-ref and one gh call.

Maintainers

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.

Source 25 files
hooks/index.mjs 418 lines
1// @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}
418
hooks/ather/packs/unreal.mjs 350 lines
1// @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}
350
hooks/ather/team.mjs 312 lines
1// @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 : '')
312
hooks/ather/workspace.mjs 189 lines
1// @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}
189
hooks/board.mjs 229 lines
1// @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}
229
hooks/lanes-store.mjs 63 lines
1// @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 })
63
hooks/pane.mjs 220 lines
1// @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)
220
hooks/records.mjs 184 lines
1// @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}
184
hooks/sources.mjs 210 lines
1// @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']
210
hooks/ather/shell.mjs 95 lines
1// @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()
95
hooks/ather/packs/index.mjs 129 lines
1// @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 }
129
hooks/ather/model.mjs 705 lines
1// @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