SLOPSHOPPER

ather-board

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…

newpanecommandtoastmodelprocess
v0.1.1no licenseupdated 2026-10-13AskTinNguyen/ather-mods/ather-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ather-board
│ ┃ board ✕ › fix the failing auth test and add an audit log call │ ┃ Ather Board reads a repository’s intents │ ┃ (its docs/intent folder), and this one has ⏺ Read(src/auth.ts) │ ┃ none. Open the session in a checkout that ⎿ Read 6 lines │ ┃ has them. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ ather-board: Ather Board reads a repository’s intents (its docs/ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · board
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.
README

Ather Board

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.

In 30 seconds

  • /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.
  • Ask it anything in the box at the top ("what is blocking combat?"): Sonnet answers in a line or two and cuts the board to what it means. /board ask <question> does the same.
  • Press an intent to open it in place: its decisions with their options as buttons, and its next action (answer, close, unstick, give a reason, write the checklist, fix the header, ask the owner) ready to hand to the session.
  • /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.

Install

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.

The views

ViewWhat 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).
TriageWhat 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).
TableEvery 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 itemsEvery 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, AreasOne 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.

Sonnet's help, and what it costs

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.

Where it reads, and how fresh

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.

The shared page

/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.

Changes

  • 0.1.1 A done intent shows how many of its items were met: the board now reads the progress of completed intents, which Ather's reader leaves out (they read as none met before). Done's glyph is ■, no longer close to Ready to close's ✓.
  • 0.1.0 First release: /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).

Maintainers

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.

