Ather Board: the team's intents as a project board. /board shows what is stuck, every intent by owner, area or stage, the open acceptance items and each…

The team's intents as a project board that helps you act: what is yours to do now, what is stuck team-wide, where everything stands, how intents map onto the game's modules, and each person's and area's load. It reads every intent on origin/main (and the checkout's own drafts) the way Ather Automata does. It changes nothing itself: every action is handed to the session, which asks you before it writes, commits or messages anyone.
/board opens the board in the session on For you: what is yours to act on, and Sonnet's suggestions for you and the team. Triage, Modules, Table, Open items, People and Areas are a tab away./board ask <question> does the same./board open writes the full page (the brief, a kanban by stage, the Map of intents on the game's modules, the weekly pulse, every view with filters) and opens it in your browser./board publish writes the same page and has the session publish it to the team's shared site, for leads and producers who do not run Claude Code.claude plugin install ather-board@ather --scope user
Then type /board in a session opened in a checkout with intents (a docs/intent folder). Ather Automata is not needed alongside it.
| View | What it answers |
|---|---|
| For you (pane) | What needs you, by your git user.name: your intents waiting on your decision, ready for you to close, stuck, needing a checklist or a tidy header; then the rest of your open work. Above it, Suggested: Sonnet's brief (a headline and up to five suggestions, each naming real intents and one action) and the board's own (close your ready ones together, answer your oldest decision, the teammates' intents waiting on a decision). A suggestion is a press. |
| Modules (pane), Map (page) | The intents on the game's core modules: S2's main character locomotion, combat, nine-tail abilities and look; enemy locomotion, AI and abilities; bosses; quests; environment presets and levels; VFX, cinematics, audio, UI, performance and tooling. The pane groups intents by module; the page draws the network, modules as hubs and intents as dots (lime where someone is needed, bigger with more items open, dashed lines where one intent names another). |
| Triage | What needs someone, team-wide, grouped by reason, the worst first: decisions waiting on an owner or director, ready to close (every item met, not closed), no change for 5 days or more, parked without a reason, no acceptance checklist. Each intent sits under its worst reason; its row names any others. Below it, Data hygiene: intents whose files the board could not fully read (a Status that is a sentence or another word than active, parked or completed; no Owner line; an area outside the list). |
| Table | Every intent, grouped by owner, area, stage or not at all, each group with a one-line rollup ("4 intents · 3 done · 6 open items · 2d"). Sort by recent, stalest, closest to done or most open items. Filter by words, area, owner and stage; done intents are hidden unless shown. |
| Open items | Every acceptance item not yet met, of every intent not done, grouped by area or owner, the biggest pile first. Parked intents' items count: they are still owed. |
| People, Areas | One line each: open, parked and done intents, open items, decisions waiting, stale intents, last change. Pick one to see its intents in the table. |
| Board (page) | A kanban, a column for each stage that has intents: Plan, Building, Proving, Ready to close, Parked, Done. |
| Pulse (page) | Intents opened and closed per week, from the first week anything happened, and how many are in each stage. |
Every row reads the same on both surfaces, as Ather's list does: stage glyph, name and its warnings (decisions waiting, stale, parked without a reason), the checklist bar (▰▱) and count, age, owner. Both use Ather's look: lime only for what needs someone, amber for a warning, ink and quiet grey for the rest; the page in light and dark.
Pressing an intent in the session opens it in place with what can be done about it, its next action first. On your own intents that is answering its decisions (each option a button, Ather's own decision prompt behind it), closing it, unsticking it, giving a parked one its reason, writing its checklist or fixing its header; on a teammate's, asking the owner (a drafted message, never sent) or where it stands. Each press hands the session one prompt; nothing changes until you agree there. On the page, clicking one opens its goal, open items, PRs and issue in place. The page keeps its view and filters in the address, so a link to "Table, VFX, stalest first" opens just that.
Stages are the ones Ather's list uses: Building while an item is open, Proving once every item is met and its PRs are not merged, Ready to close once every item is met with no PR named, Plan with no checklist, Parked, and Done for a Status of completed. The board does not ask GitHub about PRs.
The brief, the ask box and the module map use Sonnet through the mod's own model calls. The brief runs once when the board opens and again only when origin/main moves, or on ↻. Each intent's modules are asked once and kept (in the mod's store, per repository) until the intent's title, area or goal changes, or the list of modules does; about 150 intents take three requests the first time and none after. Every reply is checked against what the board read: an intent or a module it does not know, or an action it does not have, is dropped.
The modules are S2's list for an Unreal checkout. A repository can name its own in .ather/board.json:
{ "modules": [{ "key": "mc-locomotion", "name": "Main character locomotion", "hint": "movement, sprint, dodge, traversal" }] }
Without either, the modules are the intents' Areas, and no model is asked.
The board reads origin/main (or the branch the team merges into: baseBranch in .ather/profile.json, else the clone's default) through git, without taking git's lock, plus the checkout's own intent folders that are newer. When the pane is drawn it fetches that branch in the background at most every ten minutes (no tags, no automatic gc); f fetches now. The line under the title says how fresh the list is.
/board publish writes the page to <checkout>/<local folder>/board/index.html (in S2, Saved/AtherAutomata/board/index.html) and hands the session one prompt: publish that file through the vesper-sites MCP server, updating the earlier page in place. The page is one self-contained HTML file: no network, the data inside, light and dark, usable on a phone.
Add the studio's Sites MCP once, under that name, with the address the studio shares internally (it is not written here, as this repository is public):
claude mcp add --transport http vesper-sites <Sites MCP address>
Then sign in to it from /mcp in a Claude Code terminal. Without it, the session stops and says so.
/board opens on For you (what is yours to act on, with Sonnet's suggestions), then Triage, Modules, Table, Open items, People and Areas, with filters, sorts and groups; an ask box; intents opened in place with their decisions and next action, every action handed to the session; /board open (the full page with the brief, Board, Map and Pulse); /board publish (through the vesper-sites MCP server).The board reads intents with Ather Automata's own modules, copied into hooks/ather/ by node ather-board/sync.mjs; never edit those copies. After a change to them in ather-automata/hooks/, run the sync; node dev/test-all.mjs fails while the copies differ, and runs the board's unit tests (tests/*.test.mjs). Then claude plugin validate ather-board and claude plugin test ather-board, bump version in .claude-plugin/plugin.json and the repository's .claude-plugin/marketplace.json, and add a line under Changes.
hooks/index.mjs 342 lines1// @ts-check
2// Ather Board: the engine half. /board opens the pane (pane.mjs), /board open writes the page
3// (page.mjs) and opens it in the browser, /board publish writes it and asks the session to publish
4// it through the team's Sites MCP. Read-only: it reads intents with Ather's reader (snapshot.mjs)
5// and fetches origin's base branch the way Ather does (team.mjs: no tags, never the index lock).
6// The pane opens on For you (D8) with the brief (assist.mjs) above it; every action is a prompt handed
7// to the session, which asks before it changes anything (D6).
8// Everything here catches its own failures: the board never fails a command or a drawing.
9
10import { ACTION_LABELS, batchPrompt, decisionPrompt } from './act.mjs'
11import { ask, askState, briefState, clearAsk, mapState, runBrief, runMapping } from './assist.mjs'
12import { modulesFor } from './modules.mjs'
13import { answerLook } from './brief.mjs'
14import { mergeSuggestions, plainSuggestions } from './focus.mjs'
15import { choosePack } from './ather/packs/index.mjs'
16import { GIT_ENV, NO_SYNC, baseOf, canFetchNow, fetchMain, isFetchDue, syncError, syncText } from './ather/team.mjs'
17import { boardPane } from './pane.mjs'
18import { renderPage } from './page.mjs'
19import { EMPTY_BOARD_CACHE, readBoard } from './snapshot.mjs'
20import { DEFAULT_LOOK, GROUP_KEYS, NO_FILTER, SORT_KEYS, pickLook } from './views.mjs'
21
22/**
23 * @typedef {import('claude-code').EngineInterface} Engine
24 * @typedef {import('./snapshot.mjs').Board} Board
25 * @typedef {import('./snapshot.mjs').BoardCache} BoardCache
26 * @typedef {import('./views.mjs').Look} Look
27 * @typedef {import('./ather/team.mjs').Repo} Repo
28 * @typedef {import('./ather/team.mjs').Sync} Sync
29 * @typedef {import('./ather/packs/index.mjs').Pack} Pack
30 * @typedef {{ root: string, isRepo: boolean, pack: Pack, base: string, repoName: string, repoUrl: string, me: string, settings: Record<string, any> | null }} Lane
31 * the checkout the board reads; `me`: its git user.name; `settings`: its .ather/board.json (the modules), null for none
32 */
33
34const PANE_ID = 'board'
35// The board is read again when drawn at most this often, and at once after a fetch moved origin.
36const READ_EVERY_MS = 60 * 1000
37// A prompt goes out after the command hook returns: the engine refuses one queued from inside a hook.
38const AFTER_HOOK_MS = 50
39// How long writing the page waits for the brief and the module map.
40const MODEL_WAIT_MS = 60 * 1000
41// The MCP server the page is published through, as /mcp lists it (README: adding it).
42const SITES_SERVER = 'vesper-sites'
43
44/** @type {Look} */
45let look = { ...DEFAULT_LOOK, view: 'foryou' }
46// The intent opened in place in the pane ('' none).
47let opened = ''
48/** @type {Promise<Lane> | null} */
49let lane = null
50/** @type {Board | null} */
51let board = null
52/** @type {BoardCache} */
53let cache = EMPTY_BOARD_CACHE
54/** @type {Promise<void> | null} */
55let reading = null
56// A forced read asked for while one runs: one more read follows it, so it sees what the fetch brought.
57let isAgain = false
58/** @type {Sync} */
59let sync = NO_SYNC
60// A line under the tabs: what the last press did (the page written, a publish handed over).
61let note = ''
62
63/** @param {Engine} $ @param {string} root @returns {Repo} */
64const repoOf = ($, root) => ({
65 git: (args, { stdin, timeoutMs = 60000 } = {}) =>
66 $.process.run(['git', '-C', root, ...args], { cwd: root, env: GIT_ENV, timeoutMs, ...(stdin === undefined ? {} : { stdin }) }).catch(error => ({ exitCode: -1, stdout: '', stderr: String(error), isAborted: /aborted$/.test(String(error)) })),
67 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null),
68 list: path => $.fs.list(path),
69 mtime: path => $.fs.stat(path).then(stat => stat.mtimeMs, () => 0),
70 real: async folder => (await $.fs.stat(folder, { resolve: true })).realPath ?? folder,
71})
72
73// "git@github.com:sipherxyz/s2.git" or "https://github.com/sipherxyz/s2.git" → its web address; '' for another host.
74/** @param {string} remote */
75export const webOf = remote => {
76 const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote.trim())
77 return match ? `https://github.com/${match[1]}/${match[2]}` : ''
78}
79
80// The checkout the session runs in, its pack and the branch its team merges into. Once per module load;
81// a lookup that failed is tried again next time.
82/** @param {Engine} $ @returns {Promise<Lane>} */
83const laneOf = $ =>
84 (lane ??= (async () => {
85 const start = (await $.session.root()).replace(/\\/g, '/')
86 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: start, env: GIT_ENV, timeoutMs: 10000 }).catch(() => undefined)
87 const isRepo = top?.exitCode === 0
88 const root = isRepo ? (top?.stdout ?? '').trim().replace(/\\/g, '/') : start
89 const io = { read: (/** @type {string} */ path) => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null), exists: (/** @type {string} */ path) => $.fs.exists(path).catch(() => false), list: (/** @type {string} */ path) => $.fs.list(path), sessionId: () => $.session.id() }
90 const chosen = await choosePack(io, root)
91 const remote = isRepo ? await $.process.run(['git', 'config', '--get', 'remote.origin.url'], { cwd: root, env: GIT_ENV, timeoutMs: 10000 }).catch(() => undefined) : undefined
92 const repoUrl = webOf(remote?.stdout ?? '')
93 const user = await $.process.run(['git', 'config', 'user.name'], { cwd: root, env: GIT_ENV, timeoutMs: 10000 }).catch(() => undefined)
94 const settings = await io.read(`${root}/.ather/board.json`).then(text => { try { return text ? JSON.parse(text) : null } catch { return null } })
95 return { root, isRepo, pack: chosen.pack, base: baseOf(chosen.profile, chosen.defaultBranch), repoName: repoUrl.split('/').pop() || root.split('/').pop() || '', repoUrl, me: (user?.stdout ?? '').trim(), settings }
96 })().catch(error => {
97 lane = null
98 throw error
99 }))
100
101/** @param {Engine} $ */
102const hasIntents = async $ => (await $.fs.exists(`${(await laneOf($)).root}/docs/intent`).catch(() => false))
103
104// Reads the board again, one read at a time; `force` skips the minute's wait, and while a read runs
105// asks for one more after it.
106/** @param {Engine} $ @param {boolean} [force] */
107const refresh = ($, force = false) => {
108 if (reading) {
109 isAgain ||= force
110 return reading
111 }
112 if (!force && board && Date.now() - board.readAt < READ_EVERY_MS) return Promise.resolve()
113 reading = (async () => {
114 try {
115 const at = await laneOf($)
116 do {
117 isAgain = false
118 const read = await readBoard(repoOf($, at.root), at.root, { pack: at.pack, base: at.base, cache, now: Date.now() })
119 board = read.board
120 cache = read.cache
121 sync = { ...sync, isRepo: read.board.isRepo, hasMain: read.board.sha !== '', base: at.base }
122 } while (isAgain)
123 } catch (error) {
124 $.ui.log(`Ather Board: reading the intents failed: ${String(error)}`, { to: 'debug' })
125 } finally {
126 reading = null
127 $.ui.invalidate('ui.render')
128 }
129 })()
130 return reading
131}
132
133// Fetches origin's base in the background when due (or at once for ↻), then reads the board again.
134/** @param {Engine} $ @param {boolean} [now] */
135const syncMain = async ($, now = false) => {
136 const at = await laneOf($)
137 if (!(now ? canFetchNow(sync, Date.now()) : isFetchDue(sync, Date.now()))) return
138 sync = { ...sync, isFetching: true, triedAt: Date.now() }
139 $.ui.invalidate('ui.render')
140 const fetched = await fetchMain(repoOf($, at.root), at.base).catch(error => ({ error: String(error), lock: '', moved: false }))
141 const time = Date.now()
142 sync = fetched.error ? { ...sync, isFetching: false, failedAt: time, error: fetched.error, lock: fetched.lock } : { ...sync, isFetching: false, fetchedAt: time, error: '', lock: '' }
143 if (fetched.moved) await refresh($, true)
144 else $.ui.invalidate('ui.render')
145}
146
147// The page, written where the pack keeps local files (S2: Saved/AtherAutomata/board/index.html).
148/** @param {Engine} $ @returns {Promise<string>} */
149const writePage = async $ => {
150 const at = await laneOf($)
151 await refresh($, true)
152 if (!board) throw new Error('the intents could not be read')
153 // The page carries the brief and the map: both are waited for, up to a limit (each runs once per read,
154 // so usually done); past it the page has what is in, and the next write has the rest.
155 const io = assistIo($, at.repoName)
156 const set = modulesFor(at.pack.id, at.settings, board.records)
157 await Promise.race([Promise.all([runBrief(io, board, at.me), runMapping(io, board, set)]), new Promise(resolve => $.clock.after(MODEL_WAIT_MS, () => resolve(undefined)))])
158 const [css, client] = await Promise.all([$.fs.read(`${$.plugin.root}/page/board.css`), $.fs.read(`${$.plugin.root}/page/client.js`)])
159 const path = `${at.root}/${at.pack.localDir}/board/index.html`
160 await $.fs.write(path, renderPage(board, { now: Date.now(), repoName: at.repoName, repoUrl: at.repoUrl, css: String(css), client: String(client), brief: briefState().value, briefAt: briefState().at, modules: set.modules, mapping: mapState().value.mapping }))
161 return path
162}
163
164// Opens a file with the system's own handler: `start` on Windows (a drive-letter path), else `open`, then `xdg-open`.
165/** @param {Engine} $ @param {string} path */
166const openFile = async ($, path) => {
167 const tries = /^[A-Za-z]:/.test(path) ? [['cmd', '/c', 'start', '', path.replace(/\//g, '\\')]] : [['open', path], ['xdg-open', path]]
168 for (const argv of tries) {
169 const ran = await $.process.run(argv, { timeoutMs: 15000 }).catch(() => undefined)
170 if (ran?.exitCode === 0) return true
171 }
172 return false
173}
174
175// What the session is asked to do with the written page.
176/** @param {string} path @param {string} repoName */
177export const publishPrompt = (path, repoName) =>
178 `Publish the Ather Board page at ${path} to the team's shared site through the ${SITES_SERVER} MCP server. It is one self-contained HTML file: publish it as it is, without editing it. If a site for "${repoName || 'the'} intents" was published before, update that one in place rather than creating another. If the ${SITES_SERVER} server is not connected, stop and tell me to add it (ather-board's README says how). Reply with the page's address. Change nothing in the repository.`
179
180/** @param {Engine} $ @param {string} text */
181const deliver = ($, text) => $.clock.after(AFTER_HOOK_MS, () => void $.prompt.submit({ text }).catch(error => $.ui.toast(`Ather Board: could not hand that to the session: ${String(error)}`)))
182
183/** @param {Engine} $ */
184const openPage = async $ => {
185 try {
186 const path = await writePage($)
187 note = (await openFile($, path)) ? `Page written and opened: ${path}` : `Page written: ${path} (open it in a browser)`
188 } catch (error) {
189 note = `The page could not be written: ${String(error)}`
190 }
191 $.ui.invalidate('ui.render')
192 return note
193}
194
195/** @param {Engine} $ */
196const publish = async $ => {
197 try {
198 const path = await writePage($)
199 deliver($, publishPrompt(path, (await laneOf($)).repoName))
200 note = `Page written: ${path}. The session publishes it through ${SITES_SERVER}.`
201 } catch (error) {
202 note = `The page could not be written: ${String(error)}`
203 }
204 $.ui.invalidate('ui.render')
205 return note
206}
207
208/** @param {Engine} $ */
209const openPane = async $ => {
210 await $.ui.open({ id: PANE_ID, title: 'ATHER BOARD', focus: true, closeOnEscape: true, rows: 24 })
211 $.ui.invalidate('ui.render')
212}
213
214/** @template {string} T @param {readonly T[]} keys @param {T} current */
215const nextOf = (keys, current) => keys[(keys.indexOf(current) + 1) % keys.length] ?? current
216
217// The model, the redraw and the kept module answers, as the brief, the ask box and the mapping take them
218// (assist.mjs). The answers are kept per repository in the mod's store.
219/** @param {Engine} $ @param {string} repoName @returns {import('./assist.mjs').Io} */
220const assistIo = ($, repoName) => ({
221 load: async () => /** @type {import('./modules.mjs').Kept | null} */ ((await $.store.get(`modules:${repoName}`).catch(() => undefined)) ?? null),
222 save: kept => $.store.set(`modules:${repoName}`, kept).catch(() => undefined),
223 complete: async (request, signal) => {
224 const reply = await $.model.complete(request, { signal })
225 if (reply.isAnswered) return { text: reply.text, error: '' }
226 return { text: '', error: reply.reason === 'api-error' ? `the model call failed (${reply.error}${reply.status ? `, ${reply.status}` : ''})` : `the model gave no answer (${reply.reason})` }
227 },
228 redraw: () => $.ui.invalidate('ui.render'),
229})
230
231// A question to the model, its answer cutting the board (the ask box, /board ask).
232/** @param {Engine} $ @param {string} question */
233const askBoard = async ($, question) => {
234 const at = await laneOf($)
235 if (board) await ask(assistIo($, at.repoName), question, board, at.me, answer => setLook($, answerLook(answer, look.view)))
236}
237
238// Hands the session one prompt and says so under the tabs.
239/** @param {Engine} $ @param {string} prompt @param {string} what */
240const handOver = ($, prompt, what) => {
241 deliver($, prompt)
242 note = `Handed to the session: ${what}. It asks before changing anything.`
243 $.ui.invalidate('ui.render')
244}
245
246/** @param {Engine} $ @param {Partial<Look>} patch */
247const setLook = ($, patch) => {
248 look = { ...look, ...patch }
249 $.ui.invalidate('ui.render')
250}
251
252/** @param {Engine} $ @param {any} e */
253const drawPane = async ($, e) => {
254 const el = $.ui.resolve(e)
255 if (!(await hasIntents($))) return el.Text({ wrap: 'wrap', children: NOT_HERE })
256 void syncMain($)
257 if (!board) await refresh($)
258 else void refresh($)
259 const at = await laneOf($)
260 const now = Date.now()
261 const records = board?.records ?? []
262 // The brief once per read of origin (D7); its suggestions merged with the board's own.
263 const io = assistIo($, at.repoName)
264 const set = modulesFor(at.pack.id, at.settings, records)
265 if (board) {
266 void runBrief(io, board, at.me)
267 void runMapping(io, board, set)
268 }
269 const brief = briefState()
270 const suggestions = mergeSuggestions(brief.value?.suggestions ?? [], plainSuggestions(records, at.me, now))
271 const named = (/** @type {readonly string[]} */ keys) => records.filter(one => keys.includes(one.key))
272 return boardPane(
273 el,
274 { records, look, width: e.props?.bodyColumns ?? 80, isDesktop: e.surface !== 'terminal', now, title: `${at.repoName || 'Team'} intents`, fresh: syncText(sync, now), error: syncError(sync), note, me: at.me, opened, suggestions, brief, asked: askState(), modules: set.modules, mapped: mapState() },
275 {
276 onView: view => () => setLook($, { view }),
277 onFilter: patch => setLook($, { filter: { ...look.filter, ...patch } }),
278 onPick: (by, value) => setLook($, pickLook(look, by, value)),
279 onSort: () => setLook($, { sort: nextOf(SORT_KEYS, look.sort) }),
280 onGroup: () => setLook($, { group: nextOf(GROUP_KEYS, look.group) }),
281 onBy: () => setLook($, { by: look.by === 'area' ? 'owner' : 'area' }),
282 onRow: record => () => {
283 opened = opened === record.key ? '' : record.key
284 $.ui.invalidate('ui.render')
285 },
286 onAction: (kind, list) => handOver($, batchPrompt(kind, list, at.base), `${ACTION_LABELS[kind].toLowerCase()}, ${list.map(one => one.slug).join(', ')}`),
287 onDecide: (record, call, letter) => handOver($, decisionPrompt(record, call, letter), letter ? `${call.id} on ${record.slug}: ${letter}` : `explain ${call.id} on ${record.slug}`),
288 onSuggestion: one => (one.action === 'show' ? setLook($, { view: 'table', filter: { ...NO_FILTER, keys: one.keys } }) : handOver($, batchPrompt(one.action, named(one.keys), at.base), one.title)),
289 onBriefAgain: () => { if (board) void runBrief(io, board, at.me, true) },
290 onMapAgain: () => { if (board) void runMapping(io, board, set, true) },
291 onAsk: question => void askBoard($, question),
292 onClearAsk: () => {
293 clearAsk()
294 setLook($, { filter: NO_FILTER })
295 },
296 onOpenPage: () => void openPage($),
297 onPublish: () => void publish($),
298 onSync: () => void syncMain($, true),
299 },
300 )
301}
302
303const NOT_HERE = 'Ather Board reads a repository’s intents (its docs/intent folder), and this one has none. Open the session in a checkout that has them.'
304const HELP = '/board opens the board, on what is yours to act on. /board ask <question> asks Sonnet and cuts the board to the answer. /board open writes the full page and opens it in your browser. /board publish writes it and has the session publish it to the team’s shared site.'
305
306/** @param {import('claude-code').On} on */
307export function register(on) {
308 on('session.start', async ($, e, next) => {
309 await $.command.register({ name: 'board', description: 'The team’s intents as a board: triage, table, open items, people and areas', argumentHint: '[open | publish | ask <question>]' }).catch(error => $.ui.log(`Ather Board: /board not registered: ${String(error)}`, { to: 'debug' }))
310 return next(e)
311 })
312
313 on('command.run', { command: 'board' }, async ($, e) => {
314 try {
315 if (!(await hasIntents($))) return { text: NOT_HERE }
316 const word = e.args.trim().toLowerCase()
317 if (word === 'open') return { text: await openPage($) }
318 if (word === 'publish') return { text: await publish($) }
319 if (word.startsWith('ask ')) {
320 await openPane($)
321 await refresh($)
322 void askBoard($, e.args.trim().slice(4))
323 return { text: 'Asked Sonnet; the answer shows on the board.' }
324 }
325 if (word !== '') return { text: HELP }
326 await openPane($)
327 return { text: 'Board opened.' }
328 } catch (error) {
329 return { text: `Ather Board: ${String(error)}` }
330 }
331 })
332
333 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
334 try {
335 return await drawPane($, e)
336 } catch (error) {
337 $.ui.log(`Ather Board: the pane failed to draw: ${String(error)}`, { to: 'debug' })
338 return $.ui.resolve(e).Text({ children: 'Ather Board could not draw. /board tries again.' })
339 }
340 })
341}
342hooks/act.mjs 111 lines1// @ts-check
2// Ather Board: what can be done about an intent from the board, and what each press hands the session.
3// The board never changes anything itself (D6): every action is one prompt the session acts on with its
4// own tools, asking before it writes, commits or messages anyone. Answering a decision uses Ather's own
5// prompts (decide.mjs), so a decision taken here is recorded the intent skill's way, as in /ather.
6// Pure: no `$`.
7
8import { decidePrompt, explainPrompt } from './ather/decide.mjs'
9import { aboutIntentPrompt, andList, checklistPrompt, closePrompt, plural } from './ather/model.mjs'
10import { HYGIENE } from './views.mjs'
11
12/**
13 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
14 * @typedef {import('./views.mjs').Call} Call
15 * @typedef {'decide' | 'close' | 'unstick' | 'reason' | 'checklist' | 'tidy' | 'ping' | 'about'} ActionKind
16 */
17
18// What each action's button says.
19export const ACTION_LABELS = /** @type {const} */ ({
20 decide: 'Answer its decisions',
21 close: 'Close it',
22 unstick: 'Unstick it',
23 reason: 'Give a reason',
24 checklist: 'Write the checklist',
25 tidy: 'Fix its header',
26 ping: 'Ask the owner',
27 about: 'Where it stands',
28})
29export const ACTION_KINDS = /** @type {readonly ActionKind[]} */ (Object.keys(ACTION_LABELS))
30
31// The owner's actions, by the flag that calls for each, worst first: an intent's next action is the first
32// whose flag it has. A teammate's intent offers asking its owner instead, when anything needs someone.
33// Only an intent's owner is offered these (OWNER_ACTIONS); the brief turns one suggested for a teammate's into show.
34const OWN = /** @type {const} */ ([
35 ['decisions', 'decide'],
36 ['met', 'close'],
37 ['unparked', 'reason'],
38 ['stale', 'unstick'],
39 ['plan', 'checklist'],
40 ['status', 'tidy'],
41 ['owner', 'tidy'],
42 ['area', 'tidy'],
43])
44
45/**
46 * Every action an intent offers, its next action first and "Where it stands" last. Done intents offer
47 * only that. `isMine`: the person at the keyboard owns it.
48 * @param {BoardRecord} record @param {boolean} isMine @returns {ActionKind[]}
49 */
50export const actionsFor = (record, isMine) => {
51 if (record.stage === 'done') return ['about']
52 const kinds = new Set(record.flags.map(flag => flag.kind))
53 /** @type {ActionKind[]} */
54 const offered = isMine ? OWN.filter(([flag]) => kinds.has(flag)).map(([, action]) => action) : record.flags.some(flag => !HYGIENE.includes(flag.kind)) ? ['ping'] : []
55 return [...new Set([...offered, /** @type {ActionKind} */ ('about')])]
56}
57
58export const OWNER_ACTIONS = /** @type {readonly ActionKind[]} */ ([...new Set(OWN.map(([, action]) => action))])
59
60/** @param {BoardRecord} record @param {boolean} isMine */
61export const nextAction = (record, isMine) => actionsFor(record, isMine)[0] ?? 'about'
62
63// Reading an intent wherever it is: this checkout's copy, else origin's, never changing either.
64/** @param {string} slug @param {string} base */
65const readIt = (slug, base) => `Read intent ${slug} from docs/intent/${slug}/; if this checkout lacks it or is behind, read it from origin/${base} with git show (GIT_OPTIONAL_LOCKS=0), without fetching or checking anything out.`
66
67// What every writing prompt asks first: the checkout's copy is the one to change.
68/** @param {string} slug @param {string} base */
69const inCheckout = (slug, base) => `Work in docs/intent/${slug}/ of this checkout; if it is missing or behind origin/${base}, say so and stop before changing anything.`
70
71/**
72 * The one prompt an action hands the session. `base`: the branch the team merges into.
73 * @param {ActionKind} kind @param {BoardRecord} record @param {string} base
74 */
75export const actionPrompt = (kind, record, base) => {
76 const { slug } = record
77 const flags = record.flags.map(flag => flag.label)
78 switch (kind) {
79 case 'decide':
80 return `Walk me through the ${plural(record.calls.length, 'open decision')} on intent ${slug} (${record.calls.map(call => call.id).join(', ')}), one at a time: what each is about, its options and your recommendation. Record each answer I give the intent skill's way (.agents/skills/intent/SKILL.md, the decision step). ${inCheckout(slug, base)}`
81 case 'close':
82 return `${closePrompt(slug, record.prs, 'show me the diff before committing as the skill says')} ${inCheckout(slug, base)}`
83 case 'unstick':
84 return `Intent ${slug} has had no change for a while. ${readIt(slug, base)} Tell me, in under eight lines, what it is waiting on and the one next step that would move it, then offer to take that step. Change nothing until I say.`
85 case 'reason':
86 return `Intent ${slug} is parked with no reason on its Status line. Read its files and propose a one-line reason (what it waits on, or why it stopped) as "Status: parked: <reason>"; show me the line and edit prompt.md only once I agree. ${inCheckout(slug, base)}`
87 case 'checklist':
88 return `${checklistPrompt(slug)} ${inCheckout(slug, base)}`
89 case 'tidy':
90 return `The header of intent ${slug} does not read cleanly: ${andList(record.flags.filter(flag => HYGIENE.includes(flag.kind)).map(flag => flag.label))}. Propose the fix the intent skill's way (Status one word: active, parked or completed, a parked reason after it; an Owner line naming a person; an Area from docs/intent/README.md), show me the diff, and change nothing until I agree. ${inCheckout(slug, base)}`
91 case 'ping':
92 return `Draft a short, friendly message to ${record.owner || 'the owner'} about their intent ${slug}: ${flags.length ? andList(flags) : 'where it stands'}. ${readIt(slug, base)} Name the one thing that would help. Show me the draft; do not send it.`
93 case 'about':
94 return aboutIntentPrompt(slug, record.source === 'main', '', base)
95 }
96}
97
98// One action over several intents (closing every ready one, say): each one's own prompt, in turn.
99/** @param {ActionKind} kind @param {readonly BoardRecord[]} records @param {string} base */
100export const batchPrompt = (kind, records, base) =>
101 records.length === 1 && records[0]
102 ? actionPrompt(kind, records[0], base)
103 : [`Take these ${records.length} intents one at a time, finishing each before the next:`, ...records.map((one, index) => `${index + 1}. ${actionPrompt(kind, one, base)}`)].join('\n')
104
105// A decision answered in place: an option chosen, or Explain. Ather's own wording (decide.mjs).
106/** @param {BoardRecord} record @param {Call} call @param {string} letter '' explains it */
107export const decisionPrompt = (record, call, letter) => {
108 const option = call.options.find(one => one.letter === letter)
109 return option ? decidePrompt(record.slug, call.id, `${option.letter} — ${option.text}`) : explainPrompt(record.slug, call.id)
110}
111hooks/assist.mjs 147 lines1// @ts-check
2// Ather Board: the engine half of the model's help (brief.mjs and modules.mjs build the requests and check
3// the replies). Three jobs, each run by one `latestJob`: the brief, once per read of origin/<base> (its
4// commit, or the local folders when there is none) and again on ↻ (D7); a question, when asked; and the
5// module mapping, once per read and list of modules. A newer run of a job cancels the older; a failed run
6// says why and keeps what the job last had. The engine comes in as closures (Io), built in index.mjs
7// where `$` lives.
8
9import { askRequest, briefRequest, parseAnswer, parseBrief } from './brief.mjs'
10import { BATCH, keep, listKey, mappingOf, mappingRequest, parseMapping } from './modules.mjs'
11
12/**
13 * @typedef {{
14 * complete: (request: { model: string, system: string, prompt: string, maxTokens: number }, signal: AbortSignal) => Promise<{ text: string, error: string }>,
15 * redraw: () => void, load: () => Promise<import('./modules.mjs').Kept | null>, save: (kept: import('./modules.mjs').Kept) => Promise<void>,
16 * }} Io `complete`: one model completion, its text or why it gave none; `redraw`: draw the pane again;
17 * `load`, `save`: the module answers kept across sessions
18 * @typedef {import('./snapshot.mjs').Board} Board
19 * @typedef {import('./brief.mjs').Brief} Brief
20 * @typedef {import('./brief.mjs').Answer} Answer
21 * @typedef {{ mapping: import('./modules.mjs').Mapping, missing: number }} Mapped `missing`: intents still to sort
22 */
23
24/**
25 * Where a job stands: `key` what it was run for (a commit, a question), `value` its last result
26 * (kept through a failed run), `at` when that came in.
27 * @template T
28 * @typedef {{ state: 'idle' | 'running' | 'ready' | 'failed', key: string, value: T, error: string, at: number }} Job
29 */
30
31/**
32 * One job that keeps only its latest run. `run` starts `work` unless a run for the same key is already
33 * in or under way (then it returns that one; `force` starts anew), cancelling an older one; `work` gets
34 * the run's signal and `show`, to publish a partial value. A run resolves once it is over.
35 * @template T @param {T} empty
36 */
37const latestJob = empty => {
38 /** @type {Job<T>} */
39 let job = { state: 'idle', key: '', value: empty, error: '', at: 0 }
40 /** @type {AbortController | null} */
41 let stop = null
42 /** @type {Promise<void>} */
43 let running = Promise.resolve()
44 return {
45 get: () => job,
46 /** @param {Io} io @param {string} key @param {boolean} force @param {(signal: AbortSignal, show: (value: T) => void) => Promise<T>} work */
47 run: (io, key, force, work) => {
48 if (!force && job.key === key && job.state !== 'idle') return running
49 stop?.abort()
50 const mine = new AbortController()
51 stop = mine
52 job = { ...job, state: 'running', key, error: '' }
53 io.redraw()
54 const show = (/** @type {T} */ value) => {
55 if (mine.signal.aborted) return
56 job = { ...job, value }
57 io.redraw()
58 }
59 running = (async () => {
60 try {
61 const value = await work(mine.signal, show)
62 if (!mine.signal.aborted) job = { ...job, state: 'ready', value, at: Date.now() }
63 } catch (error) {
64 if (!mine.signal.aborted) job = { ...job, state: 'failed', error: error instanceof Error ? error.message : String(error) }
65 } finally {
66 if (stop === mine) stop = null
67 io.redraw()
68 }
69 })()
70 return running
71 },
72 clear: () => {
73 stop?.abort()
74 stop = null
75 job = { state: 'idle', key: '', value: empty, error: '', at: 0 }
76 },
77 }
78}
79
80// A completion's checked result, or a failure that says why there is none.
81/** @template T @param {{ text: string, error: string }} reply @param {(text: string) => T | null} parse @param {string} what @returns {T} */
82const resultOf = (reply, parse, what) => {
83 if (reply.error) throw new Error(reply.error)
84 const read = parse(reply.text)
85 if (read === null) throw new Error(`the reply held no ${what}`)
86 return read
87}
88
89const brief = latestJob(/** @type {Brief | null} */ (null))
90const asked = latestJob(/** @type {Answer | null} */ (null))
91const mapped = latestJob(/** @type {Mapped} */ ({ mapping: {}, missing: 0 }))
92
93export const briefState = brief.get
94export const askState = asked.get
95export const mapState = mapped.get
96
97// What a read of the board is: the commit read, or the checkout's own folders by their count and last change.
98/** @param {Board} board */
99const keyOf = board => board.sha || `local:${board.records.length}:${Math.max(0, ...board.records.map(one => one.updatedAt))}`
100
101/**
102 * The brief for this read of the board; `force` asks again. Resolves once it is in (or failed).
103 * @param {Io} io @param {Board} board @param {string} me @param {boolean} [force]
104 */
105export const runBrief = (io, board, me, force = false) =>
106 brief.run(io, keyOf(board), force, async signal => resultOf(await io.complete(briefRequest(board.records, me, Date.now()), signal), text => parseBrief(text, board.records, me), 'brief'))
107
108/**
109 * Asks the model a question about the board; `onAnswer` applies the cut of the board it picked.
110 * @param {Io} io @param {string} question @param {Board} board @param {string} me @param {(answer: Answer) => void} onAnswer
111 */
112export const ask = (io, question, board, me, onAnswer) =>
113 question.trim() === ''
114 ? Promise.resolve()
115 : asked.run(io, question.trim(), true, async signal => {
116 const answer = resultOf(await io.complete(askRequest(question, board.records, me, Date.now()), signal), text => parseAnswer(text, board.records), 'answer')
117 if (!signal.aborted) onAnswer(answer)
118 return answer
119 })
120
121// Puts the question away (and stops one still running).
122export const clearAsk = asked.clear
123
124/**
125 * Which intents work on which modules: what was kept for them, then Sonnet for the rest, a batch at a
126 * time, each answer kept as it comes so a later session does not ask again. Once per read of the board
127 * and list of modules; `force` asks again for those still missing.
128 * @param {Io} io @param {Board} board @param {import('./modules.mjs').Modules} set @param {boolean} [force]
129 */
130export const runMapping = (io, board, set, force = false) =>
131 mapped.run(io, `${keyOf(board)}|${listKey(set.modules)}`, force, async (signal, show) => {
132 let kept = await io.load()
133 const { mapping, missing } = mappingOf(board.records, set, kept)
134 /** @type {Mapped} */
135 let now = { mapping, missing: missing.length }
136 show(now)
137 for (let at = 0; at < missing.length && !signal.aborted; at += BATCH) {
138 const batch = missing.slice(at, at + BATCH)
139 const answered = resultOf(await io.complete(mappingRequest(batch, set.modules), signal), text => parseMapping(text, batch, set.modules), 'mapping')
140 kept = keep(kept, set.modules, batch, answered)
141 await io.save(kept)
142 now = { mapping: { ...now.mapping, ...answered }, missing: now.missing - Object.keys(answered).length }
143 show(now)
144 }
145 return now
146 })
147hooks/modules.mjs 125 lines1// @ts-check
2// Ather Board: the game's core modules, and which intents work on which. The modules come from the
3// repository's .ather/board.json ("modules": [{ "key", "name", "hint" }]), else S2's default list for an
4// Unreal checkout, else the intents' own Areas. Sonnet sorts intents into modules (one to three each);
5// each answer is kept with the intent's fingerprint (title, area, goal) and asked again only when that
6// changes, or when the list of modules does. Modules made from Areas need no model: an intent sits in its
7// area. Pure: no `$`; assist.mjs makes the calls and keeps the answers.
8
9import { MODEL, jsonOf } from './brief.mjs'
10
11/**
12 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
13 * @typedef {{ key: string, name: string, hint: string }} Module what the model reads: `hint` says what belongs in it
14 * @typedef {{ modules: Module[], byArea: boolean }} Modules `byArea`: made from the Areas, so no model is asked
15 * @typedef {Record<string, string[]>} Mapping intent key → its module keys
16 * @typedef {{ list: string, entries: Record<string, { print: string, modules: string[] }> }} Kept what is kept across sessions: the module list it was asked against, and each intent's answer with its fingerprint
17 */
18
19// S2's core modules: the game's systems and the work around them, in the order the map rings them.
20export const S2_MODULES = /** @type {readonly Module[]} */ ([
21 { key: 'mc-locomotion', name: 'Main character locomotion', hint: 'movement, sprint, dodge, traversal, jumps, camera follow' },
22 { key: 'mc-combat', name: 'Main character combat', hint: 'attacks, combos, parry, hit reactions, lock-on, damage' },
23 { key: 'mc-ninetails', name: 'Nine-tail abilities', hint: 'the main character’s tails, tail skills and transformations' },
24 { key: 'mc-look', name: 'Main character look', hint: 'model, materials, customization, body, animation art' },
25 { key: 'enemy-locomotion', name: 'Enemy locomotion', hint: 'how enemies move, navigate and animate' },
26 { key: 'enemy-ai', name: 'Enemy AI', hint: 'behaviour trees, perception, decisions, encounter logic' },
27 { key: 'enemy-abilities', name: 'Enemy abilities', hint: 'enemy attacks, projectiles, skills, hitboxes' },
28 { key: 'bosses', name: 'Bosses', hint: 'boss fights, phases, boss abilities' },
29 { key: 'quests', name: 'Quests and encounters', hint: 'quests, encounter design, scripted events, level scripting' },
30 { key: 'env-presets', name: 'Environment presets', hint: 'weather, time of day, fog, lighting and look presets' },
31 { key: 'env-levels', name: 'Environment levels', hint: 'level layout, world building, HLOD, streaming, foliage' },
32 { key: 'vfx', name: 'VFX', hint: 'Niagara and visual effects' },
33 { key: 'cinematics', name: 'Cinematics', hint: 'cutscenes, sequences, shots' },
34 { key: 'audio', name: 'Audio', hint: 'sound, music, audio tools' },
35 { key: 'ui', name: 'UI', hint: 'HUD, menus, front end' },
36 { key: 'performance', name: 'Performance', hint: 'optimization, profiling, memory, platform budgets' },
37 { key: 'tooling', name: 'Tooling', hint: 'editor tools, validators, pipeline, automation, agent tooling' },
38])
39
40// The one module of an intent's own Area, for modules made from Areas.
41/** @param {string} area */
42const areaKey = area => area.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'unsorted'
43
44/**
45 * The modules of a repository: its own list from .ather/board.json (`settings`), else S2's for the Unreal
46 * pack, else the Areas its intents use.
47 * @param {string} packId @param {Record<string, any> | null} settings @param {readonly BoardRecord[]} records @returns {Modules}
48 */
49export const modulesFor = (packId, settings, records) => {
50 const own = Array.isArray(settings?.modules) ? settings.modules.filter((/** @type {any} */ one) => typeof one?.key === 'string' && typeof one?.name === 'string').map((/** @type {any} */ one) => ({ key: one.key, name: one.name, hint: typeof one.hint === 'string' ? one.hint : '' })) : []
51 if (own.length > 0) return { modules: own, byArea: false }
52 if (packId === 'unreal') return { modules: [...S2_MODULES], byArea: false }
53 return { modules: [...new Set(records.map(one => one.area))].sort().map(area => ({ key: areaKey(area), name: area, hint: '' })), byArea: true }
54}
55
56// What a module answer depends on: when the intent says something else of itself, it is asked again.
57/** @param {BoardRecord} record */
58export const fingerprint = record => `${record.title}|${record.area}|${record.goal}`
59
60/** @param {readonly Module[]} modules */
61export const listKey = modules => modules.map(one => one.key).join(',')
62
63/**
64 * Every intent's modules: by area when the modules are the Areas, else what was kept for it while its
65 * fingerprint holds. `missing`: the intents to ask the model about.
66 * @param {readonly BoardRecord[]} records @param {Modules} set @param {Kept | null} kept
67 * @returns {{ mapping: Mapping, missing: BoardRecord[] }}
68 */
69export const mappingOf = (records, set, kept) => {
70 /** @type {Mapping} */
71 const mapping = {}
72 /** @type {BoardRecord[]} */
73 const missing = []
74 const valid = kept && kept.list === listKey(set.modules) ? kept.entries : {}
75 for (const record of records) {
76 const entry = valid[record.key]
77 if (set.byArea) mapping[record.key] = [areaKey(record.area)]
78 else if (entry && entry.print === fingerprint(record)) mapping[record.key] = entry.modules
79 else missing.push(record)
80 }
81 return { mapping, missing }
82}
83
84// How many intents one request sorts: about 60 lines of digest in, a short JSON object out.
85export const BATCH = 60
86
87/** @param {readonly BoardRecord[]} records @param {readonly Module[]} modules */
88export const mappingRequest = (records, modules) => ({
89 model: MODEL,
90 maxTokens: 4000,
91 system: 'You sort a game studio’s work items ("intents") into the game’s core modules. Pick the one to three modules each intent mainly works on, most central first. Use only the module keys given; never invent one. An intent about a tool for a module belongs to that module and to tooling.',
92 prompt: `The modules (key: name, what belongs in it):\n${modules.map(one => `${one.key}: ${one.name}${one.hint ? `, ${one.hint}` : ''}`).join('\n')}\n\nThe intents (key | title | area | goal):\n${records.map(one => [one.key, one.title, one.area, one.goal].join(' | ')).join('\n')}\n\nReply with JSON only, no prose around it: {"<intent key>": ["<module key>", ...], ...} for every intent above.`,
93})
94
95/**
96 * The model's sorting, checked: known intents and modules only, at most three each, once each. An
97 * intent the reply leaves out stays missing, to be asked again.
98 * @param {string} text @param {readonly BoardRecord[]} records @param {readonly Module[]} modules @returns {Mapping}
99 */
100export const parseMapping = (text, records, modules) => {
101 const reply = jsonOf(text)
102 if (!reply) return {}
103 const known = new Set(modules.map(one => one.key))
104 /** @type {Mapping} */
105 const mapping = {}
106 for (const record of records) {
107 const value = reply[record.key]
108 const picked = Array.isArray(value) ? [...new Set(value.filter(key => typeof key === 'string' && known.has(key)))].slice(0, 3) : []
109 if (picked.length > 0) mapping[record.key] = picked
110 }
111 return mapping
112}
113
114// What is kept once the model has answered for some intents.
115/** @param {Kept | null} kept @param {readonly Module[]} modules @param {readonly BoardRecord[]} records @param {Mapping} answered @returns {Kept} */
116export const keep = (kept, modules, records, answered) => {
117 const list = listKey(modules)
118 const entries = kept && kept.list === list ? { ...kept.entries } : {}
119 for (const record of records) {
120 const answer = answered[record.key]
121 if (answer) entries[record.key] = { print: fingerprint(record), modules: answer }
122 }
123 return { list, entries }
124}
125hooks/brief.mjs 142 lines1// @ts-check
2// Ather Board: the model's help (D7, D9). The brief: Sonnet reads a digest of the open intents and says
3// what matters, as a headline and a few suggestions that each name intents and one action. Ask: a
4// question typed in the pane comes back as a short answer and a cut of the board. Every reply is checked
5// against what the board read: an intent it does not know, or an action it does not have, is dropped,
6// so a suggestion on the board always acts on real intents. Pure: no `$`; index.mjs makes the calls.
7
8import { ACTION_KINDS, OWNER_ACTIONS } from './act.mjs'
9import { isMine } from './ather/model.mjs'
10import { NO_FILTER, PANE_VIEWS, STAGES, ageText } from './views.mjs'
11
12/**
13 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
14 * @typedef {import('./views.mjs').View} View
15 * @typedef {import('./views.mjs').Filter} Filter
16 * @typedef {import('./focus.mjs').Suggestion} Suggestion
17 * @typedef {{ headline: string, suggestions: Suggestion[] }} Brief
18 * @typedef {{ answer: string, view: View | '', area: string, owner: string, stage: string, keys: string[] }} Answer a cut of the board: '' leaves that part as it is; `keys`: only these intents
19 */
20
21export const MODEL = 'sonnet'
22export const MAX_SUGGESTIONS = 5
23
24// One line per open intent, what a model needs to judge it and nothing more (a digest of ~80 intents is ~6k tokens).
25/** @param {readonly BoardRecord[]} records @param {string} me @param {number} now */
26export const digest = (records, me, now) =>
27 records
28 .filter(one => one.stage !== 'done')
29 .map(one =>
30 [
31 one.key,
32 STAGES.find(stage => stage.key === one.stage)?.label ?? one.stage,
33 one.area,
34 `${one.owner || 'no owner'}${isMine(one, me) ? ' (me)' : ''}`,
35 one.total ? `${one.done}/${one.total} met` : 'no checklist',
36 `changed ${ageText(one.updatedAt, now) || '?'} ago`,
37 one.flags.length ? `flags: ${one.flags.map(flag => flag.label).join(', ')}` : '',
38 one.calls.length ? `decisions: ${one.calls.map(call => `${call.id} ${call.title}`).join('; ')}` : '',
39 one.goal,
40 ]
41 .filter(Boolean)
42 .join(' | '),
43 )
44 .join('\n')
45
46// The views an answer may pick: the pane's, less For you (which takes no filter).
47const VIEWS = PANE_VIEWS.filter(view => view !== 'foryou')
48
49const ROLE = `You help a game studio's team cut through its list of "intents" (features, each a folder with a goal, an acceptance checklist and open decisions). The board is read-only; every action you suggest is handed to a Claude Code session that asks the person before changing anything. Be concrete and brief: name intents by their key exactly as given, never invent one. Prefer what unblocks the most work: decisions waiting, work ready to close, stale or stuck work, then hygiene.`
50
51const ACTIONS = `Actions: ${ACTION_KINDS.join(', ')}, or show (cut the board to the intents named). ${OWNER_ACTIONS.join(', ')} are for the person's own intents (marked "(me)"); for a teammate's, use ping, about or show.`
52
53/** @param {readonly BoardRecord[]} records @param {string} me @param {number} now */
54export const briefRequest = (records, me, now) => ({
55 model: MODEL,
56 maxTokens: 1500,
57 system: `${ROLE}\n${ACTIONS}`,
58 prompt: `The person at the keyboard is ${me || 'unknown'}. The open intents, one per line (key | stage | area | owner | checklist | last change | flags | decisions | goal):\n\n${digest(records, me, now)}\n\nWrite the brief: what matters most right now, for this person first, then for the team. Reply with JSON only, no prose around it:\n{"headline": "<one sentence>", "suggestions": [{"title": "<imperative, under 12 words>", "why": "<one short sentence>", "action": "<action>", "intents": ["<key>", ...]}]}\nAt most ${MAX_SUGGESTIONS} suggestions.`,
59})
60
61/** @param {string} question @param {readonly BoardRecord[]} records @param {string} me @param {number} now */
62export const askRequest = (question, records, me, now) => ({
63 model: MODEL,
64 maxTokens: 800,
65 system: `${ROLE}\nYou answer a question about the board and pick the cut of it that shows the answer. Views: ${VIEWS.join(', ')} (items: open acceptance items; modules: the game's modules). Stages: ${STAGES.map(stage => stage.key).join(', ')}.`,
66 prompt: `The person at the keyboard is ${me || 'unknown'}. The open intents, one per line (key | stage | area | owner | checklist | last change | flags | decisions | goal):\n\n${digest(records, me, now)}\n\nTheir question: ${question.trim()}\n\nReply with JSON only, no prose around it:\n{"answer": "<at most three sentences>", "view": "<a view, or empty>", "area": "<an area exactly as listed, or empty>", "owner": "<an owner exactly as listed, or empty>", "stage": "<a stage, or empty>", "intents": ["<key>", ...]}\nList intents only when the answer is a particular set of them; leave the rest empty.`,
67})
68
69// The first JSON object in a reply (a model may wrap it in a code fence); null when there is none.
70/** @param {string} text @returns {Record<string, unknown> | null} */
71export const jsonOf = text => {
72 const start = text.indexOf('{')
73 const end = text.lastIndexOf('}')
74 if (start < 0 || end <= start) return null
75 try {
76 const value = JSON.parse(text.slice(start, end + 1))
77 return value && typeof value === 'object' && !Array.isArray(value) ? value : null
78 } catch {
79 return null
80 }
81}
82
83/** @param {unknown} value @param {number} max */
84const words = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '')
85
86// The keys a reply names that the board read, in its order, once each.
87/** @param {unknown} value @param {readonly BoardRecord[]} records @returns {string[]} */
88const knownKeys = (value, records) => {
89 const known = new Set(records.map(one => one.key))
90 return Array.isArray(value) ? [...new Set(value.filter(key => typeof key === 'string' && known.has(key)))] : []
91}
92
93// What a suggestion may ask for: an action, or show.
94/** @type {Suggestion['action'][]} */
95const PICKS = ['show', ...ACTION_KINDS]
96
97// A brief from the model's reply; null when the reply has no brief in it. A suggestion naming no known
98// intent, or an unknown action, is dropped; an action only the owner may take, suggested for a
99// teammate's intent, becomes show.
100/** @param {string} text @param {readonly BoardRecord[]} records @param {string} me @returns {Brief | null} */
101export const parseBrief = (text, records, me) => {
102 const reply = jsonOf(text)
103 if (!reply) return null
104 const list = Array.isArray(reply.suggestions) ? reply.suggestions : []
105 /** @type {Suggestion[]} */
106 const suggestions = list.flatMap((/** @type {unknown} */ item, /** @type {number} */ index) => {
107 const one = item && typeof item === 'object' ? /** @type {Record<string, unknown>} */ (item) : {}
108 const keys = knownKeys(one.intents, records)
109 const action = PICKS.find(kind => kind === words(one.action, 20))
110 if (keys.length === 0 || !action) return []
111 const isOwn = keys.every(key => records.some(record => record.key === key && isMine(record, me)))
112 return [{ key: `brief-${index}`, title: words(one.title, 90) || keys.join(', '), why: words(one.why, 200), action: action !== 'show' && OWNER_ACTIONS.some(kind => kind === action) && !isOwn ? 'show' : action, keys }]
113 })
114 return { headline: words(reply.headline, 240), suggestions: suggestions.slice(0, MAX_SUGGESTIONS) }
115}
116
117// An answer from the model's reply; null when there is none. A view, area, owner or stage the board
118// does not have is left as it is ('').
119/** @param {string} text @param {readonly BoardRecord[]} records @returns {Answer | null} */
120export const parseAnswer = (text, records) => {
121 const reply = jsonOf(text)
122 if (!reply) return null
123 const answer = words(reply.answer, 600)
124 const pick = (/** @type {unknown} */ value, /** @type {readonly string[]} */ allowed) => { const one = words(value, 80); return allowed.includes(one) ? one : '' }
125 return {
126 answer,
127 view: VIEWS.find(view => view === words(reply.view, 20)) ?? '',
128 area: pick(reply.area, [...new Set(records.map(one => one.area))]),
129 owner: pick(reply.owner, [...new Set(records.map(one => one.owner))]),
130 stage: pick(reply.stage, STAGES.map(stage => stage.key)),
131 keys: knownKeys(reply.intents, records),
132 }
133}
134
135// The cut of the board an answer picked: its view (the table when it names intents and no view; For you,
136// which has no filters, gives way to Triage), and its filters over a clean slate.
137/** @param {Answer} answer @param {View} current @returns {{ view: View, filter: Filter }} */
138export const answerLook = (answer, current) => ({
139 view: answer.view || (answer.keys.length > 0 ? 'table' : current === 'foryou' ? 'triage' : current),
140 filter: { ...NO_FILTER, area: answer.area, owner: answer.owner, stage: answer.stage, keys: answer.keys },
141})
142hooks/focus.mjs 101 lines1// @ts-check
2// Ather Board: For you (D8). What the person at the keyboard can act on, before the team's views:
3// their intents grouped by what each needs from them, worst first, then the rest of their open work;
4// and the suggestions the board can make without a model (the brief adds the model's, brief.mjs).
5// Pure: no `$`.
6
7import { isMine } from './ather/model.mjs'
8import { nextAction } from './act.mjs'
9import { moduleSections } from './map.mjs'
10import { DAY_MS, WARNINGS, recordsFor, rowOf, sectionsOf, sortRecords } from './views.mjs'
11
12/**
13 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
14 * @typedef {import('./views.mjs').Section} Section
15 * @typedef {import('./views.mjs').IntentGroup} IntentGroup
16 * @typedef {import('./act.mjs').ActionKind} ActionKind
17 * @typedef {{ key: string, title: string, why: string, action: ActionKind | 'show', keys: string[] }} Suggestion
18 * one thing worth doing: `action` over the intents `keys` (`show`: cut the board to them)
19 */
20
21// What each of the person's open intents needs from them, in this order; an intent sits in the first that fits.
22const NEEDS = /** @type {const} */ ([
23 { key: 'decide', title: 'Waiting on your decision', actions: ['decide'] },
24 { key: 'close', title: 'Ready for you to close', actions: ['close'] },
25 { key: 'stuck', title: 'Stuck', actions: ['reason', 'unstick'] },
26 { key: 'tidy', title: 'Needs a checklist or a tidy header', actions: ['checklist', 'tidy'] },
27])
28
29/** @param {string} key @param {string} title @param {string} empty @param {readonly BoardRecord[]} list @returns {IntentGroup} */
30const group = (key, title, empty, list) => ({ kind: 'intents', key, title, meta: String(list.length), empty, hide: 'owner', rows: list.map(record => rowOf(record, WARNINGS)) })
31
32/**
33 * For you as sections: a lime label over what needs the person, then their other open work.
34 * `me`: their git name ('' when unknown).
35 * @param {readonly BoardRecord[]} records @param {string} me @returns {Section[]}
36 */
37export const forYou = (records, me) => {
38 if (me === '') return [{ kind: 'label', key: 'label-you', title: 'For you', meta: '', isLoud: true, groups: [group('you-none', '', 'Set git user.name in this checkout so the board knows which intents are yours. Triage shows the team’s.', [])] }]
39 const mine = sortRecords(records.filter(one => one.stage !== 'done' && isMine(one, me)), 'stale')
40 const needs = NEEDS.map(need => ({ need, list: mine.filter(one => need.actions.some(action => action === nextAction(one, true))) })).filter(one => one.list.length > 0)
41 const placed = new Set(needs.flatMap(one => one.list.map(record => record.key)))
42 const rest = sortRecords(mine.filter(one => !placed.has(one.key)), 'recent')
43 const count = needs.reduce((sum, one) => sum + one.list.length, 0)
44 return [
45 { kind: 'label', key: 'label-you', title: 'For you', meta: String(count), isLoud: true, groups: needs.length > 0 ? needs.map(({ need, list }) => group(`you-${need.key}`, need.title, '', list)) : [group('you-none', '', mine.length > 0 ? 'Nothing of yours needs you now.' : 'You own no open intents. Triage shows the team’s.', [])] },
46 ...(rest.length > 0 ? [/** @type {Section} */ ({ kind: 'label', key: 'label-yours', title: 'Your other open work', meta: String(rest.length), isLoud: false, groups: [group('yours', '', '', rest)] })] : []),
47 ]
48}
49
50// The brief's suggestions, then the board's own that the brief did not already make (the same action on the same intents).
51/** @param {readonly Suggestion[]} brief @param {readonly Suggestion[]} plain */
52export const mergeSuggestions = (brief, plain) => {
53 const said = new Set(brief.map(one => `${one.action}:${[...one.keys].sort().join(',')}`))
54 return [...brief, ...plain.filter(one => !said.has(`${one.action}:${[...one.keys].sort().join(',')}`))]
55}
56
57/**
58 * What the board suggests without a model, most useful first, at most four: close every ready intent of
59 * yours at once, answer your oldest decision, tidy your headers, and see the teammates' intents that wait on a decision.
60 * @param {readonly BoardRecord[]} records @param {string} me @param {number} now @returns {Suggestion[]}
61 */
62export const plainSuggestions = (records, me, now) => {
63 const open = records.filter(one => one.stage !== 'done')
64 const mine = open.filter(one => isMine(one, me))
65 const days = (/** @type {BoardRecord} */ one) => Math.max(0, Math.floor((now - one.updatedAt) / DAY_MS))
66 const ready = mine.filter(one => one.stage === 'met')
67 const [oldest] = sortRecords(mine.filter(one => one.calls.length > 0), 'stale')
68 const untidy = mine.filter(one => nextAction(one, true) === 'tidy')
69 const waiting = open.filter(one => !isMine(one, me) && one.calls.length > 0)
70 /** @type {Suggestion[]} */
71 const all = [
72 ...(ready.length === 0 ? [] : [{ key: 'close-ready', title: ready.length === 1 ? `Close ${ready[0]?.slug}` : `Close your ${ready.length} ready intents`, why: 'Every acceptance item is met.', action: /** @type {const} */ ('close'), keys: ready.map(one => one.key) }]),
73 ...(oldest ? [{ key: 'decide-oldest', title: `Answer ${oldest.calls.length === 1 ? 'the decision' : `the ${oldest.calls.length} decisions`} on ${oldest.slug}`, why: `Waiting ${days(oldest)} ${days(oldest) === 1 ? 'day' : 'days'}; the work cannot go on until then.`, action: /** @type {const} */ ('decide'), keys: [oldest.key] }] : []),
74 ...(untidy.length === 0 ? [] : [{ key: 'tidy-mine', title: untidy.length === 1 ? `Fix the header of ${untidy[0]?.slug}` : `Fix the headers of your ${untidy.length} intents`, why: 'The team’s views cannot place them as written.', action: /** @type {const} */ ('tidy'), keys: untidy.map(one => one.key) }]),
75 ...(waiting.length === 0 ? [] : [{ key: 'show-waiting', title: `${waiting.length} teammates’ intents wait on a decision`, why: 'Answering, or nudging their owners, unblocks the most work.', action: /** @type {const} */ ('show'), keys: waiting.map(one => one.key) }]),
76 ]
77 return all.slice(0, 4)
78}
79
80/**
81 * What each of the pane's views draws besides its sections: the search and the pickers (`filters`), the
82 * suggestions (`brief`, For you), and where the module mapping stands (`mapping`, Modules).
83 * @typedef {{ filters: boolean, brief: boolean, mapping: boolean }} Extras
84 */
85/** @type {Extras} */
86const PLAIN = { filters: true, brief: false, mapping: false }
87/** @type {Partial<Record<import('./views.mjs').View, Extras>>} */
88const EXTRAS = { foryou: { filters: false, brief: true, mapping: false }, modules: { filters: true, brief: false, mapping: true } }
89
90/**
91 * What a pane view shows: the records it sees, its sections (For you's, the modules', or the shared
92 * views'), and its extras. The one place the pane's views are told apart.
93 * @param {readonly BoardRecord[]} records @param {import('./views.mjs').Look} look
94 * @param {{ me: string, modules: readonly import('./modules.mjs').Module[], mapping: import('./modules.mjs').Mapping, now: number }} context
95 */
96export const paneView = (records, look, { me, modules, mapping, now }) => {
97 const shown = recordsFor(records, look)
98 const parts = look.view === 'foryou' ? forYou(records, me) : look.view === 'modules' ? moduleSections(shown, modules, mapping, look.sort, now) : sectionsOf(shown, look, now)
99 return { shown, parts, extras: EXTRAS[look.view] ?? PLAIN }
100}
101hooks/ather/packs/index.mjs 129 lines1// @ts-check
2// Ather Automata: which pack serves a repository, and the shape every pack has.
3// The core (intents, Plan → Build → Prove → Ship, Next, away windows, issues,
4// the squad) is the same everywhere; a pack says what proof, traps, held
5// actions and words fit the kind of project. Pure apart from the Io it is given.
6//
7// Selection (D3): the repository's .ather/profile.json, then markers (*.uproject
8// → unreal; package.json or pyproject.toml → web), then the core alone. Read once
9// per session and root, and cached.
10
11import { gitDirOf, isAbsolute, normalFolder } from '../workspace.mjs'
12import { core } from './core.mjs'
13import { unreal } from './unreal.mjs'
14import { makeWebPack } from './web.mjs'
15
16/**
17 * @typedef {{ state: 'none' | 'pass' | 'fail', detail: string, at?: number, by?: string }} Rung `at`, `by`: when, and by which session (its first 8 hex), the record was written
18 * @typedef {{ rungs: { rung: string, value: Rung, gates?: RungGates }[], context: string[], toasts: { text: string, timeoutMs?: number }[], bumps: string[] }} ShellReading
19 * @typedef {{ ran: string[], all: string[] }} RungGates on a rung that gates declare: the gates a command ran (none: a command that is no gate), of all that declare it
20 * @typedef {{ id: string, pattern: RegExp, title: string, fix: string, rule?: { file: string, text: string } }} PackTrap
21 * @typedef {{ name: string, verb: string, isGlobal?: boolean }} CreateItem
22 * @typedef {{ isHeld: boolean, isFree: boolean, holder: string, until: string }} EditorState
23 * @typedef {{ command: string, proofs: string[], id?: string, proves?: string, passOn?: 'exit' | 'counts' }} Gate `passOn`: how its tests and ui proofs pass, on its exit code (also without the field) or on test counts
24 * @typedef {{ host: string, branch: string, deployment: string, url: string, expectStatus: number }} Production
25 * @typedef {{ files?: readonly string[], defaultBranch?: string }} Found what else was read of a repository where its pack is chosen: the names of the files at its root, and the default branch its clone knows
26 * @typedef {{ pack: Pack, held: readonly string[], isProven: boolean, base?: string }} HeldAt what a folder's own checkout holds, when that is not the session's; `base`: the branch its team merges into, when not main
27 * @typedef {{ isProven?: boolean, scripts?: Record<string, string>, base?: string, at?: (folder: string | null) => HeldAt | null }} HeldContext `base`: the branch the session's checkout merges into, when not main
28 * @typedef {{
29 * id: 'unreal' | 'web' | 'core',
30 * roles: readonly string[], roleLabels: Record<string, string>, roleDescriptions: Record<string, string>,
31 * roleWords: string, roleHelp: string, roleFallback: string, roleKey: string, parseRole: (text: string) => string | null,
32 * owners: string, areas: readonly string[], normalizeArea: (text: string) => string,
33 * rungLabels: Record<string, string>, proofWords: Record<string, string>, emptyEvidence: () => Record<string, Rung>,
34 * requiredRungs: (role: string) => string[], isProven: (evidence: Record<string, Rung>, role: string) => boolean, anyProofText: string,
35 * localDir: string, debriefPath: (slug: string) => string,
36 * lockFile: string | null, parseLock: (raw: string | null, nowMinutes: number) => import('./unreal.mjs').EditorLock, lockRoles: readonly string[],
37 * lockLine?: (command: string, raw: string) => string,
38 * ownCheck: { role: string, after: string, rung: string, label: string, hint: string, proveHint: string, detail: string, reply: string } | null,
39 * traps: readonly PackTrap[],
40 * held: { labels: Record<string, string>, nouns: Record<string, string>, kinds: readonly string[], defaults: readonly string[] },
41 * heldSegment: (segment: string, held: readonly string[], context: HeldContext) => string | null,
42 * mergePolicy: 'hold' | 'with-proof', mergeRungs?: readonly string[], rungGates?: Readonly<Record<string, string[]>>,
43 * baseBranch?: string,
44 * isAssetSave: (input: string) => boolean, readShell: (command: string, text: string, ran: { isError?: boolean, deny?: string }) => ShellReading,
45 * mcpKind: (input: string) => 'write' | 'read' | 'pie' | null, binaryAssets: RegExp | null, briefPaths: RegExp,
46 * skillGroups: readonly { group: string, names: readonly string[] }[], createGroups: readonly { group: string, items: readonly CreateItem[] }[],
47 * createOrder: Record<string, readonly string[]>, createTitle: string, createMeta: (editor: EditorState) => string, createPrompt: (verb: string, name: string, editor: EditorState) => string,
48 * prompts: { brief: (role: string, slug: string) => string, prove: (role: string, slug: string) => string, ship: (role: string, slug: string) => string, shipHint: (role: string) => string, briefHint: string, tour: string, tourToast: string, ask: (question: string) => string },
49 * mandate: { flags: string, allowed: string, merge: string, away: string, pane: string },
50 * statusWhat: string, notHere: string, gates: readonly Gate[], production: Production | null, scripts?: Record<string, string>,
51 * teams?: Readonly<Record<string, string>>, names?: import('../worklist.mjs').NameRules,
52 * [key: string]: unknown
53 * }} Pack
54 */
55
56/**
57 * What selection reads: closures over the engine, as state.mjs's Io.
58 * @typedef {{ read: (path: string) => Promise<string | null>, exists: (path: string) => Promise<boolean>, list: (path: string) => Promise<{ name: string, kind: string }[]>, sessionId: () => Promise<string> }} PackIo
59 */
60
61/** @param {string | null} text */
62const json = text => {
63 if (!text) return null
64 try {
65 const value = JSON.parse(text)
66 return value && typeof value === 'object' ? value : null
67 } catch {
68 return null
69 }
70}
71
72// The default branch a clone knows: where refs/remotes/origin/HEAD points, in the git folder its worktrees share.
73// Read from the files, with no git call; '' when it cannot be told.
74/** @param {PackIo} io @param {string} root */
75const defaultBranchOf = async (io, root) => {
76 const named = await gitDirOf(io, root)
77 if (!named) return ''
78 // A worktree's .git file may name its git folder from the worktree.
79 const gitDir = named === `${root}/.git` || isAbsolute(named) ? named : normalFolder(`${root}/${named}`)
80 const common = ((await io.read(`${gitDir}/commondir`)) ?? '').trim()
81 const shared = common === '' ? gitDir : isAbsolute(common) ? common : normalFolder(`${gitDir}/${common}`)
82 return /^ref:\s*refs\/remotes\/origin\/(.+)/.exec((await io.read(`${shared}/refs/remotes/origin/HEAD`)) ?? '')?.[1]?.trim() ?? ''
83}
84
85/**
86 * Which pack a repository gets, and why, with the default branch its clone knows ('' when it cannot be told).
87 * Pure over what it reads.
88 * @param {PackIo} io @param {string} root
89 * @returns {Promise<{ pack: Pack, source: 'profile' | 'marker' | 'none', profile: Record<string, any> | null, defaultBranch: string }>}
90 */
91export const choosePack = async (io, root) => {
92 const profile = json(await io.read(`${root}/.ather/profile.json`))
93 const packageJson = json(await io.read(`${root}/package.json`))
94 const defaultBranch = await defaultBranchOf(io, root).catch(() => '')
95 const named = typeof profile?.pack === 'string' ? profile.pack.toLowerCase() : ''
96 if (named === 'unreal') return { pack: unreal, source: 'profile', profile, defaultBranch }
97 if (named === 'core') return { pack: core, source: 'profile', profile, defaultBranch }
98 const entries = await io.list(root).catch(() => [])
99 const files = entries.filter(entry => entry.kind === 'file' && typeof entry.name === 'string').map(entry => entry.name)
100 const web = () => makeWebPack(profile, packageJson, { files, defaultBranch })
101 if (named === 'web') return { pack: web(), source: 'profile', profile, defaultBranch }
102 if (entries.some(entry => entry.kind === 'file' && /\.uproject$/i.test(entry.name))) return { pack: unreal, source: 'marker', profile, defaultBranch }
103 if (packageJson || entries.some(entry => entry.kind === 'file' && /^(package\.json|pyproject\.toml)$/i.test(entry.name))) return { pack: web(), source: 'marker', profile, defaultBranch }
104 return { pack: core, source: 'none', profile, defaultBranch }
105}
106
107/** @type {Map<string, ReturnType<typeof choosePack>>} */
108const chosen = new Map()
109
110// Once per session and root: a profile edited mid-session takes effect in the next session.
111/** @param {PackIo} io @param {string} root */
112export const packFor = async (io, root) => {
113 const key = `${await io.sessionId().catch(() => '')}|${root}`
114 const hit = chosen.get(key)
115 if (hit) return hit
116 const read = choosePack(io, root)
117 chosen.set(key, read)
118 return read
119}
120
121// A repository that got its intents mid-session has a profile now: its pack is chosen again.
122/** @param {Pick<PackIo, 'sessionId'>} io @param {string} root */
123export const forgetPack = async (io, root) => void chosen.delete(`${await io.sessionId().catch(() => '')}|${root}`)
124
125// Tests only: forget what was chosen.
126export const forgetPacks = () => chosen.clear()
127
128export { core, unreal, makeWebPack }
129hooks/ather/team.mjs 308 lines1// @ts-check
2// Ather Automata: the team's intents as origin/main has them (or origin/<base>, where the team
3// merges into another branch), each dated by its folder's last commit there, merged with the
4// folders only this checkout has (D1-D3). Pure: git
5// and the files come through a Repo of closures built where `$` lives (console.mjs).
6// Every git call here reads, and never the working tree or the index; the fetch writes
7// only the remote-tracking ref.
8
9import { branchNamed } from './shell.mjs'
10
11/**
12 * `isAborted`: the app took the run back before it ended (it never ran to an exit code).
13 * @typedef {{ exitCode: number, stdout: string, stderr?: string, isAborted?: boolean }} Ran
14 * @typedef {{
15 * git: (args: readonly string[], options?: { stdin?: string, timeoutMs?: number }) => Promise<Ran>,
16 * read: (path: string) => Promise<string | null>,
17 * list: (path: string) => Promise<readonly { name: string, kind: string }[]>,
18 * mtime: (path: string) => Promise<number>,
19 * real?: (folder: string) => Promise<string>,
20 * }} Repo `git` runs in the checkout with GIT_ENV; one that could not start or ran out of time answers exit code -1, the reason in stderr.
21 * `real`: the folder a path really lands in, behind any symbolic link; without it folders are compared as given
22 * @typedef {{ files: string[], at: number, firstAuthor: string, prompt: string, progress: string, findings: string }} MainFolder `at`: the folder's last commit, ms
23 * @typedef {{ sha: string, folders: Map<string, MainFolder> }} MainSnapshot what origin/main held at `sha`
24 * @typedef {{ key: string, dirty: Set<string>, committed: Set<string> }} LocalState which folders are uncommitted, and which this branch committed since main, as of `key`
25 * @typedef {{ main: MainSnapshot | null, local: LocalState | null }} TeamCache what the last read learned; each part is read again only when what it depends on moved
26 * @typedef {import('./model.mjs').IntentFiles} IntentFiles
27 */
28
29// Every git call Ather makes reads without taking the index lock, and never asks for a password (D2).
30export const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
31// The remote-tracking ref of the branch a checkout's team merges into (its base).
32/** @param {string} base */
33const refOf = base => `origin/${base}`
34export const MAIN = refOf('main')
35const 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/** @param {string} base */
40export const fetchArgs = base => ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false', 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', 'origin', `+refs/heads/${base}:refs/remotes/${refOf(base)}`]
41export const FETCH_ARGS = fetchArgs('main')
42export const FETCH_EVERY_MS = 10 * 60 * 1000
43// As long as the engine lets a process run: a slow fetch is not a failed one.
44export const FETCH_TIMEOUT_MS = 10 * 60 * 1000
45export const EMPTY_CACHE = /** @type {TeamCache} */ ({ main: null, local: null })
46
47// The branch a checkout's team merges into: `baseBranch` in its .ather/profile.json when it reads as a branch name, else the default
48// branch its clone knows (`defaultBranch`: packs/index.mjs choosePack; '' when it cannot be told), else main.
49/** @param {Record<string, any> | null} profile @param {string} defaultBranch */
50export const baseOf = (profile, defaultBranch) => branchNamed(profile?.baseBranch) || branchNamed(defaultBranch) || 'main'
51
52/** @param {string} text */
53const lf = text => text.replace(/\r\n/g, '\n')
54
55// The intent folder a repository path is in: "docs/intent/lead-vfx/prompt.md" → "lead-vfx".
56/** @param {string} path */
57const slugOf = path => /^docs\/intent\/([^/]+)\/./.exec(path.trim())?.[1] ?? ''
58
59// `git ls-tree -r --name-only` under docs/intent: each folder with the names of its files.
60/** @param {string} text @returns {Map<string, string[]>} */
61export const parseTree = text => {
62 /** @type {Map<string, string[]>} */
63 const folders = new Map()
64 for (const line of lf(text).split('\n')) {
65 const slug = slugOf(line)
66 if (!slug) continue
67 folders.set(slug, [...(folders.get(slug) ?? []), line.trim().slice(`${INTENTS}/${slug}/`.length)])
68 }
69 return folders
70}
71
72// `git log --format=%x00%ct%x09%an --name-only` under docs/intent, newest first: each folder's
73// last commit time (ms) and the author of its first commit.
74/** @param {string} text @returns {Map<string, { at: number, firstAuthor: string }>} */
75export const parseLog = text => {
76 /** @type {Map<string, { at: number, firstAuthor: string }>} */
77 const folders = new Map()
78 for (const record of lf(text).split('\0').slice(1)) {
79 const [head = '', ...paths] = record.split('\n')
80 const [seconds = '', author = ''] = head.split('\t')
81 const at = Number(seconds) * 1000
82 if (!Number.isFinite(at) || at <= 0) continue
83 for (const slug of new Set(paths.map(slugOf).filter(Boolean))) folders.set(slug, { at: folders.get(slug)?.at ?? at, firstAuthor: author.trim() })
84 }
85 return folders
86}
87
88// `git cat-file --batch` output, one entry per object asked for: its text, or null when missing.
89// Sizes count bytes, so the text is walked as UTF-8.
90/** @param {string} text @param {number} count @returns {(string | null)[]} */
91export const parseBatch = (text, count) => {
92 const bytes = new TextEncoder().encode(text)
93 const decoder = new TextDecoder()
94 /** @type {(string | null)[]} */
95 const out = []
96 let at = 0
97 while (out.length < count && at < bytes.length) {
98 const end = bytes.indexOf(10, at)
99 if (end < 0) break
100 const head = decoder.decode(bytes.subarray(at, end))
101 at = end + 1
102 const blob = / blob (\d+)$/.exec(head)
103 if (!blob) {
104 out.push(null)
105 continue
106 }
107 const size = Number(blob[1])
108 out.push(decoder.decode(bytes.subarray(at, at + size)))
109 at += size + 1
110 }
111 while (out.length < count) out.push(null)
112 return out
113}
114
115// `git status --porcelain=v1 -z` under docs/intent: the folders with uncommitted or untracked files.
116/** @param {string} text */
117export const parseStatus = text => new Set(text.split('\0').map(entry => slugOf(entry.replace(/^.. /, ''))).filter(Boolean))
118
119/** @param {string} prompt */
120const isCompleted = prompt => /^\s*-\s*Status:\s*completed/im.test(prompt)
121
122// The text of each file at `sha`, in order; null for one that is missing (one `git cat-file --batch`).
123/** @param {Repo} repo @param {string} sha @param {readonly string[]} paths */
124export const blobs = async (repo, sha, paths) => {
125 if (paths.length === 0) return []
126 const ran = await repo.git(['cat-file', '--batch'], { stdin: paths.map(path => `${sha}:${path}\n`).join('') })
127 return ran.exitCode === 0 ? parseBatch(ran.stdout, paths.length) : paths.map(() => null)
128}
129
130/** @param {string} a @param {string} b */
131const isSamePath = (a, b) => {
132 const norm = (/** @type {string} */ path) => path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
133 return norm(a) === norm(b)
134}
135
136/** @param {Repo} repo @param {string} ref */
137const shaOf = async (repo, ref) => {
138 const ran = await repo.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
139 return ran.exitCode === 0 ? ran.stdout.trim() : ''
140}
141
142// What origin/main (origin/<base>) holds under docs/intent: every folder's files, last commit and first
143// author, its prompt.md, and the progress and findings of open ones. Read again only when the ref moved:
144// `previous` is returned as it is while the ref is still at its commit. null: no such ref.
145/** @param {Repo} repo @param {MainSnapshot | null} previous @param {string} [base] @returns {Promise<MainSnapshot | null>} */
146export const readMain = async (repo, previous, base = 'main') => {
147 const sha = await shaOf(repo, refOf(base))
148 if (!sha) return null
149 if (previous?.sha === sha) return previous
150 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])])
151 if (tree.exitCode !== 0 || log.exitCode !== 0) return previous
152 const files = parseTree(tree.stdout)
153 const dates = parseLog(log.stdout)
154 const slugs = [...files.keys()].filter(slug => files.get(slug)?.includes('prompt.md'))
155 const prompts = await blobs(repo, sha, slugs.map(slug => `${INTENTS}/${slug}/prompt.md`))
156 const open = slugs.filter((_, index) => !isCompleted(prompts[index] ?? ''))
157 const more = await blobs(repo, sha, open.flatMap(slug => [`${INTENTS}/${slug}/progress.md`, `${INTENTS}/${slug}/findings.md`]))
158 /** @type {Map<string, MainFolder>} */
159 const folders = new Map()
160 slugs.forEach((slug, index) => {
161 const at = open.indexOf(slug)
162 folders.set(slug, {
163 files: files.get(slug) ?? [],
164 at: dates.get(slug)?.at ?? 0,
165 firstAuthor: dates.get(slug)?.firstAuthor ?? '',
166 prompt: lf(prompts[index] ?? ''),
167 progress: at < 0 ? '' : lf(more[at * 2] ?? ''),
168 findings: at < 0 ? '' : lf(more[at * 2 + 1] ?? ''),
169 })
170 })
171 return { sha, folders }
172}
173
174// Which local folders are uncommitted, and which this branch committed since main. Both calls load
175// the whole index of a large checkout, so readTeam asks only when `key` (main, HEAD and the intent
176// files' times) moved.
177/** @param {Repo} repo @param {string} sha @param {string} key @returns {Promise<LocalState>} */
178const readLocal = async (repo, sha, key) => {
179 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])])
180 return { key, dirty: parseStatus(status.exitCode === 0 ? status.stdout : ''), committed: new Set(parseLog(ahead.exitCode === 0 ? ahead.stdout : '').keys()) }
181}
182
183// The checkout's copy of an intent main has too wins when it says something else and is newer:
184// uncommitted, or committed on this branch since main (D1). Main's progress and findings count only
185// where main kept them (for open intents).
186/** @param {{ prompt: string, progress: string, findings: string }} local @param {MainFolder} onMain @param {boolean} isNewer */
187export const localWins = (local, onMain, isNewer) =>
188 isNewer && (local.prompt !== onMain.prompt || (onMain.progress !== '' && local.progress !== onMain.progress) || (onMain.findings !== '' && local.findings !== onMain.findings))
189
190// The intents to show: origin/main's, and the checkout's own folders. A folder on both is read from
191// main unless the checkout's copy wins (localWins); the tracked one (`pinned`) always from the
192// checkout, where its session writes. Main's are dated by their last commit there, the checkout's by
193// their files (D3). `isRepo` false: not a git checkout of its own, so only its folders are read, untagged.
194// `base`: the branch the checkout's team merges into, when that is not main; "main" above is then origin/<base>.
195/**
196 * @param {Repo} repo @param {string} root
197 * @param {{ cache: TeamCache, pinned: string | null, base?: string }} options
198 * @returns {Promise<{ isRepo: boolean, cache: TeamCache, intents: IntentFiles[] }>}
199 */
200export const readTeam = async (repo, root, { cache, pinned, base = 'main' }) => {
201 const top = await repo.git(['rev-parse', '--show-toplevel'])
202 // Git names the checkout's real folder; the session may have reached it through a link (macOS /var, /tmp).
203 const isRepo = top.exitCode === 0 && (isSamePath(top.stdout, root) || (repo.real !== undefined && isSamePath(top.stdout, await repo.real(root).catch(() => root))))
204 const main = isRepo ? await readMain(repo, cache.main, base) : null
205 const folders = []
206 for (const entry of await repo.list(`${root}/${INTENTS}`).catch(() => [])) {
207 if (entry.kind !== 'dir') continue
208 const dir = `${root}/${INTENTS}/${entry.name}`
209 const prompt = await repo.read(`${dir}/prompt.md`)
210 if (prompt === null) continue
211 const isOpen = !isCompleted(prompt)
212 const [progress, findings] = await Promise.all([repo.read(`${dir}/progress.md`), repo.read(`${dir}/findings.md`)])
213 const times = await Promise.all(['prompt.md', 'progress.md', 'findings.md', 'log.md'].map(name => repo.mtime(`${dir}/${name}`)))
214 const text = { prompt: lf(prompt), progress: isOpen || entry.name === pinned ? lf(progress ?? '') : '', findings: isOpen ? lf(findings ?? '') : '' }
215 folders.push({ slug: entry.name, dir, text, times })
216 }
217 const key = main ? [main.sha, await shaOf(repo, 'HEAD'), ...folders.map(one => `${one.slug}:${one.times.join(',')}`)].join('|') : ''
218 const local = !main ? null : cache.local?.key === key ? cache.local : await readLocal(repo, main.sha, key)
219 /** @type {Map<string, IntentFiles>} */
220 const out = new Map()
221 for (const [slug, folder] of main?.folders ?? []) {
222 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 })
223 }
224 for (const { slug, dir, text, times } of folders) {
225 const onMain = main?.folders.get(slug)
226 if (onMain && slug !== pinned && !localWins(text, onMain, Boolean(local && (local.dirty.has(slug) || local.committed.has(slug))))) continue
227 const [prompt = 0, progress = 0, , log = 0] = times
228 out.set(slug, {
229 slug,
230 ...text,
231 files: slug === pinned ? (await repo.list(dir).catch(() => [])).map(one => one.name) : [],
232 hasDebrief: false,
233 updatedAt: Math.max(prompt, progress, log),
234 source: 'local',
235 firstAuthor: onMain?.firstAuthor ?? '',
236 })
237 }
238 return { isRepo, cache: { main, local }, intents: [...out.values()] }
239}
240
241/**
242 * Where the background fetch stands, for the sync line. `lock`: the git lock a failed fetch ran into.
243 * `base`: the branch fetched, when the checkout's team does not merge into main.
244 * @typedef {{ isRepo: boolean, hasMain: boolean, isFetching: boolean, triedAt: number, fetchedAt: number, failedAt: number, error: string, lock: string, base?: string }} Sync
245 */
246
247/** @type {Sync} */
248export const NO_SYNC = { isRepo: false, hasMain: false, isFetching: false, triedAt: 0, fetchedAt: 0, failedAt: 0, error: '', lock: '' }
249
250// A fetch may start on its own: a git checkout, none running, the last try at least ten minutes ago (or never).
251/** @param {Sync} sync @param {number} now */
252export const isFetchDue = (sync, now) => sync.isRepo && !sync.isFetching && (sync.triedAt === 0 || now - sync.triedAt >= FETCH_EVERY_MS)
253
254// ↻ fetches at once, except after a fetch that ran into a git lock: that one waits for its next due time.
255/** @param {Sync} sync @param {number} now */
256export const canFetchNow = (sync, now) => sync.isRepo && !sync.isFetching && (!sync.lock || isFetchDue(sync, now))
257
258// The lock a failed git call names ("refs/remotes/origin/main.lock", "index.lock"), inside .git; '' when none.
259/** @param {string} text */
260export const lockOf = text => {
261 const path = (/([^\s'"]+\.lock)\b/.exec(text)?.[1] ?? '').replace(/\\/g, '/')
262 return path.includes('/.git/') ? path.slice(path.lastIndexOf('/.git/') + 6) : path.split('/').pop() ?? ''
263}
264
265// Fetches origin's main, or the `base` given. Synced means git said so and origin/<base> resolves after it (`moved`: it moved).
266// On a failure: git's last line of complaint, and the lock it ran into, if any. `isAborted`: the app took the
267// run back, so nothing was tried.
268/** @param {Repo} repo @param {string} [base] @returns {Promise<{ error: string, lock: string, moved: boolean, isAborted?: true }>} */
269export const fetchMain = async (repo, base = 'main') => {
270 const before = await shaOf(repo, refOf(base))
271 const ran = await repo.git(base === 'main' ? FETCH_ARGS : fetchArgs(base), { timeoutMs: FETCH_TIMEOUT_MS })
272 const after = await shaOf(repo, refOf(base))
273 if (ran.exitCode === 0 && after) return { error: '', lock: '', moved: after !== before }
274 const stderr = ran.stderr ?? ''
275 return { error: (stderr.trim().split('\n').pop() || `git fetch exited with ${ran.exitCode}`).slice(0, 200), lock: lockOf(stderr), moved: false, ...(ran.isAborted ? { isAborted: /** @type {const} */ (true) } : {}) }
276}
277
278// One sync line for several checkouts: a failed fetch in any of them, else one running, else the least
279// recently synced (never synced counts as the least). One checkout's is its own.
280/** @param {readonly Sync[]} syncs @returns {Sync} */
281export const syncSummary = syncs => {
282 const repos = syncs.filter(one => one.isRepo)
283 const failed = repos.find(one => one.failedAt > one.fetchedAt && !one.isFetching)
284 return failed ?? repos.find(one => one.isFetching) ?? [...repos].sort((a, b) => a.fetchedAt - b.fetchedAt)[0] ?? syncs[0] ?? NO_SYNC
285}
286
287/** @param {number} ms */
288const agoText = ms => {
289 const minutes = Math.floor(Math.max(0, ms) / 60000)
290 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
291}
292
293// "synced 4 min ago ↻": how fresh the list is. A failed fetch says so, naming the git lock it ran
294// into, and keeps the last good time.
295/** @param {Sync} sync @param {number} now */
296export const syncText = (sync, now) => {
297 if (!sync.isRepo) return ''
298 if (sync.isFetching) return 'syncing…'
299 const synced = sync.fetchedAt ? `synced ${agoText(now - sync.fetchedAt)}` : ''
300 if (sync.failedAt > sync.fetchedAt) return `${sync.lock ? `sync waits on ${sync.lock}` : 'sync failed'}${synced ? ` · ${synced}` : ''} ↻`
301 if (synced) return `${synced} ↻`
302 return sync.hasMain ? 'not synced yet ↻' : `no ${refOf(sync.base ?? 'main')} ↻`
303}
304
305// What git said when the last fetch failed, for the line under "sync failed"; a named lock says enough on its own line.
306/** @param {Sync} sync */
307export const syncError = sync => (sync.failedAt > sync.fetchedAt && !sync.isFetching && !sync.lock ? sync.error : '')
308hooks/pane.mjs 253 lines1// @ts-check
2// Ather Board: the pane's look. It draws the sections views.mjs says a view shows, with the elements
3// it is handed (`el`) and Ather's own look: a label in spaced-out capitals (lime for what needs someone),
4// each group under it a bold title with its rollup, and Ather's row anatomy (rows.mjs workLine: glyph,
5// title, warning, bar, count, age, owner). For you leads (focus.mjs), the suggestions above it; the ask
6// box sits over every view; a row opens in place with what can be done about it (act.mjs).
7// Pure: no `$`; every press arrives as a closure from index.mjs.
8
9import { AMBER, LIME, QUIET, fit, label, section, statusLine, workLine } from './ather/rows.mjs'
10import { BAR_CELLS, ageText, miniBar, rowColumns } from './ather/worklist.mjs'
11import { ACTION_LABELS, actionsFor } from './act.mjs'
12import { isMine } from './ather/model.mjs'
13import { paneView } from './focus.mjs'
14import { GROUPS, PANE_VIEWS, SORTS, STAGES, VIEW_LABELS, choices, summaryText } from './views.mjs'
15
16/**
17 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
18 * @typedef {import('./views.mjs').Filter} Filter
19 * @typedef {import('./views.mjs').Look} Look
20 * @typedef {import('./views.mjs').Section} Section
21 * @typedef {import('./views.mjs').IntentGroup} IntentGroup
22 * @typedef {ReturnType<typeof rowColumns>} Columns
23 * @typedef {import('./views.mjs').View} View
24 * @typedef {{
25 * onView: (view: View) => () => void, onFilter: (patch: Partial<Filter>) => void, onSort: () => void, onGroup: () => void, onBy: () => void,
26 * onPick: (by: 'owner' | 'area', value: string) => void, onRow: (record: BoardRecord) => () => void, onOpenPage: () => void, onPublish: () => void, onSync: () => void,
27 * onAction: (kind: import('./act.mjs').ActionKind, records: readonly BoardRecord[]) => void, onDecide: (record: BoardRecord, call: import('./views.mjs').Call, letter: string) => void,
28 * onSuggestion: (suggestion: Suggestion) => void, onBriefAgain: () => void, onMapAgain: () => void, onAsk: (question: string) => void, onClearAsk: () => void,
29 * }} Actions
30 * @typedef {import('./focus.mjs').Suggestion} Suggestion
31 * @typedef {{
32 * records: BoardRecord[], look: Look, width: number, isDesktop: boolean, now: number, title: string, fresh: string, error: string, note: string,
33 * me: string, opened: string, suggestions: Suggestion[], modules: import('./modules.mjs').Module[],
34 * brief: import('./assist.mjs').Job<import('./brief.mjs').Brief | null>, asked: import('./assist.mjs').Job<import('./brief.mjs').Answer | null>,
35 * mapped: import('./assist.mjs').Job<import('./assist.mjs').Mapped>,
36 * }} PaneModel `opened`: the key of the intent opened in place ('' none); `suggestions`: the brief's and the board's own, merged;
37 * `brief`, `asked`, `mapped`: the model's jobs (assist.mjs)
38 */
39
40// A list longer than this ends in a line pointing at the page, which shows them all.
41export const ROW_CAP = 60
42
43/** @param {string} stage */
44const glyphOf = stage => STAGES.find(one => one.key === stage)?.glyph ?? '·'
45
46// A heading and its meta beside it, quiet: a label spaced out, or a group's title in bold (D7 of Ather's look).
47/** @param {any} el @param {string} key @param {any} head @param {string} meta */
48const withMeta = (el, key, head, meta) => el.Box({ key, flexDirection: 'row', gap: 2, children: [head, ...(meta ? [el.Text({ key: `${key}-meta`, color: QUIET, wrap: 'truncate', children: meta })] : [])] })
49
50// A row's cells as Ather's list has them; in a group of one owner, that column shows the area.
51/** @param {PaneModel} model @param {IntentGroup} part @param {import('./views.mjs').Row} one @returns {import('./ather/worklist.mjs').RowCells} */
52const cellsOf = (model, part, { record, flags }) => ({
53 glyph: glyphOf(record.stage),
54 title: record.slug,
55 warn: flags.map(flag => flag.label).join(' · '),
56 repo: '',
57 bar: miniBar(record.done, record.total),
58 count: record.total ? `${record.done}/${record.total}` : '',
59 age: ageText(record.updatedAt, model.now),
60 owner: part.hide === 'owner' ? record.area : record.owner,
61})
62
63// A group of intents as lines: its head, then one anatomy, the right-hand columns (`cols`) lined up across
64// every group the view shows; Open items lists each intent's items under its row.
65/** @param {any} el @param {PaneModel} model @param {Actions} actions @param {IntentGroup} part @param {Columns} cols */
66const groupLines = (el, model, actions, part, cols) => {
67 const shown = part.rows.slice(0, ROW_CAP)
68 const more = part.rows.length - shown.length
69 return [
70 ...(part.title ? [withMeta(el, `${part.key}-head`, el.Text({ key: `${part.key}-title`, bold: true, children: fit(part.title, Math.max(8, model.width - part.meta.length - 2)) }), part.meta)] : []),
71 ...shown.flatMap(one => [
72 workLine(el, { key: `${part.key}-${one.record.key}`, cells: cellsOf(model, part, one), cols, width: model.width, ownerColour: QUIET, onPress: actions.onRow(one.record), isClicked: model.isDesktop }),
73 ...(model.opened === one.record.key ? [opened(el, model, actions, one.record, part.key)] : []),
74 ...one.items.map(item => el.Text({ key: `${part.key}-${one.record.key}-${item.id}`, color: QUIET, wrap: 'truncate', children: fit(` ${item.id} ${item.text}`, model.width) })),
75 ]),
76 ...(more > 0 ? [el.Text({ key: `${part.key}-more`, color: QUIET, children: `+${more} more: o opens the page with every one` })] : []),
77 ...(part.rows.length === 0 && part.empty ? [el.Text({ key: `${part.key}-empty`, color: QUIET, children: part.empty })] : []),
78 ]
79}
80
81// An intent opened in place: its goal, its decisions with their options (the owner's own to answer), and
82// what can be done about it, its next action first. Every press hands the session one prompt (act.mjs).
83/** @param {any} el @param {PaneModel} model @param {Actions} actions @param {BoardRecord} record @param {string} at */
84const opened = (el, model, actions, record, at) => {
85 const isOwn = isMine(record, model.me)
86 const key = `${at}-${record.key}-open`
87 const [next, ...rest] = actionsFor(record, isOwn)
88 return el.Box({
89 key,
90 flexDirection: 'column',
91 width: '100%',
92 paddingLeft: 2,
93 marginBottom: 1,
94 children: [
95 ...(record.goal ? [el.Text({ key: `${key}-goal`, color: QUIET, wrap: 'wrap', children: record.goal })] : []),
96 ...(isOwn
97 ? record.calls.map(call =>
98 el.Box({ key: `${key}-${call.id}`, flexDirection: 'column', marginTop: 1, children: [
99 el.Text({ key: `${key}-${call.id}-title`, color: AMBER, wrap: 'wrap', children: `${call.id} ${call.title}` }),
100 el.Box({ key: `${key}-${call.id}-options`, flexDirection: 'row', flexWrap: 'wrap', gap: 2, children: [
101 ...call.options.map(option => el.Button({ key: `decide-${record.key}-${call.id}-${option.letter}`, label: `${option.letter}: ${option.label}${option.isRecommended ? ' (recommended)' : ''}`, plain: true, onPress: () => actions.onDecide(record, call, option.letter) })),
102 el.Button({ key: `decide-${record.key}-${call.id}-explain`, label: 'Explain', plain: true, dimColor: true, onPress: () => actions.onDecide(record, call, '') }),
103 ] }),
104 ] }),
105 )
106 : []),
107 el.Box({ key: `${key}-acts`, flexDirection: 'row', flexWrap: 'wrap', gap: 2, marginTop: 1, children: [
108 ...(next ? [el.Button({ key: `act-${record.key}-${next}`, label: ACTION_LABELS[next], variant: 'primary', onPress: () => actions.onAction(next, [record]) })] : []),
109 ...rest.map(kind => el.Button({ key: `act-${record.key}-${kind}`, label: ACTION_LABELS[kind], plain: true, dimColor: true, onPress: () => actions.onAction(kind, [record]) })),
110 ] }),
111 ],
112 })
113}
114
115// The ask box, over every view: a question to the model, and its answer while the board shows its cut.
116/** @param {any} el @param {PaneModel} model @param {Actions} actions */
117const askBox = (el, model, actions) => {
118 const { asked } = model
119 const said = asked.state === 'running' ? 'Reading the board…' : asked.state === 'ready' ? (asked.value?.answer ?? '') : asked.state === 'failed' ? `No answer: ${asked.error}` : ''
120 return el.Box({ key: 'ask-box', flexDirection: 'column', width: '100%', marginTop: 1, children: [
121 el.Input({ key: 'ask', label: 'Ask', placeholder: 'what is blocking combat? whose decisions are oldest?', value: asked.key, submitLabel: 'ask Sonnet', onSubmit: (/** @type {string} */ question) => actions.onAsk(question) }),
122 ...(said ? [el.Box({ key: 'ask-said', flexDirection: 'row', gap: 2, children: [
123 el.Box({ key: 'ask-said-words', flexGrow: 1, flexShrink: 1, children: [el.Text({ key: 'ask-said-text', color: asked.state === 'ready' ? undefined : QUIET, wrap: 'wrap', children: said })] }),
124 ...(asked.state === 'running' ? [] : [el.Button({ key: 'ask-clear', label: 'Clear', hotkey: 'x', plain: true, dimColor: true, onPress: actions.onClearAsk })]),
125 ] })] : []),
126 ] })
127}
128
129// The brief and the board's own suggestions, each a press that acts or cuts the board.
130/** @param {any} el @param {PaneModel} model @param {Actions} actions */
131const briefSection = (el, model, actions) => {
132 const { brief } = model
133 const status = brief.state === 'running' ? 'Sonnet is reading the board…' : brief.state === 'failed' ? `No brief: ${brief.error}` : brief.state === 'ready' ? `Sonnet, ${ageText(brief.at, model.now) || 'just now'}` : ''
134 const headline = brief.value?.headline ?? ''
135 return section(el, 'brief', [
136 el.Box({ key: 'brief-head', flexDirection: 'row', gap: 2, children: [
137 label(el, 'brief-label', 'Suggested', Math.max(9, model.width - status.length - 8)),
138 el.Button({ key: 'brief-again', label: '↻', hotkey: 'r', plain: true, dimColor: true, onPress: actions.onBriefAgain }),
139 ...(status ? [el.Text({ key: 'brief-status', color: QUIET, wrap: 'truncate', children: status })] : []),
140 ] }),
141 ...(headline ? [el.Text({ key: 'brief-headline', wrap: 'wrap', children: headline })] : []),
142 ...model.suggestions.map(one =>
143 el.Box({ key: `suggest-${one.key}`, flexDirection: 'column', children: [
144 el.Button({ key: `suggest-${one.key}-press`, label: `› ${one.title}`, plain: true, onPress: () => actions.onSuggestion(one) }),
145 ...(one.why ? [el.Text({ key: `suggest-${one.key}-why`, color: QUIET, wrap: 'wrap', children: ` ${one.why}` })] : []),
146 ] }),
147 ),
148 ...(model.suggestions.length === 0 && brief.state !== 'running' ? [el.Text({ key: 'brief-none', color: QUIET, children: 'Nothing to suggest right now.' })] : []),
149 ])
150}
151
152// A top-level label over its groups, with no blank line between them.
153/** @param {any} el @param {PaneModel} model @param {Actions} actions @param {Extract<Section, { kind: 'label' }>} part @param {Columns} cols */
154const labelSection = (el, model, actions, part, cols) =>
155 section(el, part.key, [
156 withMeta(el, `${part.key}-head`, label(el, `${part.key}-label`, part.title, model.width - part.meta.length - 2, part.isLoud ? LIME : undefined), part.meta),
157 ...part.groups.map(group => el.Box({ key: group.key, flexDirection: 'column', width: '100%', children: groupLines(el, model, actions, group, cols) })),
158 ])
159
160// People or Areas: a line each; pressing one opens the table of its intents.
161/** @param {any} el @param {Actions} actions @param {Extract<Section, { kind: 'lines' }>} part */
162const lineSection = (el, actions, part) => {
163 const width = Math.min(28, Math.max(4, ...part.lines.map(line => line.title.length)))
164 return section(el, part.key, part.lines.map(line =>
165 el.Box({
166 key: `${part.key}-${line.key}`,
167 flexDirection: 'row',
168 gap: 2,
169 children: [
170 el.Box({ key: `${part.key}-${line.key}-name`, width, flexShrink: 0, children: [el.Button({ key: `${part.key}-${line.key}-press`, label: fit(line.title, width), plain: true, onPress: () => actions.onPick(part.by, line.key) })] }),
171 el.Text({ key: `${part.key}-${line.key}-words`, color: QUIET, wrap: 'truncate', children: line.words }),
172 ...(line.warn ? [el.Text({ key: `${part.key}-${line.key}-warn`, color: AMBER, wrap: 'truncate', children: line.warn })] : []),
173 ],
174 }),
175 ))
176}
177
178// The search field, the pickers, and the toggles of the view in hand.
179/** @param {any} el @param {PaneModel} model @param {Actions} actions */
180const filters = (el, model, actions) => {
181 const { filter, view } = model.look
182 const all = choices(model.records)
183 /** @param {string} key @param {string} text @param {readonly string[]} values @param {string} current @param {(value: string) => void} onSelect @param {(value: string) => string} [name] */
184 const pick = (key, text, values, current, onSelect, name = value => value) =>
185 el.Select({ key, label: text, value: current, options: [{ key: `${key}-any`, value: '', label: 'any' }, ...values.map(value => ({ key: `${key}-${value}`, value, label: name(value) }))], onSelect })
186 const toggles = [
187 ...(view === 'table' ? [el.Button({ key: 'sort', label: `Sort: ${SORTS[model.look.sort]}`, hotkey: 's', plain: true, onPress: actions.onSort }), el.Button({ key: 'group', label: `Group: ${GROUPS[model.look.group]}`, hotkey: 'g', plain: true, onPress: actions.onGroup })] : []),
188 ...(view === 'items' ? [el.Button({ key: 'by', label: `By ${model.look.by}`, hotkey: 'g', plain: true, onPress: actions.onBy })] : []),
189 ...(view === 'table' ? [el.Button({ key: 'done', label: filter.showDone ? 'Done shown' : 'Done hidden', hotkey: 'd', plain: true, dimColor: !filter.showDone, onPress: () => actions.onFilter({ showDone: !filter.showDone }) })] : []),
190 ]
191 return el.Box({
192 key: 'filters',
193 flexDirection: 'column',
194 width: '100%',
195 marginTop: 1,
196 children: [
197 el.Input({ key: 'words', label: 'Search', placeholder: 'words from a name, title, area or owner', value: filter.words, onInput: (/** @type {string} */ words) => actions.onFilter({ words }), onSubmit: (/** @type {string} */ words) => actions.onFilter({ words }) }),
198 el.Box({
199 key: 'pickers',
200 flexDirection: 'row',
201 flexWrap: 'wrap',
202 gap: 2,
203 children: [
204 pick('area', 'Area', all.areas, filter.area, area => actions.onFilter({ area })),
205 pick('owner', 'Owner', all.owners, filter.owner, owner => actions.onFilter({ owner })),
206 pick('stage', 'Stage', STAGES.map(stage => stage.key), filter.stage, stage => actions.onFilter({ stage }), key => STAGES.find(stage => stage.key === key)?.label ?? key),
207 ...toggles,
208 ],
209 }),
210 ],
211 })
212}
213
214/** @param {any} el @param {PaneModel} model @param {Actions} actions */
215export const boardPane = (el, model, actions) => {
216 const { look } = model
217 const { shown: records, parts, extras } = paneView(model.records, look, { me: model.me, modules: model.modules, mapping: model.mapped.value.mapping, now: model.now })
218 const { mapped } = model
219 const mapStatus = mapped.state === 'running' ? `Sonnet is mapping ${mapped.value.missing} intents onto the modules\u2026` : mapped.state === 'failed' ? `Mapping stopped: ${mapped.error}` : ''
220 const groups = parts.flatMap(part => (part.kind === 'label' ? part.groups : part.kind === 'intents' ? [part] : []))
221 const cols = rowColumns(groups.flatMap(group => group.rows.slice(0, ROW_CAP).map(one => cellsOf(model, group, one))), model.width < 90 ? 12 : 22)
222 return el.Box({
223 flexDirection: 'column',
224 width: '100%',
225 children: [
226 label(el, 'title', model.title, model.width),
227 statusLine(el, { text: summaryText(model.records, model.now), fresh: model.fresh, width: model.width, hotkey: 'f', onPress: actions.onSync, error: model.error }),
228 el.Box({
229 key: 'tabs',
230 flexDirection: 'row',
231 flexWrap: 'wrap',
232 gap: 2,
233 marginTop: 1,
234 children: [
235 ...PANE_VIEWS.map((view, index) => el.Button({ key: `tab-${view}`, label: VIEW_LABELS[view], hotkey: String(index + 1), plain: true, dimColor: view !== look.view, onPress: actions.onView(view) })),
236 // The views, then apart from them what acts beyond the pane.
237 el.Text({ key: 'tabs-apart', color: QUIET, children: '│' }),
238 el.Button({ key: 'open-page', label: 'Open the page', hotkey: 'o', plain: true, dimColor: true, onPress: actions.onOpenPage }),
239 el.Button({ key: 'publish', label: 'Publish', hotkey: 'p', plain: true, dimColor: true, onPress: actions.onPublish }),
240 ],
241 }),
242 askBox(el, model, actions),
243 ...(extras.filters ? [filters(el, model, actions)] : []),
244 ...(model.note ? [el.Text({ key: 'note', color: QUIET, wrap: 'wrap', children: model.note })] : []),
245 ...(records.length !== model.records.length ? [el.Text({ key: 'shown', color: QUIET, children: `${records.length} of ${model.records.length} shown` })] : []),
246 ...(extras.brief ? [briefSection(el, model, actions)] : []),
247 ...(extras.mapping && mapStatus ? [el.Box({ key: 'map-status', flexDirection: 'row', gap: 2, marginTop: 1, children: [el.Text({ key: 'map-status-text', color: QUIET, wrap: 'wrap', children: mapStatus }), el.Button({ key: 'map-again', label: '\u21bb', plain: true, dimColor: true, onPress: actions.onMapAgain })] })] : []),
248 ...parts.map(part => (part.kind === 'label' ? labelSection(el, model, actions, part, cols) : part.kind === 'lines' ? lineSection(el, actions, part) : section(el, part.key, groupLines(el, model, actions, part, cols)))),
249 el.Text({ key: 'legend', color: QUIET, wrap: 'wrap', children: `${STAGES.map(stage => `${stage.glyph} ${stage.label}`).join(' ')} · bar: ${'▰'.repeat(BAR_CELLS)} items met` }),
250 ],
251 })
252}
253hooks/page.mjs 64 lines1// @ts-check
2// Ather Board: the board as one self-contained HTML page, for the browser and for the shared site.
3// Nothing is fetched: the styles, the script, the board's data and the views it cuts them with are all
4// inside. The views are views.mjs's own functions, written in by their source. Pure: no `$`.
5
6import * as map from './map.mjs'
7import * as views from './views.mjs'
8
9/** @typedef {import('./snapshot.mjs').Board} Board */
10/** @typedef {import('./brief.mjs').Brief} Brief */
11
12/** @param {string} text */
13const escapeHtml = text => text.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char)
14
15// views.mjs's and map.mjs's exports as script: `const name = <source>` for each function, its JSON for each value.
16export const viewsScript = () =>
17 [...Object.entries(views), ...Object.entries(map)]
18 .map(([name, value]) => `const ${name} = ${typeof value === 'function' ? String(value) : JSON.stringify(value)};`)
19 .join('\n')
20
21/**
22 * @param {Board} board
23 * @param {{ now: number, repoName: string, repoUrl: string, css: string, client: string, brief?: Brief | null, briefAt?: number, modules?: import('./modules.mjs').Module[], mapping?: import('./modules.mjs').Mapping }} page
24 * `repoUrl`: the repository's web address, for PR and issue links ('' for none); `css`, `client`: page/board.css and page/client.js;
25 * `brief`: the latest brief, shown read-only at the top of Triage (A14), and when it was made; `modules`, `mapping`: the game's
26 * modules and which intents work on which, for the Map (A15)
27 */
28export const renderPage = (board, { now, repoName, repoUrl, css, client, brief = null, briefAt = 0, modules = [], mapping = {} }) => {
29 const data = JSON.stringify({ records: board.records, now, repoUrl, sha: board.sha, base: board.base, brief, briefAt, modules, mapping })
30 // Inside <script>, "</" could end the element: written as "<\/".
31 const script = (/** @type {string} */ text) => text.replace(/<\//g, '<\\/')
32 const title = `${repoName ? `${repoName} ` : ''}intents`
33 const at = new Date(now).toISOString().replace('T', ' ').slice(0, 16)
34 return `<!doctype html>
35<html lang="en">
36<head>
37<meta charset="utf-8">
38<meta name="viewport" content="width=device-width, initial-scale=1">
39<title>${escapeHtml(title)}</title>
40<style>
41${css}
42</style>
43</head>
44<body>
45<header>
46<p class="brand">Ather Board</p>
47<h1>${escapeHtml(title)}</h1>
48<p id="summary"></p>
49</header>
50<nav id="tabs" role="tablist"></nav>
51<div id="controls"></div>
52<div id="shown"></div>
53<main id="view"></main>
54<footer>Read from ${escapeHtml(board.sha ? `origin/${board.base} at ${board.sha.slice(0, 12)}` : 'the checkout')} on ${at} UTC by Ather Board. Read-only: change an intent in its files.</footer>
55<script type="application/json" id="board-data">${script(data)}</script>
56<script>
57${script(viewsScript())}
58${script(client)}
59</script>
60</body>
61</html>
62`
63}
64hooks/snapshot.mjs 60 lines1// @ts-check
2// Ather Board: the board's records for one checkout, read with Ather's reader (team.mjs readTeam:
3// origin/<base> through git, never the index lock, plus the checkout's own folders), each dated by
4// its folder's first commit. Ather's reader leaves out the progress of completed intents (its list does
5// not show them); the board shows them, so it reads that progress itself, once per commit, or a done
6// intent would read as none of its items met. Pure: git and the files come through the Repo it is given.
7
8import { blobs, parseLog, readTeam } from './ather/team.mjs'
9import { toRecord } from './records.mjs'
10
11/**
12 * @typedef {import('./ather/team.mjs').Repo} Repo
13 * @typedef {import('./ather/team.mjs').TeamCache} TeamCache
14 * @typedef {import('./ather/team.mjs').MainSnapshot} MainSnapshot
15 * @typedef {import('./ather/packs/index.mjs').Pack} Pack
16 * @typedef {import('./views.mjs').BoardRecord} BoardRecord
17 * @typedef {{ team: TeamCache, created: { sha: string, at: Map<string, number> } | null, closed: { sha: string, progress: Map<string, string> } | null }} BoardCache
18 * what the last read learned: first commits, and the progress of the intents Ather's reader left without it
19 * @typedef {{ records: BoardRecord[], sha: string, base: string, isRepo: boolean, readAt: number }} Board
20 */
21
22/** @type {BoardCache} */
23export const EMPTY_BOARD_CACHE = { team: { main: null, local: null }, created: null, closed: null }
24
25// When each folder was first committed on `sha`: the oldest commit adding a file under it (oldest first,
26// so parseLog's first sight of a folder is its first commit).
27/** @param {Repo} repo @param {string} sha */
28const readCreated = async (repo, sha) => {
29 const ran = await repo.git(['log', '--reverse', '--diff-filter=A', '--format=%x00%ct%x09%an', '--name-only', sha, '--', 'docs/intent'])
30 return new Map([...parseLog(ran.exitCode === 0 ? ran.stdout : '')].map(([slug, { at }]) => [slug, at]))
31}
32
33// The progress.md of every folder on main that has one Ather's reader did not read (its completed
34// intents), by slug, in one batch (team.mjs blobs).
35/** @param {Repo} repo @param {MainSnapshot} main */
36const readClosed = async (repo, main) => {
37 const slugs = [...main.folders].filter(([, folder]) => folder.progress === '' && folder.files.includes('progress.md')).map(([slug]) => slug)
38 const texts = await blobs(repo, main.sha, slugs.map(slug => `docs/intent/${slug}/progress.md`))
39 return new Map(slugs.flatMap((slug, index) => { const text = texts[index]; return typeof text === 'string' ? [[slug, text.replace(/\r\n/g, '\n')]] : [] }))
40}
41
42/**
43 * @param {Repo} repo @param {string} root
44 * @param {{ pack: Pack, base: string, cache: BoardCache, now: number }} options
45 * @returns {Promise<{ board: Board, cache: BoardCache }>}
46 */
47export const readBoard = async (repo, root, { pack, base, cache, now }) => {
48 const team = await readTeam(repo, root, { cache: cache.team, pinned: null, base })
49 const main = team.cache.main
50 const sha = main?.sha ?? ''
51 const created = !sha ? null : cache.created?.sha === sha ? cache.created : { sha, at: await readCreated(repo, sha) }
52 const closed = !main ? null : cache.closed?.sha === main.sha ? cache.closed : { sha: main.sha, progress: await readClosed(repo, main) }
53 const slugs = new Set(team.intents.map(files => files.slug))
54 const records = team.intents.map(files => {
55 const progress = files.source === 'main' && files.progress === '' ? closed?.progress.get(files.slug) : undefined
56 return toRecord(progress === undefined ? files : { ...files, progress }, { pack, createdAt: created?.at.get(files.slug) ?? 0, now, slugs })
57 })
58 return { board: { records, sha, base, isRepo: team.isRepo, readAt: now }, cache: { team: team.cache, created, closed } }
59}
60hooks/views.mjs 303 lines1// @ts-check
2// Ather Board: what every view shows, for the pane and the page alike. The look (view, filter, sort,
3// group), the records a view sees, the sections it draws (sectionsOf), and the cuts beneath them:
4// filter, sort, group with a rollup per group, triage, the outstanding items and the pulse. The
5// pane (pane.mjs) and the page (page/client.js) only draw the sections. Pure: no `$`.
6//
7// The HTML page runs these same functions in the browser: page.mjs writes each export into the
8// page by its source text, and each value as JSON. So every exported function is self-contained: it
9// names only other exports of this module, Ather's `ageText`, `miniBarSvg` and `BAR_CELLS` (imported,
10// and written into the page too, so both surfaces draw a row's age and bar alike) and the language's
11// own globals. The page test runs every one of them as written into the page.
12
13import { BAR_CELLS, STAGE_BLOCKS, STAGE_GLYPHS, ageText, miniBarSvg } from './ather/worklist.mjs'
14
15export { BAR_CELLS, ageText, miniBarSvg }
16
17/**
18 * @typedef {'plan' | 'build' | 'prove' | 'met' | 'parked' | 'done'} Stage
19 * @typedef {'decisions' | 'met' | 'stale' | 'unparked' | 'plan' | 'status' | 'owner' | 'area'} FlagKind
20 * @typedef {{ kind: FlagKind, label: string }} Flag
21 * @typedef {{
22 * key: string, slug: string, title: string, goal: string, area: string, owner: string, stage: Stage,
23 * status: string, statusNote: string, done: number, total: number, openItems: { id: string, text: string }[],
24 * prs: number[], issue: number | null, calls: Call[], updatedAt: number, createdAt: number,
25 * source: 'main' | 'local', flags: Flag[], mentions: string[]
26 * }} BoardRecord one intent as the board reads it (records.mjs); `calls`: its open findings waiting on a decision;
27 * `mentions`: the other intents its files name
28 * @typedef {{ id: string, title: string, options: { letter: string, label: string, text: string, isRecommended: boolean }[] }} Call a decision waiting: its options as the finding writes them (model.mjs parseOptions); none for an open question
29 * @typedef {{ words: string, area: string, owner: string, stage: string, showDone: boolean, keys: readonly string[] }} Filter '' matches any; `keys`: only these intents (a suggestion's or an answer's), none: any
30 * @typedef {'recent' | 'stale' | 'closest' | 'items'} Sort
31 * @typedef {'owner' | 'area' | 'stage' | 'none'} Group
32 * @typedef {'foryou' | 'triage' | 'board' | 'map' | 'modules' | 'table' | 'items' | 'people' | 'areas' | 'pulse'} View 'foryou': the pane's own (focus.mjs), needing the person's name;
33 * 'modules' (the pane) and 'map' (the page): the intents on the game's modules (map.mjs)
34 * @typedef {{ view: View, filter: Filter, sort: Sort, group: Group, by: 'area' | 'owner' }} Look what the person picked
35 * @typedef {{ count: number, done: number, open: number, parked: number, items: number, calls: number, stale: number, at: number }} Rollup `open`: neither done nor parked; `calls`: decisions waiting
36 */
37
38// In the order the board's columns and stage groups run: Ather's list stages (worklist.mjs), with
39// Plan before them and Done after.
40export const STAGES = [
41 { key: 'plan', label: 'Plan', glyph: '○' },
42 ...(/** @type {const} */ (['build', 'prove', 'met', 'parked'])).map(key => ({ key, label: STAGE_BLOCKS.find(block => block.key === key)?.title ?? key, glyph: STAGE_GLYPHS[key] })),
43 { key: 'done', label: 'Done', glyph: '■' },
44]
45
46/** @type {Record<Sort, string>} */
47export const SORTS = { recent: 'Recent', stale: 'Stalest first', closest: 'Closest to done', items: 'Most open items' }
48/** @type {Record<Group, string>} */
49export const GROUPS = { owner: 'Owner', area: 'Area', stage: 'Stage', none: 'No groups' }
50// In the order a press cycles them.
51export const SORT_KEYS = /** @type {const} */ (['recent', 'stale', 'closest', 'items'])
52export const GROUP_KEYS = /** @type {const} */ (['owner', 'area', 'stage', 'none'])
53
54export const DAY_MS = 86400000
55// Open with no commit for this long: stale.
56export const STALE_DAYS = 5
57
58// What needs someone, worst first: Triage groups its intents by the worst of these they have, under
59// these titles. Then the flags of data hygiene, listed apart.
60export const REASONS = /** @type {const} */ ([
61 { kind: 'decisions', title: 'Decisions waiting' },
62 { kind: 'met', title: 'Ready to close' },
63 { kind: 'stale', title: `No change for ${STALE_DAYS} days or more` },
64 { kind: 'unparked', title: 'Parked without a reason' },
65 { kind: 'plan', title: 'No acceptance checklist' },
66])
67export const HYGIENE = /** @type {readonly FlagKind[]} */ (['status', 'owner', 'area'])
68export const FLAG_ORDER = [...REASONS.map(reason => reason.kind), ...HYGIENE]
69// The warnings a row carries: what needs someone, less ready to close and no checklist, which the row's
70// stage glyph (✓, ○), or its column, already says.
71export const WARNINGS = /** @type {readonly FlagKind[]} */ (['decisions', 'stale', 'unparked'])
72
73export const VIEW_LABELS = /** @type {const} */ ({ foryou: 'For you', triage: 'Triage', board: 'Board', map: 'Map', modules: 'Modules', table: 'Table', items: 'Open items', people: 'People', areas: 'Areas', pulse: 'Pulse' })
74// The pane's views, in its tab order; the page has its own (page/client.js), adding Board, Map and Pulse.
75export const PANE_VIEWS = /** @type {const} */ (['foryou', 'triage', 'modules', 'table', 'items', 'people', 'areas'])
76// The views that hide done intents unless they are shown; the rest count every intent (Open items lists none of theirs).
77export const HIDES_DONE = /** @type {readonly View[]} */ (['board', 'table'])
78
79/** @type {Filter} */
80export const NO_FILTER = { words: '', area: '', owner: '', stage: '', showDone: false, keys: [] }
81
82/** @type {Look} */
83export const DEFAULT_LOOK = { view: 'triage', filter: NO_FILTER, sort: 'recent', group: 'owner', by: 'area' }
84
85// The words typed match when each starts a word of the intent's key, title, goal, area or owner.
86/** @param {BoardRecord} record @param {Filter} filter */
87export const matches = (record, filter) => {
88 if (!filter.showDone && record.stage === 'done' && filter.stage !== 'done') return false
89 if (filter.area && record.area !== filter.area) return false
90 if (filter.owner && record.owner !== filter.owner) return false
91 if (filter.stage && record.stage !== filter.stage) return false
92 if (filter.keys.length > 0 && !filter.keys.includes(record.key)) return false
93 const words = filter.words.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)
94 if (words.length === 0) return true
95 const hay = `${record.key} ${record.title} ${record.goal} ${record.area} ${record.owner}`.toLowerCase().split(/[^\p{L}\p{N}]+/u)
96 return words.every(word => hay.some(part => part.startsWith(word)))
97}
98
99/** @param {readonly BoardRecord[]} records @param {Filter} filter */
100export const filterRecords = (records, filter) => records.filter(record => matches(record, filter))
101
102// Recent: last change first. Stalest: open work longest untouched first, done last. Closest to done: the
103// share of the checklist met, then the fewest items left. Most open items: the longest list left.
104/** @param {readonly BoardRecord[]} records @param {Sort} sort */
105export const sortRecords = (records, sort) => {
106 const left = (/** @type {BoardRecord} */ one) => one.total - one.done
107 const share = (/** @type {BoardRecord} */ one) => (one.total > 0 ? one.done / one.total : -1)
108 /** @type {Record<Sort, (a: BoardRecord, b: BoardRecord) => number>} */
109 const by = {
110 recent: (a, b) => b.updatedAt - a.updatedAt,
111 stale: (a, b) => Number(a.stage === 'done') - Number(b.stage === 'done') || a.updatedAt - b.updatedAt,
112 closest: (a, b) => share(b) - share(a) || left(a) - left(b) || b.updatedAt - a.updatedAt,
113 items: (a, b) => left(b) - left(a) || b.updatedAt - a.updatedAt,
114 }
115 return [...records].sort(by[sort] ?? by.recent)
116}
117
118/** @param {readonly BoardRecord[]} records @returns {Rollup} */
119export const rollup = records => ({
120 count: records.length,
121 done: records.filter(one => one.stage === 'done').length,
122 open: records.filter(one => one.stage !== 'done' && one.stage !== 'parked').length,
123 parked: records.filter(one => one.stage === 'parked').length,
124 items: records.reduce((sum, one) => sum + (one.stage === 'done' ? 0 : one.openItems.length), 0),
125 calls: records.reduce((sum, one) => sum + one.calls.length, 0),
126 stale: records.filter(one => one.flags.some(flag => flag.kind === 'stale')).length,
127 at: records.reduce((latest, one) => Math.max(latest, one.updatedAt), 0),
128})
129
130// "4 intents · 3 done · 6 open items · 2d": a group in one line.
131/** @param {Rollup} sum @param {number} now */
132export const rollupText = (sum, now) =>
133 [`${sum.count} ${sum.count === 1 ? 'intent' : 'intents'}`, sum.done ? `${sum.done} done` : '', sum.items ? `${sum.items} open ${sum.items === 1 ? 'item' : 'items'}` : '', ageText(sum.at, now)].filter(Boolean).join(' · ')
134
135/**
136 * The records in groups, each sorted as asked and with its rollup. Stages run in board order; owners
137 * and areas with the most open work first, then by name. `none`: one group holding everything.
138 * @param {readonly BoardRecord[]} records @param {Group} group @param {Sort} sort
139 * @returns {{ key: string, title: string, records: BoardRecord[], rollup: Rollup }[]}
140 */
141export const groupRecords = (records, group, sort) => {
142 /** @type {(one: BoardRecord) => string} */
143 const keyOf = group === 'owner' ? one => one.owner || 'No owner' : group === 'area' ? one => one.area : group === 'stage' ? one => one.stage : () => ''
144 /** @type {Map<string, BoardRecord[]>} */
145 const buckets = new Map()
146 for (const one of records) buckets.set(keyOf(one), [...(buckets.get(keyOf(one)) ?? []), one])
147 const groups = [...buckets].map(([key, list]) => ({ key, title: group === 'stage' ? (STAGES.find(stage => stage.key === key)?.label ?? key) : key, records: sortRecords(list, sort), rollup: rollup(list) }))
148 const stageAt = (/** @type {string} */ key) => STAGES.findIndex(stage => stage.key === key)
149 return groups.sort((a, b) => (group === 'stage' ? stageAt(a.key) - stageAt(b.key) : b.rollup.open - a.rollup.open || a.title.localeCompare(b.title)))
150}
151
152/** @param {BoardRecord} record */
153export const worstFlag = record => Math.min(...record.flags.map(flag => FLAG_ORDER.indexOf(flag.kind)).filter(at => at >= 0), FLAG_ORDER.length)
154
155/**
156 * What needs someone, team-wide: open intents with a flag that is not data hygiene, worst flag first,
157 * then the longest untouched. `hygiene`: intents whose files the board could not fully read as written.
158 * @param {readonly BoardRecord[]} records
159 */
160export const triage = records => {
161 const isHygiene = (/** @type {Flag} */ flag) => HYGIENE.some(kind => kind === flag.kind)
162 const attention = records
163 .filter(one => one.stage !== 'done' && one.flags.some(flag => !isHygiene(flag)))
164 .sort((a, b) => worstFlag(a) - worstFlag(b) || a.updatedAt - b.updatedAt)
165 const hygiene = records.filter(one => one.flags.some(isHygiene)).sort((a, b) => b.updatedAt - a.updatedAt)
166 return { attention, hygiene }
167}
168
169/**
170 * Every acceptance item not yet met, of every intent not done, grouped by area or owner, the group with
171 * the most items first. Parked intents' items count too: they are still owed.
172 * @param {readonly BoardRecord[]} records @param {'area' | 'owner'} by
173 * @returns {{ title: string, count: number, intents: { record: BoardRecord, items: { id: string, text: string }[] }[] }[]}
174 */
175export const outstanding = (records, by) => {
176 /** @type {Map<string, { record: BoardRecord, items: { id: string, text: string }[] }[]>} */
177 const groups = new Map()
178 for (const record of records) {
179 if (record.stage === 'done' || record.openItems.length === 0) continue
180 const key = by === 'owner' ? record.owner || 'No owner' : record.area
181 groups.set(key, [...(groups.get(key) ?? []), { record, items: record.openItems }])
182 }
183 return [...groups]
184 .map(([title, intents]) => ({ title, count: intents.reduce((sum, one) => sum + one.items.length, 0), intents: intents.sort((a, b) => b.items.length - a.items.length) }))
185 .sort((a, b) => b.count - a.count || a.title.localeCompare(b.title))
186}
187
188/**
189 * The last `weeks` weeks (Monday to Sunday, UTC), oldest first: intents opened (their folder's first
190 * commit) and closed (done, dated by their last commit), and how many there are in each stage now.
191 * @param {readonly BoardRecord[]} records @param {number} now @param {number} [weeks]
192 */
193export const pulse = (records, now, weeks = 8) => {
194 const today = Math.floor(now / DAY_MS) * DAY_MS
195 // 1970-01-01 was a Thursday: day 4 of a week that starts on Monday is 3 days in.
196 const monday = today - (((Math.floor(today / DAY_MS) + 3) % 7) * DAY_MS)
197 const starts = Array.from({ length: weeks }, (_, index) => monday - (weeks - 1 - index) * 7 * DAY_MS)
198 const weekOf = (/** @type {number} */ at) => starts.findIndex(start => at >= start && at < start + 7 * DAY_MS)
199 const rows = starts.map(start => ({ start, opened: 0, closed: 0 }))
200 for (const one of records) {
201 const opened = rows[weekOf(one.createdAt)]
202 if (opened) opened.opened += 1
203 const closed = one.stage === 'done' ? rows[weekOf(one.updatedAt)] : undefined
204 if (closed) closed.closed += 1
205 }
206 const stages = STAGES.map(stage => ({ key: stage.key, label: stage.label, count: records.filter(one => one.stage === stage.key).length }))
207 return { weeks: rows, stages }
208}
209
210// "142 intents · 78 open · 9 parked · 55 done · this week 5 opened, 3 closed".
211/** @param {readonly BoardRecord[]} records @param {number} now */
212export const summaryText = (records, now) => {
213 const { weeks, stages } = pulse(records, now, 1)
214 const count = (/** @type {string} */ key) => stages.find(stage => stage.key === key)?.count ?? 0
215 const week = weeks[0] ?? { opened: 0, closed: 0 }
216 const open = records.length - count('done') - count('parked')
217 return `${records.length} intents · ${open} open · ${count('parked')} parked · ${count('done')} done · this week ${week.opened} opened, ${week.closed} closed`
218}
219
220// The areas and owners present, for the filter pickers: by name.
221/** @param {readonly BoardRecord[]} records */
222export const choices = records => ({
223 areas: [...new Set(records.map(one => one.area))].sort((a, b) => a.localeCompare(b)),
224 owners: [...new Set(records.map(one => one.owner).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
225})
226
227// ---------------------------------------------------------------- what a view shows
228
229// One intent as a row: the flags among `kinds` it warns with, and no items under it.
230/** @param {BoardRecord} record @param {readonly string[]} kinds @returns {Row} */
231export const rowOf = (record, kinds) => ({ record, flags: record.flags.filter(flag => kinds.includes(flag.kind)), items: [] })
232
233// A person or an area picked from People or Areas: the table of their intents, the other filters cleared.
234/** @param {Look} look @param {'owner' | 'area'} by @param {string} value @returns {Look} */
235export const pickLook = (look, by, value) => ({ ...look, view: 'table', filter: { ...NO_FILTER, showDone: look.filter.showDone, [by]: value } })
236
237// The records a view sees: the filters always, done intents only where the view hides them unless shown.
238/** @param {readonly BoardRecord[]} records @param {Look} look */
239export const recordsFor = (records, look) => filterRecords(records, HIDES_DONE.includes(look.view) ? look.filter : { ...look.filter, showDone: true })
240
241// A person's or an area's line: what is in hand, and what needs someone, apart.
242/** @param {Rollup} sum @param {number} now */
243export const rollupLine = (sum, now) => ({
244 words: [`${sum.open} open`, sum.parked ? `${sum.parked} parked` : '', sum.done ? `${sum.done} done` : '', sum.items ? `${sum.items} items` : '', ageText(sum.at, now)].filter(Boolean).join(' · '),
245 warn: [sum.calls ? `${sum.calls} ${sum.calls === 1 ? 'decision' : 'decisions'}` : '', sum.stale ? `${sum.stale} stale` : ''].filter(Boolean).join(' · '),
246})
247
248/**
249 * What a view shows, in two levels: a `label` is a top-level heading over its groups (spaced-out capitals;
250 * `isLoud`: what needs someone, Ather's one lime); a group of `intents` is its rows under its title (bold)
251 * and `meta` (its rollup), with no head when the title is ''; `lines` is People's or Areas' table.
252 * A row: the flags it warns with, the items listed under it (Open items), and the column a group of one
253 * owner or one area leaves out (`hide`, on the group).
254 * @typedef {{ record: BoardRecord, flags: Flag[], items: { id: string, text: string }[] }} Row
255 * @typedef {{ key: string, title: string, rollup: Rollup, words: string, warn: string }} Line
256 * @typedef {{ kind: 'intents', key: string, title: string, meta: string, empty: string, hide: '' | 'owner' | 'area', rows: Row[] }} IntentGroup
257 * @typedef {{ kind: 'label', key: string, title: string, meta: string, isLoud: boolean, groups: IntentGroup[] }
258 * | IntentGroup
259 * | { kind: 'lines', key: string, by: 'owner' | 'area', lines: Line[] }} Section
260 */
261
262/**
263 * What a view shows, as sections both surfaces draw; [] for Pulse (the page's own chart). `records`: recordsFor's.
264 * Board: a group per stage that has intents, in board order.
265 * @param {readonly BoardRecord[]} records @param {Look} look @param {number} now @returns {Section[]}
266 */
267export const sectionsOf = (records, look, now) => {
268 const row = rowOf
269 /** @param {string} key @param {string} title @param {string} meta @param {string} empty @param {Row[]} rows @param {'' | 'owner' | 'area'} [hide] @returns {IntentGroup} */
270 const intents = (key, title, meta, empty, rows, hide = '') => ({ kind: 'intents', key, title, meta, empty, hide, rows })
271 const none = (/** @type {string} */ empty) => [intents('none', '', '', empty, [])]
272 const view = look.view
273 if (view === 'triage') {
274 const { attention, hygiene } = triage(records)
275 const reasons = REASONS.map(reason => ({ ...reason, list: attention.filter(one => FLAG_ORDER[worstFlag(one)] === reason.kind) })).filter(reason => reason.list.length > 0)
276 // A reason's rows leave out the warning its title says; how many decisions wait is each row's own.
277 const groups = reasons.map(reason => intents(`attention-${reason.kind}`, reason.title, String(reason.list.length), '', reason.list.map(one => row(one, WARNINGS.filter(kind => kind !== reason.kind || kind === 'decisions')))))
278 return [
279 { kind: 'label', key: 'label-attention', title: 'Needs someone', meta: String(attention.length), isLoud: true, groups: groups.length > 0 ? groups : none('Nothing is stuck.') },
280 { kind: 'label', key: 'label-hygiene', title: 'Data hygiene', meta: `${hygiene.length} not fully readable`, isLoud: false, groups: [intents('hygiene', '', '', 'Every intent reads cleanly.', hygiene.map(one => row(one, HYGIENE)))] },
281 ]
282 }
283 if (view === 'board') {
284 const columns = STAGES.map(stage => ({ stage, list: sortRecords(records.filter(one => one.stage === stage.key), look.sort) })).filter(column => column.list.length > 0)
285 return columns.length > 0 ? columns.map(({ stage, list }) => intents(stage.key, `${stage.glyph} ${stage.label}`, String(list.length), '', list.map(one => row(one, WARNINGS)))) : none('No intent matches.')
286 }
287 if (view === 'table') {
288 if (records.length === 0) return none('No intent matches.')
289 const hide = look.group === 'owner' || look.group === 'area' ? look.group : ''
290 return groupRecords(records, look.group, look.sort).map(group => intents(`group-${group.key || 'all'}`, group.title, rollupText(group.rollup, now), '', group.records.map(one => row(one, WARNINGS)), hide))
291 }
292 if (view === 'items') {
293 const groups = outstanding(records, look.by)
294 if (groups.length === 0) return none('No open acceptance items.')
295 return groups.map(group => intents(`items-${group.title}`, group.title, `${group.count} open ${group.count === 1 ? 'item' : 'items'}`, '', group.intents.map(one => ({ ...row(one.record, []), items: one.items })), look.by))
296 }
297 if (view === 'people' || view === 'areas') {
298 const by = view === 'people' ? 'owner' : 'area'
299 return [{ kind: 'lines', key: `lines-${by}`, by, lines: groupRecords(records, by, 'recent').map(group => ({ key: group.key, title: group.title, rollup: group.rollup, ...rollupLine(group.rollup, now) })) }]
300 }
301 return []
302}
303