Source 27 files
hooks/index.mjs 342 lines
1// @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}
342
hooks/act.mjs 111 lines
1// @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}
111
hooks/assist.mjs 147 lines
1// @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  })
147
hooks/modules.mjs 125 lines
1// @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}
125
hooks/brief.mjs 142 lines
1// @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})
142
hooks/focus.mjs 101 lines
1// @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}
101
hooks/ather/packs/index.mjs 129 lines
1// @ts-check
2// Ather Automata: which pack serves a repository, and the shape every pack has.
3// The core (intents, Plan → Build → Prove → Ship, Next, away windows, issues,
4// the squad) is the same everywhere; a pack says what proof, traps, held
5// actions and words fit the kind of project. Pure apart from the Io it is given.
6//
7// Selection (D3): the repository's .ather/profile.json, then markers (*.uproject
8// → unreal; package.json or pyproject.toml → web), then the core alone. Read once
9// per session and root, and cached.
10
11import { gitDirOf, isAbsolute, normalFolder } from '../workspace.mjs'
12import { core } from './core.mjs'
13import { unreal } from './unreal.mjs'
14import { makeWebPack } from './web.mjs'
15
16/**
17 * @typedef {{ state: 'none' | 'pass' | 'fail', detail: string, at?: number, by?: string }} Rung `at`, `by`: when, and by which session (its first 8 hex), the record was written
18 * @typedef {{ rungs: { rung: string, value: Rung, gates?: RungGates }[], context: string[], toasts: { text: string, timeoutMs?: number }[], bumps: string[] }} ShellReading
19 * @typedef {{ ran: string[], all: string[] }} RungGates on a rung that gates declare: the gates a command ran (none: a command that is no gate), of all that declare it
20 * @typedef {{ id: string, pattern: RegExp, title: string, fix: string, rule?: { file: string, text: string } }} PackTrap
21 * @typedef {{ name: string, verb: string, isGlobal?: boolean }} CreateItem
22 * @typedef {{ isHeld: boolean, isFree: boolean, holder: string, until: string }} EditorState
23 * @typedef {{ command: string, proofs: string[], id?: string, proves?: string, passOn?: 'exit' | 'counts' }} Gate `passOn`: how its tests and ui proofs pass, on its exit code (also without the field) or on test counts
24 * @typedef {{ host: string, branch: string, deployment: string, url: string, expectStatus: number }} Production
25 * @typedef {{ files?: readonly string[], defaultBranch?: string }} Found what else was read of a repository where its pack is chosen: the names of the files at its root, and the default branch its clone knows
26 * @typedef {{ pack: Pack, held: readonly string[], isProven: boolean, base?: string }} HeldAt what a folder's own checkout holds, when that is not the session's; `base`: the branch its team merges into, when not main
27 * @typedef {{ isProven?: boolean, scripts?: Record<string, string>, base?: string, at?: (folder: string | null) => HeldAt | null }} HeldContext `base`: the branch the session's checkout merges into, when not main
28 * @typedef {{
29 *   id: 'unreal' | 'web' | 'core',
30 *   roles: readonly string[], roleLabels: Record<string, string>, roleDescriptions: Record<string, string>,
31 *   roleWords: string, roleHelp: string, roleFallback: string, roleKey: string, parseRole: (text: string) => string | null,
32 *   owners: string, areas: readonly string[], normalizeArea: (text: string) => string,
33 *   rungLabels: Record<string, string>, proofWords: Record<string, string>, emptyEvidence: () => Record<string, Rung>,
34 *   requiredRungs: (role: string) => string[], isProven: (evidence: Record<string, Rung>, role: string) => boolean, anyProofText: string,
35 *   localDir: string, debriefPath: (slug: string) => string,
36 *   lockFile: string | null, parseLock: (raw: string | null, nowMinutes: number) => import('./unreal.mjs').EditorLock, lockRoles: readonly string[],
37 *   lockLine?: (command: string, raw: string) => string,
38 *   ownCheck: { role: string, after: string, rung: string, label: string, hint: string, proveHint: string, detail: string, reply: string } | null,
39 *   traps: readonly PackTrap[],
40 *   held: { labels: Record<string, string>, nouns: Record<string, string>, kinds: readonly string[], defaults: readonly string[] },
41 *   heldSegment: (segment: string, held: readonly string[], context: HeldContext) => string | null,
42 *   mergePolicy: 'hold' | 'with-proof', mergeRungs?: readonly string[], rungGates?: Readonly<Record<string, string[]>>,
43 *   baseBranch?: string,
44 *   isAssetSave: (input: string) => boolean, readShell: (command: string, text: string, ran: { isError?: boolean, deny?: string }) => ShellReading,
45 *   mcpKind: (input: string) => 'write' | 'read' | 'pie' | null, binaryAssets: RegExp | null, briefPaths: RegExp,
46 *   skillGroups: readonly { group: string, names: readonly string[] }[], createGroups: readonly { group: string, items: readonly CreateItem[] }[],
47 *   createOrder: Record<string, readonly string[]>, createTitle: string, createMeta: (editor: EditorState) => string, createPrompt: (verb: string, name: string, editor: EditorState) => string,
48 *   prompts: { brief: (role: string, slug: string) => string, prove: (role: string, slug: string) => string, ship: (role: string, slug: string) => string, shipHint: (role: string) => string, briefHint: string, tour: string, tourToast: string, ask: (question: string) => string },
49 *   mandate: { flags: string, allowed: string, merge: string, away: string, pane: string },
50 *   statusWhat: string, notHere: string, gates: readonly Gate[], production: Production | null, scripts?: Record<string, string>,
51 *   teams?: Readonly<Record<string, string>>, names?: import('../worklist.mjs').NameRules,
52 *   [key: string]: unknown
53 * }} Pack
54 */
55
56/**
57 * What selection reads: closures over the engine, as state.mjs's Io.
58 * @typedef {{ read: (path: string) => Promise<string | null>, exists: (path: string) => Promise<boolean>, list: (path: string) => Promise<{ name: string, kind: string }[]>, sessionId: () => Promise<string> }} PackIo
59 */
60
61/** @param {string | null} text */
62const json = text => {
63  if (!text) return null
64  try {
65    const value = JSON.parse(text)
66    return value && typeof value === 'object' ? value : null
67  } catch {
68    return null
69  }
70}
71
72// The default branch a clone knows: where refs/remotes/origin/HEAD points, in the git folder its worktrees share.
73// Read from the files, with no git call; '' when it cannot be told.
74/** @param {PackIo} io @param {string} root */
75const defaultBranchOf = async (io, root) => {
76  const named = await gitDirOf(io, root)
77  if (!named) return ''
78  // A worktree's .git file may name its git folder from the worktree.
79  const gitDir = named === `${root}/.git` || isAbsolute(named) ? named : normalFolder(`${root}/${named}`)
80  const common = ((await io.read(`${gitDir}/commondir`)) ?? '').trim()
81  const shared = common === '' ? gitDir : isAbsolute(common) ? common : normalFolder(`${gitDir}/${common}`)
82  return /^ref:\s*refs\/remotes\/origin\/(.+)/.exec((await io.read(`${shared}/refs/remotes/origin/HEAD`)) ?? '')?.[1]?.trim() ?? ''
83}
84
85/**
86 * Which pack a repository gets, and why, with the default branch its clone knows ('' when it cannot be told).
87 * Pure over what it reads.
88 * @param {PackIo} io @param {string} root
89 * @returns {Promise<{ pack: Pack, source: 'profile' | 'marker' | 'none', profile: Record<string, any> | null, defaultBranch: string }>}
90 */
91export const choosePack = async (io, root) => {
92  const profile = json(await io.read(`${root}/.ather/profile.json`))
93  const packageJson = json(await io.read(`${root}/package.json`))
94  const defaultBranch = await defaultBranchOf(io, root).catch(() => '')
95  const named = typeof profile?.pack === 'string' ? profile.pack.toLowerCase() : ''
96  if (named === 'unreal') return { pack: unreal, source: 'profile', profile, defaultBranch }
97  if (named === 'core') return { pack: core, source: 'profile', profile, defaultBranch }
98  const entries = await io.list(root).catch(() => [])
99  const files = entries.filter(entry => entry.kind === 'file' && typeof entry.name === 'string').map(entry => entry.name)
100  const web = () => makeWebPack(profile, packageJson, { files, defaultBranch })
101  if (named === 'web') return { pack: web(), source: 'profile', profile, defaultBranch }
102  if (entries.some(entry => entry.kind === 'file' && /\.uproject$/i.test(entry.name))) return { pack: unreal, source: 'marker', profile, defaultBranch }
103  if (packageJson || entries.some(entry => entry.kind === 'file' && /^(package\.json|pyproject\.toml)$/i.test(entry.name))) return { pack: web(), source: 'marker', profile, defaultBranch }
104  return { pack: core, source: 'none', profile, defaultBranch }
105}
106
107/** @type {Map<string, ReturnType<typeof choosePack>>} */
108const chosen = new Map()
109
110// Once per session and root: a profile edited mid-session takes effect in the next session.
111/** @param {PackIo} io @param {string} root */
112export const packFor = async (io, root) => {
113  const key = `${await io.sessionId().catch(() => '')}|${root}`
114  const hit = chosen.get(key)
115  if (hit) return hit
116  const read = choosePack(io, root)
117  chosen.set(key, read)
118  return read
119}
120
121// A repository that got its intents mid-session has a profile now: its pack is chosen again.
122/** @param {Pick<PackIo, 'sessionId'>} io @param {string} root */
123export const forgetPack = async (io, root) => void chosen.delete(`${await io.sessionId().catch(() => '')}|${root}`)
124
125// Tests only: forget what was chosen.
126export const forgetPacks = () => chosen.clear()
127
128export { core, unreal, makeWebPack }
129
hooks/ather/team.mjs 308 lines
1// @ts-check
2// Ather Automata: the team's intents as origin/main has them (or origin/<base>, where the team
3// merges into another branch), each dated by its folder's last commit there, merged with the
4// folders only this checkout has (D1-D3). Pure: git
5// and the files come through a Repo of closures built where `$` lives (console.mjs).
6// Every git call here reads, and never the working tree or the index; the fetch writes
7// only the remote-tracking ref.
8
9import { branchNamed } from './shell.mjs'
10
11/**
12 * `isAborted`: the app took the run back before it ended (it never ran to an exit code).
13 * @typedef {{ exitCode: number, stdout: string, stderr?: string, isAborted?: boolean }} Ran
14 * @typedef {{
15 *   git: (args: readonly string[], options?: { stdin?: string, timeoutMs?: number }) => Promise<Ran>,
16 *   read: (path: string) => Promise<string | null>,
17 *   list: (path: string) => Promise<readonly { name: string, kind: string }[]>,
18 *   mtime: (path: string) => Promise<number>,
19 *   real?: (folder: string) => Promise<string>,
20 * }} Repo `git` runs in the checkout with GIT_ENV; one that could not start or ran out of time answers exit code -1, the reason in stderr.
21 *   `real`: the folder a path really lands in, behind any symbolic link; without it folders are compared as given
22 * @typedef {{ files: string[], at: number, firstAuthor: string, prompt: string, progress: string, findings: string }} MainFolder `at`: the folder's last commit, ms
23 * @typedef {{ sha: string, folders: Map<string, MainFolder> }} MainSnapshot what origin/main held at `sha`
24 * @typedef {{ key: string, dirty: Set<string>, committed: Set<string> }} LocalState which folders are uncommitted, and which this branch committed since main, as of `key`
25 * @typedef {{ main: MainSnapshot | null, local: LocalState | null }} TeamCache what the last read learned; each part is read again only when what it depends on moved
26 * @typedef {import('./model.mjs').IntentFiles} IntentFiles
27 */
28
29// Every git call Ather makes reads without taking the index lock, and never asks for a password (D2).
30export const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
31// The remote-tracking ref of the branch a checkout's team merges into (its base).
32/** @param {string} base */
33const refOf = base => `origin/${base}`
34export const MAIN = refOf('main')
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 : '')
308
hooks/pane.mjs 253 lines
1// @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}
253
hooks/page.mjs 64 lines
1// @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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}
64
hooks/snapshot.mjs 60 lines
1// @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}
60
hooks/views.mjs 303 lines
1// @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