Ather Automata: what needs you and what is next in every S2 session and web app repository (Plan, Build, Prove, Ship), a newcomer tour, autonomy windows, and…

Working the S2 way with Claude Code: what needs you, what to do next, and a safe pair of hands while you are away.
/ather whenever you wonder what to do. What needs you comes first, then the next step for your work, ready to send./ather tour walks you through it in six short steps and ends with your first piece of work started. In a hurry, skip it; Ather just asks your role./away tonight (or 8h, until 9am, until done). The session keeps working; merges wait for you. When you are back, /ather → I'm back: see what happened.| Where | What |
|---|---|
| Terminal | /ather opens one pane: where your work is (Plan ✓ ─ Build ● ─ Prove ○ ─ Ship ○) and the proof so far, what needs you, then Next, then other work you could pick up. Enter hands a row to the session; a decision opens in place with its options as buttons, so you answer it where it is shown. |
| Desktop app | /ather opens the same pane in the side panel; click a row to hand it to the session. |
| Above the prompt | One line: what needs you, a running window, or just the name; then ☾ away, ⤢ open the pane, ✕ hide it until something is new. |
| Pop-ups | A known trap with its fix, a build that really failed, a merge that dropped your edits, a worker gone quiet, a thin worker brief. |
On a PC that also runs week-calendar, the pane's line under the title adds this week's figures: PRs merged and productive agent time.
Each session works on one intent. A new session offers to continue the one you last worked on. With nothing tracked, Ather offers one list: your open intents, then the GitHub issues assigned to you that have no intent yet (high priority first), then teammates' intents you could follow (read-only: their decisions stay theirs).
Tracking. Looking at an intent never tracks it: a row, or words that name one, opens its view, and Work on this here there makes it this session's intent (so do Next's Continue and Pick up, and /ather intent <exact name>). Stop tracking in the view, or /ather untrack, undoes it; the proof recorded so far stays with the intent. A session also tracks the intent it runs: writing that intent's prompt.md or log.md from the main conversation (a new prompt.md switches to the new intent); a worker's writes never do. Several sessions may track one intent; its view then says so ("Also tracked in 1 other session · active 5m ago"). After /clear, or when a new session takes over an away window, Ather says which intent is still tracked.
Clicking an issue opens its card: Start an intent, Open on GitHub or Copy link. Starting one asks the session to check for overlapping work first (the issue preflight), then draft an intent linked to it (- Issue: #28887) and show you the plan before anything is built. Ather reads your issues with gh and never writes to GitHub. /ather issues lists them; /ather issue 28887 or #28887 starts one.
gh api) and pushes to main, however they are spelled. Each command is judged on the branch of the folder it runs in, so pushing your feature worktree is never held.Ather reads evidence from tool output, never from what the session says: an S2Editor build's own Result line, a test run whose tests passed (no tests, a failure or a non-zero exit is a fail), a started PIE or test simulation run, and a read-back from the server that was written to. A tech artist's own Editor check is /ather checked. Proof is kept with the intent for a day, so yesterday's build still counts this morning; each record names the session that produced it, and the Intent view names any other session's. Until you say your role, any role's proof counts.
| Command | Does | ||||
|---|---|---|---|---|---|
/ather | What needs you and what is next | ||||
/ather tour, /ather skip | The tour, or skip it and just say your role | ||||
/ather pick [words], /ather intent <name> | Everything open; an exact intent name works on it here, other words show the intent they match | ||||
/ather untrack | Stop tracking this session's intent (not while an away window runs) | ||||
/ather issues, /ather issue <number> | Your GitHub issues; start one | ||||
/ather role <in your words> | Designer, tech artist or engineer | ||||
/ather checked | Record your own Editor check | ||||
/ather setup | Add the intent structure to this repository (see Setting up a repository) | ||||
| `/away [8h \ | 30m \ | until 9am \ | tonight \ | until done] [goal]` | Hand over while you are away |
/away stop (or "I'm back") | End the window |
Anything else you type after /ather or under Other goes to the session as a question.
Claude Code 2.1.287 or later, in an S2 checkout:
claude plugin marketplace add AskTinNguyen/ather-mods
claude plugin install ather-automata@ather --scope user
Setting: briefGate (warn, enforce or off) for worker briefs that lack paths, acceptance checks or the shared-tree rule.
Ather works where a repository has intents (a docs/intent folder). In a repository without them, /ather asks one question: Set up intents here or Not now. Not now changes nothing; /ather setup does it later.
/ather setup asks the session to add what is missing of five pieces:
| Piece | Where |
|---|---|
| The intent skill | .agents/skills/intent/, also made loadable by Claude Code under .claude/skills/intent |
| The folder rules and the area list | docs/intent/README.md |
| The profile: pack, gates, merge policy, areas | .ather/profile.json |
| The line that ignores local state | .ather/local/ in .gitignore |
| The paragraph that names the skill | AGENTS.md, or CLAUDE.md when that is the repository's instruction file |
/ather setup only reports ("Intents are set up here: pack web, 4 gates, 9 areas. Nothing to add.")./ather opens the pane in the same session. A new session reads the profile when it starts, so restart the sessions that were already open in that repository.templates/intent-setup.zip in the plugin's folder. You can give that zip to any repository without the mod: its SETUP.md is the whole instruction, for an agent or a person.The same mod runs in web app repositories (Node and TypeScript first), piloted on Thính (AskTinNguyen/han-viet). The pane, Plan → Build → Prove → Ship, Next, away windows, the decision ledger, issues and the worker squad are the same; what counts as proof, what is held and what Next asks for come from a pack for the kind of project:
.ather/profile.json ("pack": "web" or "unreal"), else markers (*.uproject → Unreal; package.json or pyproject.toml → web), else the core alone. Read once per session. S2 checkouts get the Unreal pack and see exactly what they saw before.tests/ather-profile.test.mjs checks it): {
"version": 1,
"pack": "web",
"gates": [
{ "id": "test", "command": "npm test", "proofs": ["tests", "build"], "proves": "the full suite" },
{ "id": "lint", "command": "npm run lint", "proofs": ["lint"] },
{ "id": "build-next", "command": "npm run build:next", "proofs": ["build", "typecheck"] },
{ "id": "ui", "command": "npm run ui:verify", "proofs": ["ui"] }
],
"production": { "host": "vercel", "branch": "main", "deployment": "how the deployment is checked", "probe": { "url": "https://…", "expectStatus": 200 } },
"mergePolicy": "with-proof",
"devPorts": { "base": 3100, "perWorktree": 10, "env": "UI_VERIFY_PORT" }
}
Optional: "required" (the rungs a merge needs; default every declared proof but production) and "areas". Without a profile, npm test, lint, typecheck and build scripts stand in for gates.
tests, lint (lint and typecheck), build, ui, prod. A gate's command (or an npm run script, or the tool itself: node --test, vitest, jest, Playwright, tsc, ESLint, next build, vinext and Vite builds) passes on exit 0 with its own pass counts and no failures; a failure count or a non-zero exit fails it; a piped run is judged on its counts alone. prod is the deployment's commit status through gh api, vercel inspect, or a probe of the profile's URL. Roles: Engineer (tests, lint, build), Designer (the browser check), Product (build and the browser check).with-proof lets a merge into main through, even while you are away, once every required rung has passed in tool output in this session; otherwise it is held as in S2. Ship then asks for the production check. Without the policy, merges wait for you.vercel --prod, vercel deploy --prod, wrangler deploy), migrations against a non-local database, env and secret changes (vercel env add/rm, wrangler secret, gh secret, and the same through gh api), npm publish, terraform apply, and pushes to main, inside chained commands and npm run scripts too..next or Vite cache, lockfile drift, Node version against engines, a missing NEXT_PUBLIC_*, missing Playwright browsers, a held .next lock.frontend-design, run, code-review, security-review and simplify..ather/local/ (add it to .gitignore); debriefs to docs/intent/<slug>/debrief.md./ather asks one question instead; option descriptions may not show there, so labels stand alone.deny_root_paths.py does that, outside this plugin).One hooks module (hooks/ather.mjs) made of two halves. watch.mjs protects work and has no interface beyond pop-ups; console.mjs draws. They share state only through state.mjs, the one owner of every stored value, which applies changes one at a time. The pure logic is split by concern: model.mjs (intents, stages, next step), guards.mjs (shell and MCP checks, traps), away.mjs (window rules), issues.mjs (GitHub issues), home.mjs (what the console shows), decide.mjs (answering a decision in place: its answers, what each hands the session, this session's answers), team.mjs (the team's intents from origin/main and the background fetch), worklist.mjs (sort, stage blocks, Needs attention, owner names, row cells), rows.mjs (the pane's look, and the lists it draws: Needs you, Everything open, Home's preview), workers.mjs and inflight.mjs (the workers the watch half saw, and every tool call in flight, per loop), crew.mjs (the workers as listed, and the tree they are drawn in), crew-rows.mjs (the workers drawn), setup.mjs (which of the five setup pieces a repository has, and the prompt /ather setup hands the session). Background hooks never get in the way of the calls they watch; the mod shows state and routes, and the session does the talking.
Packs (hooks/packs/): unreal.mjs (S2), web.mjs and core.mjs, chosen by packs/index.mjs; shell.mjs reads command lines for all of them. A pack is a plain object; the pure modules take it as their last parameter, defaulting to the Unreal pack.
The setup bundle: templates/intent-setup/ holds SETUP.md and, under files/, the intent skill, the docs/intent/README.md, the example profile and the AGENTS.md paragraph. templates/intent-setup.zip is built from that folder by dev/pack-templates.mjs in the marketplace repo, the same bytes on every machine. Run node dev/pack-templates.mjs after any change to the folder; dev/test-all.mjs fails while the zip and the folder differ.
Tests: tests/ather.test.mjs, tests/acceptance.test.mjs, tests/setup.test.mjs (which reads the bundle's files) and tests/web.test.mjs (with outputs captured from han-viet under tests/fixtures/web/) run under node --test once dev/test-all.mjs has linked the test kit; tests/plugin.test.ts runs under claude plugin test ather-automata. An end-to-end run in the marketplace repo (dev/test-all.mjs) drives the real code against a stand-in engine that answers dialogs the way the app does and lays out every pane at 72 and 110 columns; with HANVIET_ROOT set to a han-viet checkout it lays out the web pane too, and --layouts <dir> writes every layout for a diff.
docs/intent folder, /ather no longer only says "none here": it asks one question, Set up intents here or Not now. /ather setup hands the session one prompt that names templates/intent-setup.zip in the plugin's folder (a neutral intent skill, a docs/intent/README.md, an example .ather/profile.json, the AGENTS.md paragraph, and a SETUP.md with the steps) and the pieces missing here by path, and answers "Asked the session to set up intents here. To add: … It asks you before it writes anything." The session reads the repository, proposes areas and gates, asks you to confirm them in one question, then writes; it never overwrites a file, leaves existing intents alone and does not commit until told. A repository that has some of the pieces is handed only the others; with nothing missing the answer is "Intents are set up here: pack web, 4 gates, 9 areas. Nothing to add." and nothing is sent. Once the pieces are there, /ather opens the pane in the same session with the new profile's gates, and the profile tool lists its areas. Left unanswered, /ather says there are no intents here and names /ather setup; /away answers as before. The zip can be given to a repository by hand./away with nothing after it answered "Not started.", /ather skip never asked your role, /ather without a pane printed one status line, and Type an answer and the confirmation for an unassigned issue asked nothing. Every question now goes through the engine's $.ui.ask, with each choice's description as before; a dismissed question, or one left alone until it closes, still changes nothing.Options: list with - A (recommended): …, or inline (a) … (b) … with Recommendation: (a)), the recommended one primary and marked, then Explain, Type an answer and Open findings ›. An option hands the session "Decide F-n on <intent>: A — <the option>" with the instruction to record it the intent skill's way and not ask again; the row shows "✓ Decided: A" for 8 seconds, then folds into "▸ N decided" (this session's answers, until the files read them resolved; not kept across a reload). Explain asks about it without deciding; Type an answer opens a text field under the row (the question dialog's Other where the surface has no field, as on mobile); other decisions are one line each and open on a press, one at a time; an intent with several keeps its one folded row. "Make it a rule?" answers the same way (Make it a rule / No, leave it). A finding whose Resolution is filled is closed whatever its heading says, so already-decided findings no longer count. Everything open's Search opens a text field (the dialog only where there is none); /ather find <words> is unchanged.origin/main with git show and git log, read-only, and says what it is for, who owns it, its stage, checklist, open decisions and next step). An intent only on main offers it in place of Work on this here, which needs the folder here (pull main first); a teammate's "See where it stands" reads from main too.origin/main through git (never touching the working tree or the index) with the checkout's own folders added and tagged local, each dated by its folder's last commit there instead of when someone last pulled; origin's main is fetched in the background (git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main with no FETCH_HEAD, submodules or automatic gc, GIT_OPTIONAL_LOCKS=0, at most every 10 minutes, one at a time; a git lock in the way is named and waited out) and the header says how fresh it is ("synced 4 min ago ↻", ↻ fetches now). Sort (Recent, Ready to close in stage blocks, Oldest) replaces the age chips; Needs attention lists your own intents with every item met or parked without a reason; owner names are tidied (LamPhung-Art is Lam Phung, Cinematic is "Tien Dang · Cinematic", no Owner falls back to the first committer); every work row has one anatomy (stage glyph, title, mini progress bar and count, age, owner) in aligned columns; an intent's several decisions wait as one row; Home previews four teammates' intents then "+N more ›"; lime is kept for what needs you./ather find <words> too) and an age filter (any time, 7, 30, 90 days, by when an item last changed). A teammate's name follows the intent's title in its own colour, dimmed; no two teammates share one. The count and the fold stay when you filter./ather untrack undo it with the proof kept, only a session's own orchestration tracks by writing (its main conversation writing an intent's prompt.md or log.md), a second session on an intent sees "Also tracked in …", proof names the session that produced it, and /clear or an adopted window says which intent is still tracked..ather/profile.json gates as proof from tool output, holds production deploys, migrations, secrets, publishes and infrastructure applies while you are away, merges with proof under with-proof, knows nine web traps, and offers web skills under Create.hooks/ather.mjs 15 lines1// @ts-check
2// Ather Automata: one hooks module per plugin, made of two halves. watch.mjs
3// protects work silently; console.mjs shows what needs you. Each catches its own
4// failures, so one half failing never stops the other. They share state only
5// through state.mjs, its owner, which serializes every change.
6
7import { register as registerWatch } from './watch.mjs'
8import { register as registerConsole } from './console.mjs'
9
10/** @param {import('claude-code').On} on @param {import('claude-code').PluginOptions} options */
11export function register(on, options) {
12 registerWatch(on, options)
13 registerConsole(on)
14}
15hooks/watch.mjs 616 lines1// @ts-check
2// Ather Automata, the silent half: guards that protect work, the autonomy
3// window's holds and decision ledger, evidence read from tool output, and the
4// tools the model calls. Its only interface is toasts. Every hook passes the
5// call on and keeps its own failures to itself, except a held action, which it
6// refuses on purpose. Shared state changes only through state.mjs.
7//
8// The host reads on(...) and $.noun.method(...) from source, so they are
9// spelled literally, and helpers that take $ are top-level functions.
10
11import { clampHours, isHolding, mandateText, offAway, windowEndText } from './away.mjs'
12import { HELD_LABELS, HELD_NOUNS, briefIssues, explainGuard, gitFolders, heldKindsOf, heldShell, isMergeCommand, isSearchCommand, matchGotchas, mcpServer } from './guards.mjs'
13import { STAGE_LABELS, andList, clockText, currentStage, directorCalls, localMinutes, parseIntent, parseTzOffset, prStatusList } from './model.mjs'
14import * as state from './state.mjs'
15import { recordHeard, recordSpawn, recordTool, resetWorkers, workerOf } from './workers.mjs'
16import { askingIn, during, isInFlight, isSilent, linkChild, markAsking, resetCalls } from './inflight.mjs'
17import { intentChanges, intentFileOf, orchestrationFileOf } from './changes.mjs'
18import { heldByLine, untrackText } from './home.mjs'
19import { GIT_ENV } from './team.mjs'
20
21/** @typedef {import('claude-code').EngineInterface} Engine */
22
23const IDLE_MS = 10 * 60 * 1000
24
25let cwd = ''
26let briefGate = 'warn'
27// Traps already counted in this session: each counts once per session.
28const seenTraps = new Set()
29// Workers already warned about (quiet, or waiting on permission): each is warned once.
30const idleWarned = new Set()
31// When the person last typed a prompt: after an away window has ended, it means they are back.
32let lastPersonAt = 0
33// When this session started: a with-proof merge counts only proof seen since (D2: "passed in tool output this session").
34// A hot reload starts it again, which only makes the rule stricter.
35let sessionStartedAt = Date.now()
36
37// The store, files and session as closures: `$` cannot be handed to state.mjs itself.
38/** @param {Engine} $ @returns {import('./state.mjs').Io} */
39function io($) {
40 return {
41 get: key => $.store.get(key),
42 set: (key, value) => $.store.set(key, value),
43 remove: key => $.store.delete(key),
44 keys: () => $.store.keys(),
45 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null),
46 write: (path, text) => $.fs.write(path, text),
47 exists: path => $.fs.exists(path).catch(() => false),
48 sessionId: () => $.session.id(),
49 root: () => $.session.root(),
50 gitUser: async () => ((await $.process.run(['git', 'config', 'user.name'], { cwd: cwd || (await $.session.root()), timeoutMs: 10000 })).stdout ?? '').trim(),
51 redraw: () => $.ui.invalidate('ui.render'),
52 list: path => $.fs.list(path),
53 }
54}
55
56/** @param {Engine} $ */
57function laneOf($) {
58 return state.lane(io($), cwd)
59}
60
61/** @param {import('claude-code').On} on @param {import('claude-code').PluginOptions} options */
62export function register(on, options) {
63 briefGate = String(options?.briefGate ?? 'warn')
64
65 on('session.start', async ($, e, next) => {
66 const result = await next(e)
67 seenTraps.clear()
68 idleWarned.clear()
69 resetWorkers()
70 resetCalls()
71 lastPersonAt = 0
72 sessionStartedAt = Date.now()
73 state.markActive()
74 cwd = e.cwd
75 try {
76 const { me, root, isS2, pack } = await laneOf($)
77 await registerTools($, pack)
78 // A repository set up in this session has a new pack: the tools are registered again under the
79 // same names, which replaces them, so they carry its areas, roles and held kinds.
80 state.onSetUp('watch', setUp => registerTools($, setUp))
81 await state.migrateRole(io($), me)
82 const adopted = isS2 && e.isInteractive ? await state.adoptWindow(io($), { me, root, isAlive: sid => isLaneAlive($, sid) }).catch(() => null) : null
83 if (isS2) void state.prune(io($), sid => isLaneGone($, sid)).catch(() => undefined)
84 if (adopted) $.ui.toast(adopted.isOver ? 'Ather: welcome back. Your away window has ended; merges stay held until you review it. Type /ather.' : 'Ather: your away window from an earlier session is still running. Type /ather to see it, or /away end.', { timeoutMs: 15000 })
85 if (adopted) await stillTracking($)
86 // The timezone probe starts a process; it must not hold the session's first prompt.
87 void detectTz($).catch(() => undefined)
88 $.clock.every(30000, () => void tick($).catch(() => undefined))
89 } catch (error) {
90 $.ui.log(`Ather watch: start failed: ${String(error)}`, { to: 'debug' })
91 }
92 return result
93 })
94
95 // The lane's heartbeat says it has ended, so peers stop listing it at once. After /clear
96 // the process goes on under a new session id (no session.start fires): the lane moves to it.
97 on('session.end', async ($, e, next) => {
98 await heartbeat($, true).catch(() => undefined)
99 if (e.reason === 'clear') followClear($, e.sessionId, 25)
100 return next(e)
101 })
102
103 on('prompt.submit', async ($, e, next) => {
104 // Typed at the terminal or the desktop, or sent from a phone over Remote Control: the person is back.
105 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') lastPersonAt = Date.now()
106 // Any prompt, or any tool call below, is the lane's last activity (its heartbeat says when).
107 state.markActive()
108 return next(e)
109 })
110
111 on('tool.call', { tool: 'mcp__ather-automata__status' }, async $ => ({ result: await statusText($) }))
112 on('tool.call', { tool: 'mcp__ather-automata__away' }, async ($, e) => ({ result: await awayTool($, /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (e))) }))
113 on('tool.call', { tool: 'mcp__ather-automata__profile' }, async ($, e) => ({ result: await profileTool($, /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (e))) }))
114
115 on('prompt.compose', async ($, e, next) => {
116 const result = await next(e)
117 try {
118 const text = (await laneOf($)).isS2 ? await laneText($) : ''
119 return text === '' ? result : { ...result, sections: [...result.sections, { id: 'ather-automata:lane', text, scope: /** @type {const} */ ('session') }] }
120 } catch {
121 return result
122 }
123 })
124
125 on('tool.call', { tool: 'Bash' }, async ($, e, next) => shell($, e.command, e, next))
126 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => shell($, e.command, e, next))
127
128 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
129 const issues = briefGate === 'off' ? [] : briefIssues(e.prompt, e.subagent_type, (await laneOf($).catch(() => null))?.pack)
130 const missing = andList(issues)
131 if (issues.length > 0 && briefGate === 'enforce') {
132 $.ui.toast(`Ather brief gate: refused a worker brief missing ${missing}.`)
133 return { deny: `Ather Automata brief gate: this worker brief is missing ${missing}. Add them (template: .agents/skills/intent/assets/worker-brief.md) and dispatch again.` }
134 }
135 const ran = await next(e)
136 if (issues.length === 0 || ran.deny !== undefined) return ran
137 $.ui.toast(`Ather: worker "${e.description}" was briefed without ${missing}.`)
138 void state.bump(io($), 'briefsFlagged').catch(() => undefined)
139 return { ...ran, context: [...(ran.context ?? []), `Ather Automata: the brief for "${e.description}" did not name ${missing}. If the worker edits files or the Editor, message it the missing parts now.`] }
140 })
141
142 // From the start of an away window until its review, the model's questions go to the ledger instead of waiting.
143 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
144 // Ather's own dialogs are the person answering, never deferred.
145 if (next.origin.plugin === $.plugin.name) return next(e)
146 const deferred = await state.deferQuestions(io($), e.questions, lastPersonAt).catch(() => null)
147 if (deferred === null) return next(e)
148 const { ids, away } = deferred
149 void state.bump(io($), 'decisionsLedgered').catch(() => undefined)
150 $.ui.toast(`Ather: ${ids.join(', ')} recorded for your review instead of waiting.`)
151 return {
152 deny: `${away.phase === 'review' ? 'The user has not reviewed the away window yet' : `The user is away until ${clockText(away.wakeAt, await state.readTz(io($)))}`} (Ather autonomy window). Do not wait. Take the recommended option for ${ids.join(', ')}, complete ${ids.length === 1 ? 'its entry' : 'their entries'} in ${away.ledgerPath} (Choice, Why, Evidence, Revert), and continue. Exception: if the question is about a destructive, production, credential, cost or CI-global action, do not take it; set the entry's Choice to "parked for the director" and move on to other work.`,
153 }
154 })
155
156 on('agent.spawn', async ($, e, next) => {
157 const spawned = await next(e)
158 if (spawned.agentId) recordSpawn({ agentId: spawned.agentId, subagentType: e.subagentType, prompt: e.prompt, description: e.description, model: spawned.model, at: Date.now() })
159 // A foreground Agent call now waits on this worker: its loop's call in flight names it.
160 if (spawned.agentId) linkChild({ loop: e.parentAgentId ?? '', toolUseId: e.tool_use_id, childId: spawned.agentId, isBackground: e.background })
161 return spawned
162 })
163
164 // A permission dialog shown to the person (not tool.check's "ask", which in auto mode the classifier often
165 // settles at once): until it settles, that call waits on their decision, not running, and a long wait is what
166 // the toast is for. Watched only: the dialog's answer is never decided here.
167 on('classic.PermissionRequest', async ($, e, next) => {
168 markAsking({ loop: e.agent_id ?? '', tool: e.tool_name, input: e.tool_input, at: Date.now() })
169 return next(e)
170 })
171
172 on('tool.call', async ($, e, next) => {
173 const tool = String(e.tool)
174 state.markActive()
175 // A worker's tool call: what it is doing now, for its avatar and trail.
176 if (e.agentId) recordTool(e.agentId, tool, /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (e)), Date.now())
177 const isMcp = tool.startsWith('mcp__') && !tool.startsWith('mcp__ather-automata__')
178 const input = JSON.stringify(e).slice(0, 4000)
179 if (isMcp && (await laneOf($)).pack.isAssetSave(input)) {
180 const denied = await hold($, 'asset-save', `${tool} ${input.slice(0, 300)}`).catch(() => null)
181 if (denied) return { deny: denied }
182 }
183 const path = /** @type {{ file_path?: unknown }} */ (e).file_path
184 const isWrite = /^(Write|Edit|MultiEdit)$/.test(tool)
185 // The session's own orchestration (its main thread, never a worker) writing an intent's prompt.md or log.md
186 // tracks that intent once the write has gone through; creating a prompt.md switches to the new intent.
187 const orchestrated = isWrite && e.agentId === undefined ? orchestrationFileOf(path) : null
188 const isNewIntent = orchestrated?.file === 'prompt.md' && !(await io($).exists(await fullPath($, String(path))))
189 // An edit to an intent's prompt, findings or progress: what it changed, read off the file before and after.
190 const intentFile = isWrite ? intentFileOf(path) : null
191 const before = intentFile ? ((await readFile($, String(path))) ?? '') : ''
192 // In flight until it settles (ran, refused or threw) or the dispatch aborts, in its loop (a worker's, or the
193 // main loop's); then heard from.
194 const settled = () => (e.agentId ? recordHeard(e.agentId, Date.now()) : undefined)
195 const ran = await during({ loop: e.agentId ?? '', toolUseId: String(e.tool_use_id ?? ''), tool, input: /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (e)), at: Date.now() }, () => next(e), settled, next.signal)
196 const hasRun = ran.deny === undefined && ran.isError !== true
197 if (orchestrated && hasRun) void laneOf($).then(({ root }) => state.track(io($), root, orchestrated.slug, { isAuto: true, onlyIfNone: !isNewIntent })).catch(() => undefined)
198 if (isMcp && ran.deny === undefined) void noteMcp($, tool, input, ran).catch(() => undefined)
199 if (intentFile && ran.deny === undefined) void noteIntentEdit($, intentFile, String(path), before).catch(() => undefined)
200 return ran
201 })
202}
203
204// ---------------------------------------------------------------- what an intent edit recorded
205
206// A tool's file path as written, or relative to the checkout.
207/** @param {Engine} $ @param {string} path */
208async function fullPath($, path) {
209 return /^([A-Za-z]:|[\\/])/.test(path) ? path : `${(await laneOf($)).root}/${path}`
210}
211
212/** @param {Engine} $ @param {string} path */
213async function readFile($, path) {
214 return io($).read(await fullPath($, path))
215}
216
217/** @param {Engine} $ @param {{ slug: string, file: import('./changes.mjs').IntentFile }} target @param {string} path @param {string} before */
218async function noteIntentEdit($, target, path, before) {
219 const after = (await readFile($, path)) ?? ''
220 // The edited file's sibling, as it is now: prompt.md for findings and progress, progress.md for prompt.
221 const sibling = async (/** @type {string} */ name) => (await readFile($, path.replace(/[^\\/]+\.md$/i, name))) ?? ''
222 const intent = target.file === 'prompt.md' ? { progress: await sibling('progress.md') } : { prompt: await sibling('prompt.md') }
223 await state.noteChanges(io($), target.slug, intentChanges(target.file, before, after, intent), Date.now())
224}
225
226// ---------------------------------------------------------------- intents and the lane
227
228/** @param {Engine} $ @param {string} slug */
229async function readIntent($, slug) {
230 const { root, pack } = await laneOf($)
231 const dir = `${root}/docs/intent/${slug}`
232 const files = io($)
233 const prompt = await files.read(`${dir}/prompt.md`)
234 if (prompt === null) return undefined
235 return parseIntent({
236 slug,
237 prompt,
238 findings: (await files.read(`${dir}/findings.md`)) ?? '',
239 progress: (await files.read(`${dir}/progress.md`)) ?? '',
240 files: (await $.fs.list(dir).catch(() => [])).map(entry => entry.name),
241 hasDebrief: await files.exists(`${root}/${pack.debriefPath(slug)}`),
242 updatedAt: 0,
243 source: 'local',
244 firstAuthor: '',
245 }, pack)
246}
247
248/** @param {Engine} $ */
249// The branch checked out in a folder (null: the session's checkout); '' when it cannot be told.
250/** @param {Engine} $ @param {string | null} [folder] */
251async function readBranch($, folder = null) {
252 const { root } = await laneOf($)
253 const base = folder === null ? root : /^([A-Za-z]:[\\/]|[\\/])/.test(folder) ? folder : `${root}/${folder}`
254 const files = io($)
255 let head = null
256 // Walk up to the checkout the folder is in: `cd Plugins/X && git push` pushes the checkout's branch.
257 for (let folderAt = base.replace(/[\\/]+$/, ''), depth = 0; head === null && folderAt !== '' && depth < 12; depth += 1) {
258 head = await files.read(`${folderAt}/.git/HEAD`)
259 if (head === null) {
260 // A worktree: .git is a file naming its gitdir.
261 const gitdir = /gitdir:\s*(.+)/.exec((await files.read(`${folderAt}/.git`)) ?? '')?.[1]?.trim()
262 if (gitdir) head = await files.read(`${gitdir}/HEAD`)
263 }
264 const parent = folderAt.replace(/[\\/][^\\/]*$/, '')
265 folderAt = parent === folderAt ? '' : parent
266 }
267 return /ref:\s*refs\/heads\/(.+)/.exec(head ?? '')?.[1]?.trim() ?? (head ?? '').trim().slice(0, 12)
268}
269
270// The branch each git segment of a command runs on, read before the pure hold check.
271/** @param {Engine} $ @param {string} command */
272async function branchesFor($, command) {
273 const branches = new Map()
274 for (const folder of gitFolders(command)) branches.set(folder, await readBranch($, folder).catch(() => ''))
275 return (/** @type {string | null} */ folder) => branches.get(folder) ?? ''
276}
277
278/** @param {Engine} $ @param {boolean} hasEnded */
279async function heartbeat($, hasEnded) {
280 const { root, isS2, pack } = await laneOf($)
281 if (!isS2) return
282 await state.writeHeartbeat(io($), { root, localDir: pack.localDir, branch: await readBranch($), hasEnded })
283}
284
285// A session this checkout can vouch has gone: its heartbeat is here and says ended, or is stale.
286// A session with no heartbeat here may be alive in another checkout, so it is left alone.
287/** @param {Engine} $ @param {string} sid */
288async function isLaneGone($, sid) {
289 const { root, pack } = await laneOf($)
290 const lane = await state.readLane(io($), root, pack.localDir, sid)
291 return lane !== null && !state.isLaneLive(lane)
292}
293
294// Another session is alive while its heartbeat is fresh and has not said it ended.
295/** @param {Engine} $ @param {string} sid */
296async function isLaneAlive($, sid) {
297 const { root, pack } = await laneOf($)
298 const lane = await state.readLane(io($), root, pack.localDir, sid)
299 return lane !== null && state.isLaneLive(lane)
300}
301
302// Where this session's evidence goes: the tracked intent at its current commit, or the session.
303/** @param {Engine} $ */
304async function scopeOf($) {
305 return state.evidenceScope(io($))
306}
307
308/** @param {Engine} $ */
309async function peers($) {
310 const { root, pack } = await laneOf($)
311 return state.readPeers(io($), root, pack.localDir)
312}
313
314// What every prompt is told about this lane: the tracked intent, the Editor lock, live peers, the window's mandate.
315/** @param {Engine} $ */
316async function laneText($) {
317 const { root, me, pack } = await laneOf($)
318 const lines = []
319 const tz = await state.readTz(io($))
320 const slug = await state.readPinned(io($))
321 const intent = slug ? await readIntent($, slug) : undefined
322 const live = await peers($)
323 if (intent) {
324 const { role } = await state.readProfile(io($), me, pack)
325 const prs = await state.readPrStates(io($))
326 const stage = STAGE_LABELS[currentStage(intent, await state.readEvidence(io($), await state.evidenceScope(io($)), pack), role, prs, pack)]
327 lines.push(`Tracked intent: ${intent.slug} (docs/intent/${intent.slug}/), status ${intent.status}, stage ${stage} (Plan, Build, Prove, Ship), checklist ${intent.acceptanceDone}/${intent.acceptanceTotal}${intent.prs.length > 0 ? `, PRs ${prStatusList(intent, prs).join(', ')}` : ''}, open director calls ${directorCalls(intent).length}.`)
328 const held = heldByLine(live, intent.slug, Date.now())
329 if (held) lines.push(`${held}.`)
330 }
331 const lock = pack.parseLock(pack.lockFile ? await io($).read(`${root}/${pack.lockFile}`) : null, localMinutes(Date.now(), tz))
332 if (lock.state === 'held') lines.push(`Editor owner lock: held by ${lock.holder || 'another lane'}${lock.until ? ` until ${lock.until}` : ''}.`)
333 if (live.length > 0) lines.push(`Live peer lanes on this checkout: ${live.map(lane => `${lane.intent ?? 'no intent'} on ${lane.branch}`).join('; ')}.`)
334 const away = await state.readAway(io($))
335 if (isHolding(away)) lines.push(mandateText(away, tz, pack))
336 return lines.length > 0 ? `Ather Automata lane state (live, read-only):\n${lines.join('\n')}` : ''
337}
338
339// While session.end runs the old id is still current: wait for the new one, then move the lane to it.
340/** @param {Engine} $ @param {string} oldSid @param {number} tries */
341function followClear($, oldSid, tries) {
342 $.clock.after(200, () => {
343 void $.session
344 .id()
345 .then(sid => {
346 if (sid !== oldSid) return state.moveLane(io($), oldSid, sid).then(() => stillTracking($))
347 if (tries > 0) return followClear($, oldSid, tries - 1)
348 $.ui.log('Ather watch: the session id did not change within 5 s of /clear; the lane stays under the old id.', { to: 'debug' })
349 })
350 .catch(() => undefined)
351 })
352}
353
354// After /clear or an adopted away window the session keeps the intent it tracked: say so, and how to stop.
355/** @param {Engine} $ */
356async function stillTracking($) {
357 const slug = await state.readPinned(io($))
358 if (slug) $.ui.toast(`Ather: Still tracking ${slug} · /ather untrack`, { timeoutMs: 12000 })
359}
360
361// Every 30 seconds: the heartbeat, quiet workers, and the end of an autonomy window.
362/** @param {Engine} $ */
363async function tick($) {
364 await heartbeat($, false)
365 const now = Date.now()
366 for (const agent of await $.agent.list().catch(() => [])) {
367 const seen = workerOf(agent.id)
368 if (agent.status !== 'running' || !seen?.lastTool || idleWarned.has(agent.id)) continue
369 // A call waiting on permission: warned once it has waited the threshold, whatever else is quiet.
370 const [asking] = askingIn(agent.id)
371 if (asking) {
372 if (asking.askedAt === undefined || now - asking.askedAt < IDLE_MS) continue
373 idleWarned.add(agent.id)
374 $.ui.toast(`Ather: worker "${agent.description}" asked permission to run ${asking.tool} ${Math.round((now - asking.askedAt) / 60000)} min ago and has not finished: ${asking.what}.`)
375 continue
376 }
377 // The same rule as the pane's: a call running (a long build, a PIE run, a foreground worker) is never quiet.
378 if (!isSilent({ isInFlight: isInFlight(agent.id), lastAt: seen.lastAt, now, quietMs: IDLE_MS })) continue
379 idleWarned.add(agent.id)
380 $.ui.toast(`Ather: worker "${agent.description}" has been quiet for ${Math.round((now - seen.lastAt) / 60000)} min after ${seen.lastTool}. Possibly a stuck permission prompt.`)
381 }
382 // A window ends at its time, or when the session reports the goal done; holds stay until the review.
383 const away = await state.readAway(io($))
384 if (away.phase === 'running' && now >= away.wakeAt && (await state.endAway(io($)))) $.ui.toast('Ather: the away window has ended; held actions stay held until you review it. Type /ather.', { timeoutMs: 15000 })
385}
386
387/** @param {Engine} $ */
388async function detectTz($) {
389 for (const argv of [['powershell', '-NoProfile', '-Command', "(Get-Date).ToString('zzz')"], ['date', '+%z']]) {
390 const run = await $.process.run(argv, { timeoutMs: 15000 }).catch(() => undefined)
391 const offset = run && run.exitCode === 0 ? parseTzOffset(run.stdout) : null
392 if (offset !== null) return state.setTz(io($), offset)
393 }
394}
395
396// ---------------------------------------------------------------- the model's tools
397
398// A held action, parked for the person's review; null when no window holds it.
399/** @param {Engine} $ @param {import('./guards.mjs').HeldKind} kind @param {string} command */
400async function hold($, kind, command) {
401 const held = await state.park(io($), kind, command, Date.now())
402 if (held === null) return null
403 void state.bump(io($), 'heldParked').catch(() => undefined)
404 $.ui.toast(`Ather: held ${HELD_NOUNS[kind]} until you review the away window (${held.parked.id}).`)
405 return `Held by the Ather away window until the user reviews it: ${HELD_LABELS[kind]}. Recorded as ${held.parked.id}. Do not retry it; continue with other work.`
406}
407
408/** @param {Engine} $ @param {Record<string, unknown>} input */
409async function awayTool($, input) {
410 const action = String(input.action ?? '')
411 const tz = await state.readTz(io($))
412 if (action === 'start') {
413 const { root, me, pack } = await laneOf($)
414 const held = Array.isArray(input.held) ? heldKindsOf(pack).filter(kind => /** @type {unknown[]} */ (input.held).includes(kind)) : undefined
415 const choice = { hours: clampHours(Number(input.hours) || 8), untilDone: input.untilDone === true, goal: typeof input.goal === 'string' ? input.goal.trim() : '', held }
416 const started = await state.startAway(io($), choice, { root, me, tz, now: Date.now(), pack })
417 if (started === null) return 'An away window is already running or waiting for the user\'s review.'
418 $.ui.toast(`Ather: away window running ${windowEndText(started, tz)}.`)
419 return `Autonomy window open ${windowEndText(started, tz)}. Allowed without asking: ${pack.mandate.allowed}. Ledger: ${started.ledgerPath}. Held: ${started.held.map(kind => HELD_LABELS[/** @type {import('./guards.mjs').HeldKind} */ (kind)] ?? kind).join(', ')}. Questions to the user are now recorded in the ledger instead of asked.`
420 }
421 if (action === 'end') return (await state.endAway(io($))) ? 'Autonomy window ended; the user reviews it with /ather.' : 'No autonomy window is running.'
422 if (action === 'close') return (await state.closeAway(io($))) ? 'Autonomy window closed.' : 'No autonomy window to close.'
423 return 'Unknown action: use start, end or close.'
424}
425
426/** @param {Engine} $ @param {Record<string, unknown>} input */
427async function profileTool($, input) {
428 const { root, me, pack } = await laneOf($)
429 const done = []
430 const role = input.role === undefined ? undefined : String(input.role).toLowerCase()
431 if (role !== undefined && !pack.roles.includes(role)) return `Unknown role "${role}": use ${pack.roles.join(', ')}.`
432 const area = input.area === undefined ? undefined : pack.normalizeArea(String(input.area))
433 if (area === 'Unsorted') return `Unknown area "${String(input.area)}": use one of ${pack.areas.join(', ')}.`
434 if (role !== undefined || area !== undefined) {
435 await state.setProfile(io($), me, { role, area }, pack)
436 done.push([role ? `Role set to ${role}.` : '', area ? `Area set to ${area}.` : ''].filter(Boolean).join(' '))
437 }
438 if (typeof input.track === 'string' && input.track.trim().toLowerCase() === 'none') {
439 done.push(untrackText(await state.untrack(io($), me)))
440 } else if (typeof input.track === 'string' && input.track.trim() !== '') {
441 const slug = input.track.trim()
442 if (!(await state.track(io($), root, slug))) return `No intent named "${slug}" in docs/intent.`
443 done.push(`This session now tracks intent ${slug}.`)
444 }
445 return done.join(' ') || 'Nothing to change: pass role, area or track.'
446}
447
448/** @param {Engine} $ */
449async function statusText($) {
450 const { root, me, pack } = await laneOf($)
451 const slug = await state.readPinned(io($))
452 const intent = slug ? await readIntent($, slug) : undefined
453 const { role, area } = await state.readProfile(io($), me, pack)
454 const evidence = await state.readEvidence(io($), await state.evidenceScope(io($)), pack)
455 const away = await state.readAway(io($))
456 const tz = await state.readTz(io($))
457 const prs = await state.readPrStates(io($))
458 return JSON.stringify(
459 {
460 me,
461 role,
462 area,
463 tracked: intent
464 ? { slug: intent.slug, status: intent.status, stage: STAGE_LABELS[currentStage(intent, evidence, role || 'engineer', prs, pack)], checklist: `${intent.acceptanceDone}/${intent.acceptanceTotal}`, prs: prStatusList(intent, prs), directorCalls: directorCalls(intent).map(one => `${one.id}: ${one.title}`) }
465 : null,
466 evidence,
467 ...(pack.lockFile ? { editorLock: pack.parseLock(await io($).read(`${root}/${pack.lockFile}`), localMinutes(Date.now(), tz)).raw } : {}),
468 ...(pack.id === 'unreal' ? {} : { pack: pack.id, gates: pack.gates.map(gate => `${gate.command}: ${gate.proofs.join(', ')}`), mergePolicy: pack.mergePolicy }),
469 peers: (await peers($)).map(lane => `${lane.intent ?? 'no intent'} on ${lane.branch}`),
470 away: { phase: away.phase, until: away.phase === 'off' ? '' : windowEndText(away, tz), ledger: away.ledgerPath, parked: away.parked.map(one => `${one.id}: ${one.command}`) },
471 recurringGotchas: (await state.readRecurring(io($), pack)).map(one => `${one.title} (${one.count} sessions)`),
472 caught: await state.readScore(io($)),
473 },
474 null,
475 1,
476 )
477}
478
479/** @param {Engine} $ @param {import('./packs/index.mjs').Pack} pack */
480async function registerTools($, pack) {
481 await $.tool.register({
482 name: 'status',
483 description: `Ather Automata: read the live state of ${pack.statusWhat} as JSON: tracked intent, its stage (Plan, Build, Prove, Ship), director calls, evidence read from tool output, ${pack.lockFile ? 'Editor owner lock' : 'the gates the profile names'}, peer lanes, autonomy window, recurring traps. Read-only.`,
484 inputSchema: { type: 'object', properties: {} },
485 })
486 await $.tool.register({
487 name: 'away',
488 description:
489 'Ather Automata: open, end or close an autonomy window. Open one (action "start") only when the user has said in their own words that they are going away and granting autonomy, for example "I am going to sleep for 8 hours, you have full autonomy". While it runs, questions to the user are written to a decision ledger instead of asked, and held actions are refused and parked for the user\'s review. "end" finishes it early; "close" closes the review.',
490 inputSchema: {
491 type: 'object',
492 properties: {
493 action: { type: 'string', enum: ['start', 'end', 'close'] },
494 hours: { type: 'number', description: 'Window length in hours (0.25 to 16). Default 8. Ignored with untilDone.' },
495 untilDone: { type: 'boolean', description: 'No fixed end: the window runs until the goal is done (call this tool with action "end" then), capped at 24 hours.' },
496 goal: { type: 'string', description: 'What to pursue while the user is away, in their words.' },
497 held: { type: 'array', items: { type: 'string', enum: heldKindsOf(pack) }, description: `Actions to refuse and park. Default: ${pack.held.defaults.join(', ')}.` },
498 },
499 required: ['action'],
500 },
501 })
502 await $.tool.register({
503 name: 'profile',
504 description: "Ather Automata: record the user's role and area when they state them, and which intent this session tracks when they choose one, for example during the Ather tour. The role shapes the next step Ather suggests and what Prove asks for; the area orders the intents Ather offers.",
505 inputSchema: {
506 type: 'object',
507 properties: {
508 role: { type: 'string', enum: [...pack.roles] },
509 ...(pack.areas.length > 0 ? { area: { type: 'string', enum: [...pack.areas] } } : { area: { type: 'string' } }),
510 track: { type: 'string', description: 'The folder name of an intent under docs/intent for this session to track, or "none" to stop tracking.' },
511 },
512 },
513 })
514}
515
516// ---------------------------------------------------------------- shell and MCP calls
517
518/** @param {Engine} $ @param {string} command @param {any} e @param {any} next */
519async function shell($, command, e, next) {
520 const away = await state.readAway(io($)).catch(() => offAway())
521 const { pack } = isHolding(away) ? await laneOf($) : { pack: null }
522 const kind = pack ? heldShell(command, away.held, await branchesFor($, command), pack, { isProven: await isMergeProven($, pack) }) : null
523 if (kind) {
524 const denied = await hold($, kind, command).catch(() => null)
525 if (denied) return { deny: denied }
526 }
527 const ran = await next(e)
528 try {
529 const context = await afterShell($, command, ran)
530 return context.length > 0 && ran.deny === undefined ? { ...ran, context: [...(ran.context ?? []), ...context] } : ran
531 } catch {
532 return ran
533 }
534}
535
536// With-proof merges (D2): every rung the profile requires passed in tool output in this session.
537/** @param {Engine} $ @param {import('./packs/index.mjs').Pack} pack */
538async function isMergeProven($, pack) {
539 if (pack.mergePolicy !== 'with-proof') return false
540 const evidence = await state.readEvidence(io($), await scopeOf($), pack)
541 const rungs = pack.mergeRungs ?? []
542 const seen = /** @type {Record<string, { state: string, at?: number }>} */ (evidence)
543 return rungs.length > 0 && rungs.every(rung => seen[rung]?.state === 'pass' && (seen[rung]?.at ?? 0) >= sessionStartedAt)
544}
545
546/** @param {Engine} $ @param {string} command @param {{ text?: string, deny?: string, isError?: boolean }} ran */
547async function afterShell($, command, ran) {
548 const context = []
549 const text = ran.text ?? ''
550 const { pack } = await laneOf($)
551 if (!isSearchCommand(command)) await noteTraps($, text, pack)
552 const guard = explainGuard(command)
553 if (guard !== null && (ran.deny !== undefined || ran.isError === true)) $.ui.toast(`Ather guard: ${guard}`, { timeoutMs: 12000 })
554 const reading = pack.readShell(command, text, ran)
555 for (const one of reading.rungs) await state.setRung(io($), await scopeOf($), one.rung, one.value)
556 context.push(...reading.context)
557 for (const toast of reading.toasts) $.ui.toast(toast.text, toast.timeoutMs === undefined ? undefined : { timeoutMs: toast.timeoutMs })
558 for (const key of reading.bumps) void state.bump(io($), key).catch(() => undefined)
559 if (isMergeCommand(command) && ran.deny === undefined && ran.isError !== true) {
560 const lost = await auditMerge($).catch(() => [])
561 if (lost.length > 0) {
562 await state.flagLost(io($), lost)
563 void state.bump(io($), 'lostWorkFlags').catch(() => undefined)
564 $.ui.toast(`Ather: the merge kept the other side of ${lost.length} binary asset(s); this branch's edits to them are gone.`, { timeoutMs: 15000 })
565 context.push(`Ather Automata merge audit: these binary assets are byte-identical to the merged-in side, so every edit this branch made to them is gone: ${lost.join(', ')}. Tell the user now, itemised, and mark each as a lost optimisation or a broken feature.`)
566 }
567 }
568 return context
569}
570
571/** @param {Engine} $ @param {string} text @param {import('./packs/index.mjs').Pack} pack */
572async function noteTraps($, text, pack) {
573 const fresh = matchGotchas(text, pack).filter(rule => !seenTraps.has(rule.id))
574 if (fresh.length === 0) return
575 for (const rule of fresh) {
576 seenTraps.add(rule.id)
577 $.ui.toast(`Ather gotcha: ${rule.title}. ${rule.fix}`, { timeoutMs: 10000 })
578 }
579 await state.countTraps(io($), fresh)
580}
581
582/** @param {Engine} $ @param {string} tool @param {string} input @param {{ text?: string, isError?: boolean }} ran */
583async function noteMcp($, tool, input, ran) {
584 const { pack } = await laneOf($)
585 const kind = pack.mcpKind(input)
586 if (kind) await state.noteMcp(io($), await scopeOf($), kind, mcpServer(tool), ran.isError !== true)
587 await noteTraps($, ran.text ?? '', pack)
588}
589
590// After a merge: binary assets byte-identical to the merged-in side lost this branch's edits.
591/** @param {Engine} $ */
592async function auditMerge($) {
593 const { root, pack } = await laneOf($)
594 const binary = pack.binaryAssets
595 if (!binary) return []
596 const git = (/** @type {string[]} */ args) => $.process.run(['git', '-C', root, ...args], { env: GIT_ENV, timeoutMs: 30000 })
597 const [, ours = '', theirs = ''] = (await git(['rev-list', '--parents', '-n', '1', 'HEAD'])).stdout.trim().split(/\s+/)
598 if (theirs === '') return []
599 const base = (await git(['merge-base', ours, theirs])).stdout.trim()
600 if (base === '') return []
601 const touched = (await git(['diff', '--name-only', base, ours])).stdout.split(/\r?\n/).filter(path => binary.test(path)).slice(0, 300)
602 if (touched.length === 0) return []
603 const blobs = async (/** @type {string} */ rev) => {
604 const map = new Map()
605 for (let i = 0; i < touched.length; i += 50) {
606 for (const line of (await git(['ls-tree', rev, '--', ...touched.slice(i, i + 50)])).stdout.split(/\r?\n/)) {
607 const match = /^\d+\s+blob\s+([0-9a-f]+)\t(.+)$/.exec(line)
608 if (match?.[1] && match[2]) map.set(match[2], match[1])
609 }
610 }
611 return map
612 }
613 const [merged, mine, other] = await Promise.all([blobs('HEAD'), blobs(ours), blobs(theirs)])
614 return touched.filter(path => merged.get(path) !== undefined && merged.get(path) === other.get(path) && merged.get(path) !== mine.get(path))
615}
616hooks/console.mjs 1616 lines1// @ts-check
2// Ather Automata, the visible half: what needs me, and what is next.
3//
4// Terminal: a one-line hint above the prompt, only when something needs you;
5// /ather opens one pane. Desktop (no drawing surface): /ather asks one question,
6// never a loop. Either way, picking something hands it to the session, which
7// asks its own questions. Shared state changes only through state.mjs.
8//
9// The host reads on(...) and $.noun.method(...) from source, so they are
10// spelled literally, and helpers that take $ are top-level functions.
11
12import { ALLOWED_TEXT, AWAY_PRESETS, isStopWord, parseAwayArgs, windowEndText } from './away.mjs'
13import { CREATE_SHOWN, skillFolder, askPrompt, batchPrompt, buildHome, heldByLine, intentStands, parseWeek, proofLine, trackConsequence, untrackText, dimColour, filterWork, personColours } from './home.mjs'
14import { issueLink, issuePrompt, parseIssues } from './issues.mjs'
15import { parsePrState, prsToRead } from './prs.mjs'
16import { STAGE_LABELS, aboutIntentPrompt, clockText, closestWord, currentStage, cutWords, directorCalls, localMinutes, nextStep, parseIntent, searchIntents } from './model.mjs'
17import { unreal } from './packs/unreal.mjs'
18import * as state from './state.mjs'
19import { crewOf } from './crew.mjs'
20import { crewSections } from './crew-rows.mjs'
21import { homeDir, resetTranscripts, sessionName } from './transcripts.mjs'
22import { recordEnd } from './workers.mjs'
23import { endLoop } from './inflight.mjs'
24import { changeGlyph } from './changes.mjs'
25import { EMPTY_CACHE, GIT_ENV, NO_SYNC, canFetchNow, fetchMain, isFetchDue, readTeam, syncText } from './team.mjs'
26import { GROUP_LABELS, SORT_LABELS, nextGroup, nextSort } from './worklist.mjs'
27import { AMBER, LIME, QUIET, choiceRow, findingRows, fit, homePreview, label, masthead, metaRow, needsRows, section, stageRow, statusLine, summaryStrip, workGroups } from './rows.mjs'
28import { DECIDED_SHOWN_MS, FRESH_ANSWERS, callId, needsView, pruneDecided, withDecided } from './decide.mjs'
29import { SETUP_PIECES, readSetup, setupPrompt, setupSummary, suggestPack } from './setup.mjs'
30
31/** @typedef {import('claude-code').EngineInterface} Engine */
32/** @typedef {'home' | 'pick' | 'away' | 'skills' | 'issue' | 'intent' | 'create' | 'finding'} Mode */
33/** @typedef {ReturnType<typeof buildHome>} Home */
34/** @typedef {import('./home.mjs').Item} Item */
35/** @typedef {import('./home.mjs').Next} Next */
36/** @typedef {import('./home.mjs').Work} Work */
37
38const PANE_ID = 'ather'
39// Queued work goes out after the command hook returns: a hook holds the turn,
40// and the engine refuses a prompt queued from inside it.
41const AFTER_HOOK_MS = 50
42// The drawn view is rebuilt when shared state changes, and at least this often for the lock file and workers.
43const VIEW_TTL_MS = 15000
44// GitHub is read in the background at this pace; a prompt never waits on it.
45const ISSUES_EVERY_MS = 15 * 60 * 1000
46
47let cwd = ''
48let me = ''
49/** @type {import('./model.mjs').Intent[]} */
50let intents = []
51// Items handed to the session in this session, shown as sent instead of offered twice.
52const sent = new Set()
53let paneMode = /** @type {Mode} */ ('home')
54// Create groups opened past their first three.
55/** @type {Set<string>} */
56const createOpen = new Set()
57// Intent changes after this were not seen yet: they make the band's notice.
58let intentSeenAt = 0
59// The issue whose card is open (paneMode 'issue'), and the view to go back to.
60let issueShown = 0
61let issueBack = /** @type {'home' | 'pick'} */ ('home')
62// The intent whose view is open (paneMode 'intent'; '' is the tracked one), and the view to go back to.
63let intentShown = ''
64let intentBack = /** @type {'home' | 'pick'} */ ('home')
65// The "Everything open" list: the words searched, its sort, how the teammates' intents are grouped
66// (read from the person's stored choice once a session), and the heads pressed to fold or unfold.
67let pickQuery = ''
68let pickSort = /** @type {import('./worklist.mjs').Sort} */ ('recent')
69let pickGroup = /** @type {import('./worklist.mjs').GroupBy} */ ('person')
70let isGroupRead = false
71// Presses of Group: a stored choice read back after a press does not undo it.
72let groupPresses = 0
73/** @type {Set<string>} */
74const pickFolded = new Set()
75// Needs you's intents opened to show each of their decisions.
76/** @type {Set<string>} */
77const callsOpen = new Set()
78// Answering in place (0.2.0, decide.mjs AnswerState): kept in this session only; this session's answers
79// stay until the files read them resolved. Then two view fields: the finding Open findings shows, and
80// whether Search's field is open.
81/** @type {import('./decide.mjs').AnswerState} */
82let answerState = { ...FRESH_ANSWERS }
83let findingShown = ''
84let isSearchOpen = false
85// What the last read of the team's intents learned (team.mjs reads each part again only when it moved),
86// and where the background fetch stands. Both, and `intents`, change together, in readIntents.
87let teamCache = EMPTY_CACHE
88let sync = NO_SYNC
89// A fetch that ended (the `count`th), for the first read that began after it to apply with what it brought in.
90/** @type {{ count: number, sync: Partial<import('./team.mjs').Sync> } | null} */
91let fetched = null
92let fetchesEnded = 0
93// The read running now, and whether another was asked for while it ran.
94/** @type {Promise<void> | null} */
95let reading = null
96let isRereadAsked = false
97// The pane has been drawn: from then on the fetch runs on its own, at most every ten minutes.
98let isDrawn = false
99/** @type {{ name: string, description: string }[]} */
100let skills = []
101// The band's ✕: hidden until it has something new to say.
102let closedHint = /** @type {string | null} */ (null)
103let isIssuesWarned = false
104let isWhoWarned = false
105let issueRetries = 0
106// A PR read is running: the minute's refresh does not start a second.
107let isPrsReading = false
108// The ↻ button: true while a refresh it started is running.
109let isIssuesRefreshing = false
110/** @type {Promise<void> | null} */
111let isAwake = null
112/** @type {{ version: number, at: number, model: Home | null }} */
113let view = { version: -1, at: 0, model: null }
114// The pack this checkout's lane chose (packs/index.mjs), for the drawing; set whenever the view is rebuilt.
115/** @type {import('./packs/index.mjs').Pack} */
116let pack = unreal
117
118// The store, files and session as closures: `$` cannot be handed to state.mjs itself.
119/** @param {Engine} $ @returns {import('./state.mjs').Io} */
120function io($) {
121 return {
122 get: key => $.store.get(key),
123 set: (key, value) => $.store.set(key, value),
124 remove: key => $.store.delete(key),
125 keys: () => $.store.keys(),
126 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null),
127 write: (path, text) => $.fs.write(path, text),
128 exists: path => $.fs.exists(path).catch(() => false),
129 sessionId: () => $.session.id(),
130 root: () => $.session.root(),
131 gitUser: async () => ((await $.process.run(['git', 'config', 'user.name'], { cwd: cwd || (await $.session.root()), timeoutMs: 10000 })).stdout ?? '').trim(),
132 redraw: () => $.ui.invalidate('ui.render'),
133 list: path => $.fs.list(path),
134 }
135}
136
137// Git and the checkout's files for team.mjs: git runs in `root` with GIT_ENV.
138/** @param {Engine} $ @param {string} root @returns {import('./team.mjs').Repo} */
139function repo($, root) {
140 return {
141 git: (args, { stdin, timeoutMs = 120000 } = {}) =>
142 $.process.run(['git', '-C', root, ...args], { cwd: root, env: GIT_ENV, timeoutMs, ...(stdin === undefined ? {} : { stdin }) }).catch(error => ({ exitCode: -1, stdout: '', stderr: String(error) })),
143 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null),
144 list: path => $.fs.list(path),
145 mtime: path => $.fs.stat(path).then(stat => stat.mtimeMs, () => 0),
146 }
147}
148
149// What transcripts.mjs and crew.mjs need of the engine.
150/** @param {Engine} $ @returns {import('./transcripts.mjs').Host} */
151function host($) {
152 return {
153 home: async () => (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '',
154 configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) || '',
155 list: path => $.fs.list(path),
156 read: path => $.fs.read(path).then(text => (typeof text === 'string' ? text : null), () => null),
157 exists: path => $.fs.exists(path).catch(() => false),
158 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }).catch(() => undefined),
159 agents: () => $.agent.list().catch(() => []),
160 }
161}
162
163/** @param {Engine} $ */
164function laneOf($) {
165 return state.lane(io($), cwd)
166}
167
168/** @param {import('claude-code').On} on */
169export function register(on) {
170 // A repository set up in this session: the console's work, skipped at the start, begins with the next wake.
171 state.onSetUp('console', () => {
172 isAwake = null
173 })
174
175 // The desktop app runs sessions the way the SDK does: not interactive at start, no surface yet.
176 // So the commands are registered in every session, and the work behind the console (reading
177 // intents and issues on timers) starts the first time someone draws or uses it, never in a
178 // scripted run nobody watches.
179 on('session.start', { isInteractive: true }, async ($, e, next) => {
180 const result = await next(e)
181 await openConsole($, e.cwd)
182 await wake($)
183 return result
184 })
185
186 on('session.start', { isInteractive: false }, async ($, e, next) => {
187 const result = await next(e)
188 await openConsole($, e.cwd)
189 return result
190 })
191
192 on('turn.complete', async ($, e, next) => {
193 const result = await next(e)
194 // Read again in the background: the turn's end never waits on git.
195 if (!e.agentId && (await laneOf($)).isS2) void refresh($).catch(() => undefined)
196 // A worker's turn ended: it finished now, not when the pane is next drawn.
197 if (e.agentId) recordEnd(e.agentId, Date.now())
198 // Its turn is over: nothing in its loop is in flight, whatever did not settle.
199 if (e.agentId) endLoop(e.agentId)
200 if (e.agentId) $.ui.invalidate('ui.render')
201 return result
202 })
203
204 on('command.run', { command: 'ather' }, async ($, e) => {
205 // Setting up is for a repository without intents too, so it is answered before the check below.
206 if (/^(setup|init)$/i.test(e.args.trim())) return { text: await setupCommand($) }
207 // A checkout without intents is read again: /ather setup may have added them in this session.
208 if (!(await state.laneAgain(io($), cwd)).isS2) return { text: await setupQuestion($) }
209 await wake($)
210 await refresh($).catch(() => undefined)
211 return { text: await atherCommand($, e.args.trim()) }
212 })
213
214 on('command.run', { command: 'away' }, async ($, e) => {
215 if (!(await laneOf($)).isS2) return { text: (await laneOf($)).pack.notHere }
216 await wake($)
217 return { text: await awayCommand($, e.args.trim()) }
218 })
219
220 // Ather's own questions reach the dialog as labels ($.ui.ask): each choice gets back what it does before it is
221 // drawn, and a dialog that closed by itself is noted, since its result alone says so.
222 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
223 if (next.origin.plugin !== $.plugin.name) return next(e)
224 const ran = await next({ ...e, questions: e.questions.map(question => ({ ...question, options: question.options.map(option => ({ ...option, description: option.description || describe(question.question, option.label) })) })) })
225 const result = /** @type {{ afkTimeoutMs?: unknown } | undefined} */ (ran.result)
226 for (const question of result?.afkTimeoutMs === undefined ? [] : e.questions) {
227 const open = asking.get(question.question)
228 if (open) open.isIdle = true
229 }
230 return ran
231 })
232
233 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
234 if (e.props.hasSurvey || !(await laneOf($)).isS2) return next(e)
235 void wake($)
236 const model = await home($)
237 const fresh = model.header.stage === 'Away' || model.open.some(one => one.kind === 'review') ? [] : await unseenChanges($)
238 const hint = fresh.length > 0 ? `◆ Intent: ${fresh.slice(0, 2).map(one => one.text.split(' · ')[0].replace(/^./, first => first.toLowerCase())).join(' · ')}${fresh.length > 2 ? ` · +${fresh.length - 2}` : ''}` : bandHint(model)
239 // Closed with ✕: stays away until there is something new to say.
240 if (closedHint !== null && (hint === '' || hint === closedHint)) return next(e)
241 closedHint = null
242 const { Box, Text, Button } = $.ui.resolve(e)
243 const isAway = model.header.stage === 'Away'
244 return Box({
245 flexDirection: 'row',
246 gap: 2,
247 children: [
248 Box({ key: 'ather-band-words', flexGrow: 1, children: [Text({ color: hint ? 'cyan' : undefined, dimColor: hint ? undefined : true, wrap: 'truncate', children: hint || '◆ Ather Automata' })] }),
249 ...(fresh.length > 0 ? [Button({ key: 'ather-intent-see', label: 'See', plain: true, onPress: () => void seeIntent($) })] : []),
250 Button({ key: 'ather-away', label: '☾', plain: true, dimColor: isAway ? undefined : true, onPress: () => void openPane($, 'away') }),
251 Button({ key: 'ather-open', label: '⤢', plain: true, dimColor: true, onPress: () => void openPane($, 'home') }),
252 Button({ key: 'ather-close', label: '✕', plain: true, dimColor: true, role: 'dismiss', onPress: () => {
253 closedHint = hint
254 intentSeenAt = Date.now()
255 $.ui.invalidate('ui.render')
256 $.ui.toast('Ather hidden until something needs you. /ather brings it back.')
257 } }),
258 ],
259 })
260 })
261
262 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
263 if (e.requestId !== PANE_ID) return next(e)
264 void wake($)
265 isDrawn = true
266 void syncMain($)
267 if (paneMode === 'intent') await readIntentView($)
268 if (paneMode === 'pick' && !isGroupRead) await readGroup($)
269 return paneView($.ui.resolve(e), $, await home($), e.props.bodyColumns ?? 80, e.surface, await crewOf(host($), (await laneOf($)).root, await state.sessionId(io($)), (await laneOf($)).pack))
270 })
271
272 on('ui.close', ($, e, next) => {
273 if (e.id === PANE_ID) {
274 // Only the view resets: this session's answers stay.
275 paneMode = 'home'
276 isSearchOpen = false
277 answerState = { ...answerState, typing: '' }
278 }
279 return next(e)
280 })
281}
282
283// ---------------------------------------------------------------- starting
284
285// Every session: fresh module state and the two commands.
286/** @param {Engine} $ @param {string} folder */
287async function openConsole($, folder) {
288 sent.clear()
289 paneMode = 'home'
290 isIssuesWarned = false
291 isWhoWarned = false
292 issueRetries = 0
293 resetTranscripts()
294 isAwake = null
295 view = { version: -1, at: 0, model: null }
296 closedHint = null
297 intentSeenAt = Date.now()
298 intentShown = ''
299 intentBack = 'home'
300 pickQuery = ''
301 pickSort = 'recent'
302 pickFolded.clear()
303 pickGroup = 'person'
304 isGroupRead = false
305 callsOpen.clear()
306 answerState = { ...FRESH_ANSWERS }
307 findingShown = ''
308 isSearchOpen = false
309 teamCache = EMPTY_CACHE
310 sync = NO_SYNC
311 fetched = null
312 fetchesEnded = 0
313 isDrawn = false
314 createOpen.clear()
315 cwd = folder
316 for (const command of [
317 { name: 'ather', description: 'Ather Automata: what needs you, and what is next', argumentHint: '[pick | find <words> | issues | issue <number> | tour | skip | role <role> | checked | intent <name> | untrack | setup]' },
318 { name: 'away', description: 'Ather Automata: going away? hand over with full autonomy, decisions recorded', argumentHint: '[tonight | 8h | 30m | until 9am | until done] [goal] | stop' },
319 ]) {
320 // One refused command must not take the other, or anything after, with it.
321 await $.command.register(command).catch(error => $.ui.log(`Ather console: /${command.name} not registered: ${String(error)}`, { to: 'debug' }))
322 }
323}
324
325// Once someone is there (a REPL start, the first draw, the first command): read intents and
326// issues, keep them fresh, and tell a newcomer about the tour. Runs once per session.
327/** @param {Engine} $ @returns {Promise<void>} */
328function wake($) {
329 if (!isAwake) isAwake = startConsoleWork($).catch(error => $.ui.log(`Ather console: start failed: ${String(error)}`, { to: 'debug' }))
330 return isAwake
331}
332
333/** @param {Engine} $ */
334async function startConsoleWork($) {
335 const lane = await laneOf($)
336 if (lane.me !== '') me = lane.me
337 pack = lane.pack
338 if (!lane.isS2) return
339 await refresh($)
340 $.clock.every(60000, () => void refresh($).then(() => (isDrawn ? syncMain($) : undefined)).catch(() => undefined))
341 // A running worker's clock: redrawn every five seconds while one runs, never otherwise.
342 $.clock.every(5000, () => {
343 void $.agent.list().then(agents => agents.some(agent => agent.status === 'running') && $.ui.invalidate('ui.render')).catch(() => undefined)
344 })
345 // watch.mjs may pick up last night's window just after this; show it.
346 $.clock.after(1500, () => void refresh($).catch(() => undefined))
347 void refreshIssues($).catch(() => undefined)
348 $.clock.every(ISSUES_EVERY_MS, () => void refreshIssues($).catch(() => undefined))
349 if ((await home($)).isNewcomer && !(await state.readProfile(io($), me)).isNudged) {
350 $.ui.toast(lane.pack.prompts.tourToast, { timeoutMs: 12000 })
351 await state.setProfile(io($), me, { isNudged: true })
352 }
353}
354
355// ---------------------------------------------------------------- the view model
356
357// Re-reads the intents (origin/main's and this checkout's, team.mjs), one read at a time: a call while
358// one runs asks for one more after it and waits for that, so the last read to land is the newest.
359// The rest comes from shared state when the view is rebuilt.
360/** @param {Engine} $ @returns {Promise<void>} */
361function refresh($) {
362 if (reading) {
363 isRereadAsked = true
364 return reading
365 }
366 reading = (async () => {
367 try {
368 do {
369 isRereadAsked = false
370 await readIntents($)
371 } while (isRereadAsked)
372 } finally {
373 reading = null
374 }
375 })()
376 return reading
377}
378
379/** @param {Engine} $ */
380async function readIntents($) {
381 // Only a fetch that ended before this read began is applied with what this read finds.
382 const seen = fetchesEnded
383 const { root, me: who, pack: chosen } = await laneOf($)
384 if (who !== me) stale()
385 me = who
386 pack = chosen
387 const files = io($)
388 const pinned = await state.readPinned(files)
389 const team = await readTeam(repo($, root), root, { cache: teamCache, pinned })
390 const read = []
391 for (const one of team.intents) read.push(parseIntent({ ...one, hasDebrief: one.slug === pinned && (await files.exists(`${root}/${chosen.debriefPath(one.slug)}`)) }, chosen))
392 const ended = fetched && fetched.count <= seen ? fetched : null
393 if (ended) fetched = null
394 teamCache = team.cache
395 intents = read.sort((a, b) => b.updatedAt - a.updatedAt)
396 // This session's answers to decisions are kept until the files read them resolved; an answer to
397 // anything else (a rule) has no file to settle it, so it stays for the session.
398 const waiting = new Set(intents.flatMap(one => directorCalls(one).map(finding => callId(one.slug, finding.id))))
399 answerState = { ...answerState, decided: pruneDecided(answerState.decided, id => waiting.has(id) || !id.startsWith('call:'), Date.now()) }
400 sync = { ...sync, ...ended?.sync, isRepo: team.isRepo, hasMain: team.cache.main !== null }
401 // The listed skills that exist here, each with the first sentence of its own description.
402 const found = []
403 for (const name of new Set([...chosen.skillGroups.flatMap(one => one.names), ...chosen.createGroups.flatMap(one => one.items.filter(item => !item.isGlobal).map(item => item.name))])) {
404 const text = await files.read(`${root}/${skillFolder(name)}/SKILL.md`)
405 if (text === null) continue
406 const description = (/^description:\s*(.+)$/m.exec(text)?.[1] ?? '').trim().replace(/^["']|["']$/g, '')
407 found.push({ name, description: /^(.+?[.!?])(\s|$)/.exec(description)?.[1] ?? description })
408 }
409 skills = found
410 stale()
411 $.ui.invalidate('ui.render')
412 void refreshPrs($).catch(() => undefined)
413}
414
415// Whether the PRs of intents with every item met are merged, read with gh in the background.
416// Each PR is asked about at most once per PR_EVERY_MS, a merged one never again. Never writes to GitHub.
417/** @param {Engine} $ */
418async function refreshPrs($) {
419 if (isPrsReading) return
420 isPrsReading = true
421 try {
422 const { root } = await laneOf($)
423 /** @type {Record<string, import('./model.mjs').PrState>} */
424 const read = {}
425 for (const number of prsToRead(intents, await state.readPrRecords(io($)), Date.now())) {
426 const run = await $.process.run(['gh', 'pr', 'view', String(number), '--json', 'state,mergedAt'], { cwd: root, timeoutMs: 30000 }).catch(() => undefined)
427 read[number] = (run && run.exitCode === 0 ? parsePrState(run.stdout) : null) ?? 'UNREAD'
428 }
429 await state.setPrStates(io($), read, Date.now())
430 } finally {
431 isPrsReading = false
432 }
433}
434
435// Fetches origin's main in the background (D2): once the pane is drawn, then at most every ten minutes,
436// or at once from ↻ (after a git lock, only at the next due time); one at a time. The sync line says
437// synced once the read after the fetch has landed; a failure keeps the last list and says so.
438/** @param {Engine} $ @param {boolean} [isAsked] */
439async function syncMain($, isAsked = false) {
440 if (!(isAsked ? canFetchNow : isFetchDue)(sync, Date.now())) return
441 sync = { ...sync, isFetching: true, triedAt: Date.now() }
442 $.ui.invalidate('ui.render')
443 const { error, lock, moved } = await fetchMain(repo($, (await laneOf($)).root))
444 $.ui.log(error ? `Ather: git fetch failed: ${error}` : `Ather: origin/main ${moved ? 'moved' : 'is up to date'}.`, { to: 'debug' })
445 fetchesEnded += 1
446 fetched = { count: fetchesEnded, sync: { isFetching: false, error, lock, ...(error ? { failedAt: Date.now() } : { fetchedAt: Date.now() }) } }
447 await refresh($).catch(() => undefined)
448 // The reads failed: the fetch's outcome is still said.
449 if (fetched) {
450 sync = { ...sync, ...fetched.sync }
451 fetched = null
452 $.ui.invalidate('ui.render')
453 }
454}
455
456function stale() {
457 view = { ...view, version: -1 }
458}
459
460// The GitHub issues assigned to the person, read with gh. Without gh, or signed out, there are
461// simply none: one line in the debug log, never an error on screen. Never writes to GitHub.
462/** @param {Engine} $ @returns {Promise<string>} why the read failed, or '' when it worked */
463async function refreshIssues($) {
464 const { root } = await laneOf($)
465 const run = await $.process.run(['gh', 'issue', 'list', '--assignee', '@me', '--state', 'open', '--limit', '30', '--json', 'number,title,url,labels,updatedAt'], { cwd: root, timeoutMs: 30000 }).catch(() => undefined)
466 if (!run || run.exitCode !== 0) {
467 // Signed out: the last list may be stale, so none is shown.
468 if (/auth login|not logged in|authentication/i.test(run?.stderr ?? '')) await state.setIssues(io($), me, [])
469 if (!isIssuesWarned) $.ui.log(`Ather: could not read your GitHub issues (is gh installed and signed in?) ${run?.stderr?.slice(0, 200) ?? ''}`, { to: 'debug' })
470 isIssuesWarned = true
471 // A slow first start or a network blip is tried again in a minute, three times at most:
472 // without gh at all, the 15-minute refresh is enough.
473 if (issueRetries < 3) {
474 issueRetries += 1
475 $.clock.after(60000, () => void refreshIssues($).catch(() => undefined))
476 }
477 return (run?.stderr || (run ? `gh exited with ${run.exitCode}` : 'gh could not be started (is it installed and on PATH?)')).trim().slice(0, 300)
478 }
479 issueRetries = 0
480 await state.setIssues(io($), me, parseIssues(run.stdout))
481 return ''
482}
483
484/** @param {Engine} $ @returns {Promise<Home>} */
485async function home($) {
486 const version = state.stateVersion()
487 if (view.model && view.version === version && Date.now() - view.at < VIEW_TTL_MS) return view.model
488 const files = io($)
489 const { root, me: who, pack: chosen } = await laneOf($)
490 pack = chosen
491 if (who !== '') me = who
492 else if (!isWhoWarned && (isWhoWarned = true)) $.ui.log('Ather: git user.name could not be read; the pane treats nobody as you until it is.', { to: 'debug' })
493 const tz = await state.readTz(files)
494 const now = Date.now()
495 const away = await state.readAway(files)
496 const profile = await state.readProfile(files, me, chosen)
497 // The week-calendar plugin keeps this week's figures in ~/.calendar/latest.json.
498 const userHome = await homeDir(host($))
499 const model = buildHome({
500 intents,
501 pinned: await state.readPinned(files),
502 me,
503 ...profile,
504 evidence: await state.readEvidence(files, await state.evidenceScope(files), chosen),
505 away,
506 ledger: away.phase === 'off' ? '' : ((await files.read(away.ledgerPath)) ?? ''),
507 lost: await state.readLost(files),
508 lock: await namedLock($, root, chosen.parseLock(chosen.lockFile ? await files.read(`${root}/${chosen.lockFile}`) : null, localMinutes(now, tz))),
509 recurring: await state.readRecurring(files, chosen),
510 issues: await state.readIssues(files, me),
511 prs: await state.readPrStates(files),
512 week: userHome ? parseWeek(await files.read(`${userHome}/.calendar/latest.json`), now) : null,
513 last: await state.readLast(files, me),
514 sent: [...sent],
515 skills,
516 workers: (await $.agent.list().catch(() => [])).filter(agent => agent.status === 'running').length,
517 now,
518 tz,
519 pack: chosen,
520 })
521 view = { version, at: now, model }
522 return model
523}
524
525// A held lock that names a Claude session shows that session's name, as its tab shows it.
526/** @param {Engine} $ @param {string} root @param {import('./model.mjs').EditorLock} lock */
527async function namedLock($, root, lock) {
528 if (lock.state !== 'held' || !lock.session) return lock
529 const name = await sessionName(host($), root, lock.session).catch(() => '')
530 return { ...lock, holder: name ? `"${name}"` : `session ${lock.session}` }
531}
532
533/** @param {Home} model */
534function bandHint(model) {
535 const { header } = model
536 if (header.stage === 'Away') return `🌙 Away ${header.progress} · ${header.sentence} · /ather`
537 if (model.open.some(one => one.kind === 'review')) return '☀ Welcome back · review the away window · /ather'
538 if (model.isNewcomer) return '◆ New here? Take the tour'
539 if (model.open.length > 0) return `◆ ${header.title} · ${model.open.length} need${model.open.length === 1 ? 's' : ''} you · /ather`
540 return ''
541}
542
543// ---------------------------------------------------------------- handing things to the session
544
545// Resolves once the session has the prompt; never awaited from a command hook. If another
546// plugin refuses the timer it never settles: the item only looks sent in this session, and
547// nothing persistent changes, because settling waits for delivery.
548/** @param {Engine} $ @param {string} text @returns {Promise<void>} */
549function deliver($, text) {
550 return new Promise((resolve, reject) => {
551 $.clock.after(AFTER_HOOK_MS, () => {
552 $.prompt.submit({ text }).then(() => resolve(), reject)
553 })
554 })
555}
556
557/** @param {Engine} $ @param {string} text */
558function fill($, text) {
559 $.clock.after(AFTER_HOOK_MS, () => {
560 void $.prompt.fill({ text }).catch(error => $.ui.toast(`Ather: could not fill the prompt box: ${String(error)}`))
561 })
562}
563
564// The one way things go to the session: shown as sent at once, `onDelivered` once the
565// session has the prompt, offered again if delivery fails.
566/** @param {Engine} $ @param {readonly string[]} ids @param {string} text @param {() => Promise<unknown>} [onDelivered] @param {() => Promise<unknown>} [onFailed] */
567function handOff($, ids, text, onDelivered, onFailed) {
568 for (const id of ids) sent.add(id)
569 stale()
570 void deliver($, text).then(
571 () => onDelivered?.(),
572 error => {
573 for (const id of ids) sent.delete(id)
574 void onFailed?.()
575 stale()
576 $.ui.invalidate('ui.render')
577 $.ui.toast(`Ather: could not send to the session: ${String(error)}`)
578 },
579 )
580}
581
582/** @param {Engine} $ @param {Item} one */
583async function act($, one) {
584 if (one.kind === 'away-end') return comeBack($)
585 if (one.kind === 'review') {
586 // The review is the person walking through the window: holds end as it is handed over, so the
587 // session's questions reach them. The prompt carries every decision and held action.
588 const saved = await state.readAway(io($))
589 await state.closeAway(io($))
590 handOff($, [one.id], one.prompt, undefined, () => state.restoreAway(io($), saved))
591 return 'Sent to the session.'
592 }
593 handOff($, [one.id], one.prompt, () => state.settleItem(io($), one))
594 return 'Sent to the session.'
595}
596
597// "I'm back": ends the window and hands its review to the session in one step.
598/** @param {Engine} $ */
599async function comeBack($) {
600 await state.endAway(io($))
601 stale()
602 const review = (await home($)).open.find(one => one.kind === 'review')
603 return review ? act($, review) : 'No away window is running.'
604}
605
606/** @param {Engine} $ @param {readonly Item[]} items */
607async function actAll($, items) {
608 handOff($, items.map(one => one.id), batchPrompt(items), async () => {
609 for (const one of items) await state.settleItem(io($), one)
610 })
611 return `Sent ${items.length} things to the session; it takes you through them one at a time.`
612}
613
614// An answer given in place: the row shows "✓ Decided" for a while, then folds. The session gets
615// `prompt` ('' gives it nothing: the press only closes the item); the item settles after the row has shown.
616/** @param {Engine} $ @param {Item} one @param {string} answer @param {string} prompt */
617async function answerItem($, one, answer, prompt) {
618 // A double click or a second Enter before the redraw: one answer, one prompt, one timer.
619 if (sent.has(one.id)) return `already answered: ${answer}`
620 answerState = { ...answerState, opened: '', typing: '', decided: withDecided(answerState.decided, { id: one.id, answer, at: Date.now() }) }
621 $.clock.after(DECIDED_SHOWN_MS + 100, () => $.ui.invalidate('ui.render'))
622 const settle = () => new Promise(resolve => $.clock.after(DECIDED_SHOWN_MS, () => resolve(state.settleItem(io($), one))))
623 const unanswer = async () => void (answerState = { ...answerState, decided: answerState.decided.filter(each => each.id !== one.id) })
624 if (prompt) handOff($, [one.id], prompt, settle, unanswer)
625 else {
626 sent.add(one.id)
627 stale()
628 void settle()
629 }
630 $.ui.invalidate('ui.render')
631 return prompt ? `sent your answer to the session: ${answer}` : `closed: ${answer}`
632}
633
634// A typed answer, from the field or the dialog's Other: the person's words are the choice.
635/** @param {Engine} $ @param {Item} one @param {import('./decide.mjs').Answers} answers @param {string} words */
636async function answerTyped($, one, answers, words) {
637 const text = words.trim()
638 return text === '' ? 'Nothing answered.' : answerItem($, one, text, answers.typed(text))
639}
640
641// Type an answer: a field under the row where the surface has one, else the question dialog's Other.
642/** @param {Engine} $ @param {Item} one @param {import('./decide.mjs').Answers} answers @param {boolean} hasInput */
643async function typeAnswer($, one, answers, hasInput) {
644 if (hasInput) {
645 answerState = { ...answerState, typing: one.id }
646 $.ui.invalidate('ui.render')
647 return 'type your answer and press Enter.'
648 }
649 /** @type {Choice[]} */
650 const choices = answers.options.slice(0, 4).map(option => ({ label: cutWords(`${option.letter}: ${option.label}${option.isRecommended ? ' (Recommended)' : ''}`, 60), description: cutWords(option.text, 200), run: () => answerItem($, one, option.letter, option.prompt) }))
651 if (choices.length === 0) choices.push({ label: 'Explain it first', description: 'The session explains it; nothing is decided.', run: async () => (handOff($, [], answers.explain), 'asked the session to explain it.') })
652 return ask($, { header: 'Answer', question: `${one.question}. Pick one, or type your own answer under Other.`, choices, fallback: 'Nothing answered.', onTyped: words => answerTyped($, one, answers, words) })
653}
654
655/** @param {Engine} $ @param {Next} next */
656async function doNext($, next) {
657 if (next.isTour) return startTour($)
658 if (next.work) return startWork($, next.work)
659 if (next.action === 'checked') return atherCommand($, 'checked')
660 if (next.isDraft) {
661 fill($, next.prompt)
662 return 'It is in the prompt box: finish it and press Enter.'
663 }
664 handOff($, [next.id], next.prompt)
665 return 'Sent to the session.'
666}
667
668/** @param {Engine} $ */
669async function startTour($) {
670 handOff($, ['next:tour'], pack.prompts.tour, () => state.setProfile(io($), me, { tourDone: true }))
671 return 'Starting the Ather tour.'
672}
673
674/** @param {Engine} $ @param {Work} work */
675async function startWork($, work) {
676 if (work.kind === 'intent') return trackSlug($, work.slug)
677 handOff($, [work.id], work.prompt)
678 return `Sent issue #${work.issue.number} to the session: it checks for overlapping work first, then drafts the intent with you.`
679}
680
681// An issue by number, from the assigned list or not.
682/** @param {Engine} $ @param {number} number @param {boolean} [isInQuestion] */
683async function startIssue($, number, isInQuestion = false) {
684 const assigned = (await state.readIssues(io($), me)).find(one => one.number === number)
685 if (assigned) {
686 handOff($, [`issue:${number}`], issuePrompt(assigned, me))
687 return `Sent issue #${number} to the session: it checks for overlapping work first, then drafts the intent with you.`
688 }
689 const issue = { number, title: '', name: '', url: '', labels: [], updatedAt: 0, area: 'Unsorted', isUrgent: false }
690 const go = async () => {
691 handOff($, [`issue:${number}`], issuePrompt(issue, me))
692 return `Sent issue #${number} to the session: it checks for overlapping work first, then drafts the intent with you.`
693 }
694 // Already inside a question: the session confirms instead of a third dialog.
695 if (isInQuestion) {
696 handOff($, [`issue:${number}`], `Issue #${number} is not assigned to me. Ask me to confirm before starting it; then: ${issuePrompt(issue, me)}`)
697 return `Sent issue #${number} to the session; it confirms with you first, since it is not assigned to you.`
698 }
699 return ask($, {
700 header: 'Issue',
701 question: `Issue #${number} is not one of your assigned issues. Start an intent from it anyway?`,
702 choices: [{ label: `Start issue #${number}`, description: 'The session checks for overlapping work first.', run: go }],
703 fallback: 'Not started.',
704 onTyped: text => typed($, text),
705 })
706}
707
708// Tracks an intent by its folder name: the deliberate act (Work on this here, Next, /ather intent <name>).
709/** @param {Engine} $ @param {string} slug */
710async function trackSlug($, slug) {
711 const { root } = await laneOf($)
712 // An intent read from origin/main that this checkout does not have yet cannot be worked on here.
713 if (!(await state.track(io($), root, slug, { me }))) return intents.some(one => one.slug === slug) ? `${slug} is on origin/main but not in this checkout yet: pull main to work on it here, or use Ask about it in its view to hear where it stands.` : `No intent named "${slug}" in docs/intent.`
714 await refresh($)
715 return `Now tracking ${slug}.`
716}
717
718// /ather intent <name> and /ather pick <name>: an exact folder name tracks it; words show the one intent they match.
719/** @param {Engine} $ @param {string} text */
720async function pickIntent($, text) {
721 return intents.some(one => one.slug === text) ? trackSlug($, text) : lookUp($, text)
722}
723
724// Words that match one intent show it and never track it; several are listed; none is said.
725/** @param {Engine} $ @param {string} text */
726async function lookUp($, text) {
727 const matches = searchIntents(intents, text)
728 const [only] = matches
729 if (matches.length === 1 && only) return showIntent($, only.slug)
730 return matches.length > 1 ? `${matches.length} intents match "${text}": ${matches.slice(0, 8).map(one => one.slug).join(', ')}.` : `No open intent matches "${text}".`
731}
732
733// Looking at an intent: its view in the pane, where Work on this here tracks it. Without a pane,
734// where it stands and the command that tracks it.
735/** @param {Engine} $ @param {string} slug */
736async function showIntent($, slug) {
737 if (await hasPane($)) {
738 intentShown = slug
739 intentBack = 'home'
740 return openPane($, 'intent')
741 }
742 return whereText($, slug)
743}
744
745// Without a pane or a dialog: where an intent stands, and the command that works on it here.
746/** @param {Engine} $ @param {string} slug */
747async function whereText($, slug) {
748 const pinned = await state.readPinned(io($))
749 const one = (await home($)).work.find(work => work.kind === 'intent' && work.slug === slug)
750 const where = one ? one.hint : slug
751 return slug === pinned ? `${where}. This session tracks it.` : `${where}. To work on it in this session: /ather intent ${slug}`
752}
753
754// A row's press: the intent's view, never tracking it.
755/** @param {Engine} $ @param {string} slug @param {'home' | 'pick'} back */
756function viewIntent($, slug, back) {
757 return () => {
758 intentShown = slug
759 intentBack = back
760 paneMode = 'intent'
761 $.ui.invalidate('ui.render')
762 }
763}
764
765// Stops tracking the session's intent: /ather untrack, and Stop tracking in the Intent view.
766/** @param {Engine} $ */
767async function untrackHere($) {
768 const outcome = await state.untrack(io($), me)
769 if (outcome.result === 'untracked') await refresh($)
770 return untrackText(outcome)
771}
772
773/** @param {Engine} $ @param {import('./away.mjs').WindowChoice} choice */
774async function startAway($, choice) {
775 const tz = await state.readTz(io($))
776 const away = await state.startAway(io($), choice, { root: (await laneOf($)).root, me, tz, now: Date.now(), pack })
777 if (away === null) return 'An away window is already running or waiting for your review: /ather shows it.'
778 const { root } = await laneOf($)
779 const ledger = away.ledgerPath.startsWith(root) ? away.ledgerPath.slice(root.length + 1) : away.ledgerPath
780 handOff($, ['away-start'], `I am away ${windowEndText(away, tz)}. Goal: ${choice.goal || 'continue the active work'}. Work through it without waiting for me and record every decision you take for me in ${ledger}.`)
781 return `Away ${windowEndText(away, tz)}${choice.goal ? ` (goal: ${choice.goal})` : ''}. ${pack.mandate.away}`
782}
783
784// Text typed instead of picking: an intent, a question for the session, or nothing.
785/** @param {Engine} $ @param {string} text @param {boolean} [isInQuestion] typed under Other, so no further question */
786async function typed($, text, isInQuestion = true) {
787 if (/^tours?$/i.test(text.trim())) return startTour($)
788 const number = /^#(\d+)$|^(\d{3,7})$/.exec(text.trim())
789 if (number) return startIssue($, Number(number[1] ?? number[2]), isInQuestion)
790 if (searchIntents(intents, text).length > 0) return lookUp($, text)
791 const question = /^(help|\?)$/i.test(text.trim()) ? 'What can Ather do for me?' : text
792 void deliver($, askPrompt(question, pack)).catch(error => $.ui.toast(`Ather: could not send to the session: ${String(error)}`))
793 return 'Sent your question to the session.'
794}
795
796// ---------------------------------------------------------------- commands
797
798/** @param {Engine} $ */
799async function hasPane($) {
800 const surfaces = /** @type {readonly string[]} */ (await $.session.surfaces().catch(() => []))
801 return surfaces.includes('terminal') || surfaces.includes('desktop')
802}
803
804/** @param {Engine} $ */
805async function skipTour($) {
806 await state.setProfile(io($), me, { tourDone: true })
807 return ask($, {
808 header: 'Your role',
809 question: 'Tour skipped (/ather tour brings it back). What kind of work do you do? It decides what proof Ather asks for.',
810 choices: pack.roles.map(role => ({ label: pack.roleLabels[role] ?? role, description: pack.roleDescriptions[role] ?? '', run: () => atherCommand($, `role ${role}`) })),
811 fallback: pack.roleFallback,
812 onTyped: text => atherCommand($, `role ${text}`),
813 })
814}
815
816// ---------------------------------------------------------------- setting a repository up
817
818// /ather setup (setup.mjs): the session is handed the bundle and what is missing here, and does the
819// writing itself. With nothing missing it answers with what the profile reads as, and sends nothing.
820/** @param {Engine} $ */
821async function setupCommand($) {
822 const { root } = await laneOf($)
823 const setup = await readSetup(io($), root)
824 if (setup.isComplete) return setupSummary(setup)
825 // The bundle ships in the plugin, beside hooks/.
826 const zip = `${$.plugin.root.replace(/\\/g, '/')}/templates/intent-setup.zip`
827 const pack = suggestPack(await $.fs.list(root).catch(() => []))
828 void deliver($, setupPrompt({ zip, missing: setup.missing, pack })).catch(error => $.ui.toast(`Ather: could not send to the session: ${String(error)}`))
829 return `Asked the session to set up intents here. To add: ${SETUP_PIECES.filter(one => setup.missing.includes(one.id)).map(one => one.path).join(', ')}. It asks you before it writes anything.`
830}
831
832// /ather where there are no intents: one question, never the setup itself. Unanswered, it says where
833// Ather works and names the way in.
834/** @param {Engine} $ */
835async function setupQuestion($) {
836 const notHere = `${(await laneOf($)).pack.notHere} /ather setup adds the structure.`
837 return ask($, {
838 header: 'Intents',
839 question: 'This repository has no intents yet (no docs/intent folder). Set them up?',
840 choices: [
841 { label: 'Set up intents here', description: 'This session reads the repository, proposes areas and gates, and asks you before it writes.', run: () => setupCommand($) },
842 { label: 'Not now', description: 'Nothing changes. /ather setup does it later.', run: async () => notHere },
843 ],
844 fallback: notHere,
845 onTyped: async () => notHere,
846 })
847}
848
849// What /ather understands after its name; a typo of one of these ("tuor", "isue") is read as it.
850const COMMAND_WORDS = ['tour', 'skip', 'pick', 'find', 'issues', 'issue', 'intent', 'role', 'checked', 'untrack', 'setup', 'init']
851
852/** @param {Engine} $ @param {string} args */
853async function atherCommand($, args) {
854 const word = args.split(/\s+/)[0]?.toLowerCase() ?? ''
855 const rest = args.slice(word.length).trim()
856 if (word === 'tour' || word === 'tours') return startTour($)
857 if (word === 'skip') return skipTour($)
858 if (word === 'untrack' && rest === '') return untrackHere($)
859 if (word === 'find') {
860 if (rest) setSearch($, rest)
861 if (await hasPane($)) return openPane($, 'pick')
862 return rest ? findText($) : searchQuestion($)
863 }
864 if ((word === 'intent' || word === 'pick') && rest) return pickIntent($, rest)
865 if ((word === 'issue' || word === 'issues') && /^#?\d+$/.test(rest)) return startIssue($, Number(rest.replace('#', '')))
866 if (word === 'role') {
867 const role = pack.parseRole(rest)
868 if (!role) return pack.roleHelp
869 await state.setProfile(io($), me, { role }, pack)
870 return `Your role is ${pack.roleLabels[role] ?? role}. It shapes the next step and what Prove asks for.`
871 }
872 if (word === 'checked') {
873 const own = pack.ownCheck
874 if (!own) return 'Nothing to record by hand here: Ather reads every proof from tool output.'
875 await state.setRung(io($), await state.evidenceScope(io($)), own.rung, { state: 'pass', detail: own.detail })
876 return own.reply
877 }
878 if (word === 'issues') {
879 // Read them now: a list that never showed up is explained here instead of staying empty.
880 const failure = await refreshIssues($).catch(error => String(error))
881 if (failure) return `Could not read your GitHub issues: ${failure}`
882 if ((await state.readIssues(io($), me)).length === 0) return 'No open GitHub issues are assigned to you.'
883 return (await hasPane($)) ? openPane($, 'pick') : workQuestion($)
884 }
885 if (word === 'pick') return (await hasPane($)) ? openPane($, 'pick') : workQuestion($)
886 // "/ather tuor": a typo of a command word is pointed out, never run ("ship" is one letter from "skip").
887 const meant = rest === '' ? closestWord(word, COMMAND_WORDS) : null
888 if (meant && searchIntents(intents, word).length === 0) return `Did you mean /ather ${meant}?`
889 if (word !== '') return typed($, args, false)
890 // /ather also brings back a band closed with ✕.
891 closedHint = null
892 return (await hasPane($)) ? openPane($, 'home') : menuQuestion($)
893}
894
895/** @param {Engine} $ @param {string} args */
896async function awayCommand($, args) {
897 const away = await state.readAway(io($))
898 if (isStopWord(args)) {
899 if (await state.endAway(io($))) return 'Away window ended; held actions stay held until you review it. Type /ather.'
900 return away.phase === 'review' ? 'The away window has ended; type /ather to review it.' : 'No away window is running.'
901 }
902 if (away.phase === 'running') return `An away window is running ${windowEndText(away, await state.readTz(io($)))}. /away end ends it.`
903 if (away.phase === 'review') return 'The last away window waits for your review: type /ather.'
904 const parsed = parseAwayArgs(args, localMinutes(Date.now(), await state.readTz(io($))))
905 return parsed ? startAway($, parsed) : presetQuestion($, args)
906}
907
908// ---------------------------------------------------------------- one question (desktop, and /away anywhere)
909
910/** @typedef {{ label: string, description: string, run: () => Promise<string> }} Choice */
911
912// $.ui.ask rejects on a dismissal. A surface that answers one with bracketed text instead, and Ather's own Close, count as one too.
913const DISMISSED = /^\[.*\]$|^(not now|close|skip|cancel|dismiss(ed)?|no preference)[.!]?$/i
914
915/** @param {unknown} value */
916function asAnswer(value) {
917 if (typeof value !== 'string') return null
918 const text = value.trim()
919 return text === '' || DISMISSED.test(text) ? null : text
920}
921
922// Offered beside a single choice: the engine would pad it with "Yes", and a way out reads better.
923const CLOSE = { label: 'Close', description: 'Close this without choosing.' }
924
925// Each question now open, by its text: its choices, and whether its dialog closed by itself.
926/** @type {Map<string, { choices: readonly Choice[], isIdle: boolean }>} */
927const asking = new Map()
928
929// What a choice of an open question does: $.ui.ask takes labels alone, so the tool.call hook in register puts this back.
930/** @param {string} question @param {string} label */
931function describe(question, label) {
932 return [...(asking.get(question)?.choices ?? []), CLOSE].find(choice => choice.label === label)?.description ?? ''
933}
934
935// One question; runs the chosen answer, hands typed text to onTyped, or returns the fallback when dismissed.
936/** @param {Engine} $ @param {{ header: string, question: string, choices: readonly Choice[], fallback: string, onTyped: (text: string) => Promise<string> }} spec */
937async function ask($, spec) {
938 const choices = spec.choices.slice(0, 4)
939 const labels = choices.map(choice => choice.label)
940 if (labels.length === 1) labels.push(CLOSE.label)
941 const open = { choices, isIdle: false }
942 asking.set(spec.question, open)
943 // Rejects when dismissed, and where nobody can be asked (a -p run).
944 const answer = await $.ui.ask(spec.question, { options: labels, header: spec.header.slice(0, 12) }).then(asAnswer, () => null)
945 asking.delete(spec.question)
946 // Resolved by itself while the person was away from the keyboard: nobody chose anything.
947 if (answer === null || open.isIdle) return spec.fallback
948 const picked = /^\d$/.test(answer) ? spec.choices[Number(answer) - 1] : undefined
949 const chosen = picked ?? spec.choices.find(choice => choice.label === answer || choice.label.replace(/ \(Recommended\)$/, '') === answer)
950 return chosen ? chosen.run() : spec.onTyped(answer)
951}
952
953// `/ather` without a drawing surface: where things stand is the question, the few things worth doing are the answers.
954/** @param {Engine} $ */
955async function menuQuestion($) {
956 const model = await home($)
957 /** @type {Choice[]} */
958 const choices = []
959 const next = model.next
960 if (next?.isTour) {
961 choices.push({ label: 'Take the tour (Recommended)', description: next.hint, run: () => doNext($, next) })
962 const [mine] = model.work.filter(one => one.isMine)
963 if (mine) choices.push({ label: cutWords(mine.kind === 'issue' ? `Start #${mine.issue.number} ${mine.issue.name}` : `Pick up ${mine.slug}`, 40), description: mine.hint, run: () => startWork($, mine) })
964 choices.push({ label: 'Skip the tour', description: 'You know your way around; Ather asks your role instead.', run: () => skipTour($) })
965 }
966 // What happened while the person was away is reviewed on its own, before anything else.
967 const review = model.open.find(one => one.kind === 'review' || one.kind === 'away-end')
968 if (review) choices.push({ label: review.label, description: review.question, run: () => act($, review) })
969 const rest = model.open.filter(one => one !== review)
970 const [only] = rest
971 if (rest.length === 1 && only) choices.push({ label: only.label, description: only.question, run: () => act($, only) })
972 else if (rest.length > 1) choices.push({ label: `Go through ${rest.length} things`, description: cutWords(rest.map(one => one.question).join(' · '), 200), run: () => actAll($, rest) })
973 if (next && !next.isTour && !sent.has(next.id)) choices.push({ label: cutWords(next.work?.kind === 'intent' || next.action ? next.label : `Next: ${next.label}`, 40), description: next.hint, run: () => doNext($, next) })
974 if (model.offerAway) choices.push({ label: 'Heading off?', description: 'Hand over until done, for 8 or 4 hours.', run: () => presetQuestion($, '') })
975 choices.push({ label: model.header.title === 'Ather' ? 'Pick something to work on' : 'Switch to other work', description: 'Your intents and GitHub issues first.', run: () => workQuestion($) })
976 const { header } = model
977 const where = [header.stage, header.progress].filter(Boolean).join(', ')
978 const lead =
979 header.stage === 'Away'
980 ? `Away ${header.progress}: ${header.sentence}.`
981 : review
982 ? `Welcome back. ${review.question}.`
983 : model.isNewcomer
984 ? `${me ? `Hi ${me.split(/\s+/)[0]}, new` : 'New'} to Ather? Start with the tour.`
985 : header.title === 'Ather'
986 ? model.work.some(one => one.kind === 'intent' && one.isMine)
987 ? 'Nothing tracked in this session.'
988 : 'Not working on an intent yet.'
989 : `${header.title}: ${where.charAt(0).toLowerCase() + where.slice(1) || 'no checklist yet'}.`
990 const waiting = rest.length > 0 ? ` Waiting on you: ${rest.map(one => (one.kind === 'call' ? one.question.split(':')[0] : one.label.charAt(0).toLowerCase() + one.label.slice(1))).join(', ')}.` : ''
991 const status = [header.title, header.stage, header.progress, model.open.length > 0 ? `${model.open.length} need${model.open.length === 1 ? 's' : ''} you` : header.sentence].filter(Boolean).join(' · ')
992 return ask($, { header: 'Ather', question: `${lead}${waiting} What now?`, choices: choices.slice(0, 4), fallback: status, onTyped: text => typed($, text) })
993}
994
995/** @param {Engine} $ */
996async function workQuestion($) {
997 const work = (await home($)).work.slice(0, 4)
998 if (work.length === 0) return 'Nothing open yet: start an intent with /intent and what you want.'
999 return ask($, {
1000 header: 'Work',
1001 question: 'What should this session work on? Your intents and your GitHub issues come first. Or type a name, or an issue #number.',
1002 // The question is the verb: a choice works on it at once, and says so (D5).
1003 choices: work.map(one => ({ label: cutWords(one.label, 40), description: workChoiceText(one), run: () => startWork($, one) })),
1004 fallback: 'Nothing chosen.',
1005 onTyped: text => typedWork($, text),
1006 })
1007}
1008
1009// A Work-question choice's line: where it stands, then what choosing it does.
1010/** @param {Work} one */
1011function workChoiceText(one) {
1012 return one.kind === 'intent' ? `${ended(workDetail(one))} ${trackConsequence(pack)}` : `${ended(one.hint)} Drafts an intent with you first.`
1013}
1014
1015// A line ended as a sentence, unless it was cut short ("…").
1016/** @param {string} text */
1017const ended = text => (/[.…]$/.test(text) ? text : `${text}.`)
1018
1019// Typed in the Work question: a name never tracks at once (D5). One match asks what to do with it
1020// (the phone's Intent view); a few become the choices; more are listed; anything else is read as in any dialog.
1021/** @param {Engine} $ @param {string} text */
1022async function typedWork($, text) {
1023 const words = text.trim()
1024 // The tour and an issue number mean what they mean anywhere.
1025 if (/^tours?$/i.test(words) || /^#?\d+$/.test(words)) return typed($, text)
1026 const matches = intents.some(one => one.slug === words) ? intents.filter(one => one.slug === words) : searchIntents(intents, words)
1027 const [only] = matches
1028 if (matches.length === 1 && only) return intentQuestion($, only.slug)
1029 if (matches.length > 1 && matches.length <= 4) {
1030 const work = (await home($)).work
1031 return ask($, {
1032 header: 'Work',
1033 question: `${matches.length} intents match "${words}". Which one should this session work on?`,
1034 choices: matches.map(one => {
1035 const row = work.find(item => item.kind === 'intent' && item.slug === one.slug)
1036 return { label: cutWords(one.slug, 40), description: `${row ? `${ended(workDetail(row))} ` : ''}${trackConsequence(pack)}`, run: () => trackSlug($, one.slug) }
1037 }),
1038 fallback: await lookUp($, words),
1039 onTyped: more => typed($, more),
1040 })
1041 }
1042 return typed($, text)
1043}
1044
1045// One intent named in the Work question, without a pane: where it stands, what working on it here
1046// means, and three ways on. Where no dialog can be asked, the reply says how to work on it instead.
1047/** @param {Engine} $ @param {string} slug */
1048async function intentQuestion($, slug) {
1049 const intent = intents.find(one => one.slug === slug)
1050 if (!intent) return lookUp($, slug)
1051 const files = io($)
1052 const { root, pack: chosen } = await laneOf($)
1053 const { role } = await state.readProfile(files, me, chosen)
1054 const evidence = await state.readEvidence(files, slug, chosen)
1055 const prs = await state.readPrStates(files)
1056 const stands = intentStands(intent, STAGE_LABELS[currentStage(intent, evidence, role, prs, chosen)], me, heldByLine(await state.readPeers(files, root, chosen.localDir), slug, Date.now()))
1057 const look = async () => {
1058 const step = nextStep(role, intent, evidence, 0, me, prs, chosen)
1059 return `${stands}${step ? ` Its next step: ${step.label}.` : ''} Not tracked here; /ather intent ${slug} works on it in this session.`
1060 }
1061 return ask($, {
1062 header: slug,
1063 question: `${stands} Work on it here? ${trackConsequence(chosen)}`,
1064 choices: [
1065 { label: 'Work on it here', description: 'Tracks it in this session now.', run: () => trackSlug($, slug) },
1066 { label: 'Just look', description: 'Says where it stands and its next step; tracks nothing.', run: look },
1067 { label: 'Pick something else', description: 'Back to what this session could work on.', run: () => workQuestion($) },
1068 ],
1069 fallback: await whereText($, slug),
1070 onTyped: more => typed($, more),
1071 })
1072}
1073
1074/** @param {Engine} $ @param {string} goal */
1075async function presetQuestion($, goal) {
1076 const tz = await state.readTz(io($))
1077 const end = (/** @type {number} */ hours) => `until ${clockText(Date.now() + hours * 3600000, tz)}`
1078 return ask($, {
1079 header: 'Away',
1080 question: `Heading off?${goal ? ` Goal: "${goal}".` : ''} While you are away the session may push branches and open PRs; nothing merges until you are back, and every decision it makes is written down for you.`,
1081 choices: AWAY_PRESETS.map((preset, index) => ({
1082 label: index === 0 ? `${preset.label} (Recommended)` : preset.label,
1083 description: preset.choice.untilDone ? 'Ends when the work is done, or after 24 hours at the latest.' : `${end(preset.choice.hours)}.`,
1084 run: () => startAway($, { ...preset.choice, goal }),
1085 })),
1086 fallback: 'Not started.',
1087 onTyped: async text => {
1088 const parsed = parseAwayArgs(text, localMinutes(Date.now(), tz))
1089 return parsed ? startAway($, { ...parsed, goal: parsed.goal || goal }) : 'Not started. Try "8h", "until 9am" or "until done".'
1090 },
1091 })
1092}
1093
1094// ---------------------------------------------------------------- the work list's search and filters
1095
1096// Narrows "Everything open" to the words (title, number, area or owner); '' shows everything.
1097/** @param {Engine} $ @param {string} text */
1098function setSearch($, text) {
1099 pickQuery = text.trim()
1100 $.ui.invalidate('ui.render')
1101 return pickQuery ? `Searching for "${pickQuery}".` : 'Showing everything.'
1102}
1103
1104// Group: Person → Area → Stage → None, remembered for the person (folds are not).
1105/** @param {Engine} $ */
1106function cycleGroup($) {
1107 pickGroup = nextGroup(pickGroup)
1108 groupPresses += 1
1109 isGroupRead = true
1110 $.ui.invalidate('ui.render')
1111 void state.setGroupBy(io($), me, pickGroup).catch(() => undefined)
1112}
1113
1114/** @param {Engine} $ */
1115async function readGroup($) {
1116 const pressesBefore = groupPresses
1117 const stored = await state.readGroupBy(io($), me).catch(() => pickGroup)
1118 if (groupPresses === pressesBefore) pickGroup = stored
1119 isGroupRead = true
1120}
1121
1122/** @param {Engine} $ */
1123function cycleSort($) {
1124 pickSort = nextSort(pickSort)
1125 $.ui.invalidate('ui.render')
1126}
1127
1128// Folds or unfolds a group of Everything open, or opens or closes an intent's decisions in Needs you.
1129/** @param {Engine} $ @param {Set<string>} opened @param {string} key */
1130function toggleIn($, opened, key) {
1131 if (!opened.delete(key)) opened.add(key)
1132 $.ui.invalidate('ui.render')
1133}
1134
1135// Without a pane: what the search found, in a line.
1136/** @param {Engine} $ */
1137async function findText($) {
1138 const found = filterWork((await home($)).work, pickQuery)
1139 return found.length === 0 ? `Nothing matches "${pickQuery}".` : `${found.length} match "${pickQuery}": ${found.slice(0, 8).map(one => one.label).join(', ')}${found.length > 8 ? ', …' : ''}.`
1140}
1141
1142// Where the surface draws no text field (and for /ather find without words): one question, and what is typed under Other is the search.
1143/** @param {Engine} $ */
1144async function searchQuestion($) {
1145 return ask($, {
1146 header: 'Search',
1147 question: `Search everything open by title, issue number, area or owner. Type the words under Other.${pickQuery ? ` Searching for "${pickQuery}" now.` : ''}`,
1148 choices: [{ label: 'Show everything', description: 'Clear the search.', run: async () => setSearch($, '') }],
1149 fallback: pickQuery ? `Still searching for "${pickQuery}".` : 'Nothing searched.',
1150 onTyped: async text => setSearch($, text),
1151 })
1152}
1153
1154// ---------------------------------------------------------------- the pane (terminal)
1155
1156/** @param {Engine} $ @param {Mode} mode */
1157async function openPane($, mode) {
1158 paneMode = mode
1159 await $.ui.open({ id: PANE_ID, title: 'ATHER AUTOMATA', focus: true, closeOnEscape: true, rows: 22 })
1160 $.ui.invalidate('ui.render')
1161 return mode === 'pick' ? 'Everything open: ↑↓ move · Enter choose · Esc close.' : 'Ather: ↑↓ move · Enter choose · Esc close.'
1162}
1163
1164/** @param {Engine} $ @param {() => Promise<string>} run @param {boolean} keepOpen */
1165function press($, run, keepOpen) {
1166 return () =>
1167 void run()
1168 .then(async text => {
1169 if (!keepOpen) await $.ui.close({ id: PANE_ID }).catch(() => undefined)
1170 $.ui.toast(`Ather: ${text}`)
1171 })
1172 .catch(error => $.ui.toast(`Ather: ${String(error)}`))
1173}
1174
1175// Reads the assigned issues again now, and says what came back.
1176/** @param {any} el @param {Engine} $ */
1177function refreshIssuesButton(el, $) {
1178 const onPress = () => {
1179 if (isIssuesRefreshing) return
1180 isIssuesRefreshing = true
1181 $.ui.invalidate('ui.render')
1182 void refreshIssues($)
1183 .catch(error => String(error))
1184 .then(async failure => {
1185 const count = failure ? 0 : (await state.readIssues(io($), me)).length
1186 $.ui.toast(failure ? `Ather: could not read your GitHub issues: ${failure}` : `Ather: ${count === 0 ? 'no open GitHub issues are assigned to you' : `${count} open GitHub issue${count === 1 ? '' : 's'} assigned to you`}.`)
1187 })
1188 .finally(() => {
1189 isIssuesRefreshing = false
1190 stale()
1191 $.ui.invalidate('ui.render')
1192 })
1193 }
1194 return el.Box({ key: 'issues-refresh-row', marginTop: 1, children: [el.Button({ key: 'issues-refresh', label: isIssuesRefreshing ? '↻ Refreshing…' : '↻ Refresh GitHub issues', hotkey: hotkeyFor('r'), plain: true, dimColor: true, onPress })] })
1195}
1196
1197/** @param {Engine} $ @param {Mode} mode */
1198function show($, mode) {
1199 return () => {
1200 paneMode = modehooks/away.mjs 138 lines1// @ts-check
2// Ather Automata: the autonomy window's rules. What a window allows and holds,
3// its ledger text, and the mandate the session is given. Pure: no `$`; the one
4// place that changes a window is state.mjs.
5
6import { HELD_LABELS } from './guards.mjs'
7import { clockText } from './model.mjs'
8import { unreal } from './packs/unreal.mjs'
9
10/** @typedef {import('./packs/index.mjs').Pack} Pack */
11
12/**
13 * @typedef {{ id: string, kind: string, command: string, at: number }} Parked
14 * A window holds actions and records questions from the moment it starts until the person has reviewed it:
15 * 'running' while they are away, 'review' once it has ended and waits for them.
16 * @typedef {{ phase: 'off' | 'running' | 'review', untilDone: boolean, goal: string, held: string[], startedAt: number, wakeAt: number, endedAt?: number, ledgerPath: string, parked: Parked[], person: string, root: string }} Away
17 * @typedef {{ hours: number, untilDone: boolean, goal: string, held?: string[] }} WindowChoice
18 */
19
20/** @returns {Away} */
21export const offAway = () => ({ phase: 'off', untilDone: false, goal: '', held: ['merge', 'push-main'], startedAt: 0, wakeAt: 0, ledgerPath: '', parked: [], person: '', root: '' })
22
23// Held actions stay held until the person has reviewed the window, not only while it runs.
24/** @param {Away} away */
25export const isHolding = away => away.phase !== 'off'
26
27// The session's questions go to the ledger while the window runs, and after it ends until the person
28// is back: the first thing they type after the end (at `lastPersonAt`) means they can be asked again.
29/** @param {Away} away @param {number} lastPersonAt */
30export const isRecordingQuestions = (away, lastPersonAt) => away.phase === 'running' || (away.phase === 'review' && lastPersonAt < (away.endedAt ?? 0))
31
32// Presets (decided 2026-10-03). Each may push branches and open draft PRs and PRs to main;
33// merges and direct pushes to main stay held.
34export const AWAY_PRESETS = [
35 { label: 'Until done', hotkey: 'u', choice: { hours: 24, untilDone: true } },
36 { label: '8 hours', hotkey: 'o', choice: { hours: 8, untilDone: false } },
37 { label: '4 hours', hotkey: 'h', choice: { hours: 4, untilDone: false } },
38]
39export const ALLOWED_TEXT = 'push branches, open draft PRs, open PRs to main'
40
41/** @param {number} hours */
42export const clampHours = hours => Math.min(16, Math.max(0.25, hours))
43
44// "/away stop", "/away end now": ending, in the words people use.
45/** @param {string} args */
46export const isStopWord = args => /^((i'?m|i am)\s+)?(end|stop|off|cancel|quit|finish|back|home)(\s+now)?[.!]?$/i.test(args.trim())
47
48// "/away until done ship it", "/away 6h fix the pool", "30m", "until 9am", "tonight": how long, then the goal.
49// `nowMinutes` is the person's local time of day, for "until 9am" and "tonight" (until 09:00).
50/** @param {string} args @param {number} nowMinutes @returns {WindowChoice | null} */
51export const parseAwayArgs = (args, nowMinutes) => {
52 const text = args.trim()
53 const done = /^(until[\s-]*done|done)\b[\s,]*/i.exec(text)
54 if (done) return { hours: 24, untilDone: true, goal: text.slice(done[0].length).trim() }
55 const hours = /^(\d{1,2}(?:\.\d+)?)\s*h(?:ours?|rs?)?\b[\s,]*/i.exec(text)
56 if (hours) return { hours: clampHours(Number(hours[1])), untilDone: false, goal: text.slice(hours[0].length).trim() }
57 const minutes = /^(\d{1,3})\s*m(?:in(?:ute)?s?)?\b[\s,]*/i.exec(text)
58 if (minutes) return { hours: clampHours(Number(minutes[1]) / 60), untilDone: false, goal: text.slice(minutes[0].length).trim() }
59 const until = /^(?:until|till|til)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b[\s,]*/i.exec(text) ?? /^(tonight|overnight|tomorrow(?:\s+morning)?)\b[\s,]*/i.exec(text)
60 if (!until) return null
61 const isNamed = /^(tonight|overnight|tomorrow)/i.test(until[1] ?? '')
62 let hour = isNamed ? 9 : Number(until[1]) % 12 + (/pm/i.test(until[3] ?? '') ? 12 : 0)
63 if (!isNamed && !until[3] && Number(until[1]) >= 12) hour = Number(until[1])
64 const target = hour * 60 + (isNamed ? 0 : Number(until[2] ?? 0))
65 const ahead = (target - nowMinutes + 1440) % 1440 || 1440
66 return { hours: Math.min(24, ahead / 60), untilDone: false, goal: text.slice(until[0].length).trim() }
67}
68
69/** @param {Away} away @param {number} tz */
70export const windowEndText = (away, tz) => (away.untilDone ? 'until done (24 hours at most)' : `until ${clockText(away.wakeAt, tz)}`)
71
72/** @param {WindowChoice} choice @param {number} now @param {string} ledgerPath @param {{ person: string, root: string }} owner @returns {Away} */
73export const newWindow = (choice, now, ledgerPath, owner) => ({
74 phase: 'running',
75 untilDone: choice.untilDone,
76 goal: choice.goal,
77 held: choice.held ?? ['merge', 'push-main'],
78 startedAt: now,
79 wakeAt: now + (choice.untilDone ? 24 : Math.min(24, Math.max(0.25, choice.hours))) * 3600000,
80 ledgerPath,
81 parked: [],
82 person: owner.person,
83 root: owner.root,
84})
85
86/** @param {Away} away */
87const heldText = away => away.held.map(kind => HELD_LABELS[/** @type {keyof typeof HELD_LABELS} */ (kind)] ?? kind).join(', ') || 'nothing'
88
89// A ledger file with a new window appended (or started).
90/** @param {string} existing @param {Away} away @param {number} tz @param {Pack} [pack] */
91export const ledgerWithWindow = (existing, away, tz, pack = unreal) =>
92 [
93 existing === '' ? '# Autonomy Window Decisions\n' : existing.trimEnd(),
94 '',
95 `## Autonomy window from ${clockText(away.startedAt, tz)}, ${windowEndText(away, tz)}`,
96 '',
97 `- Goal: ${away.goal || '(see the session)'}`,
98 `- Allowed without asking: ${pack.mandate.allowed}`,
99 `- Held: ${heldText(away)}`,
100 '',
101 'Each decision taken for you while you were away. Entry format: Options, Choice, Why, Evidence, Revert, Status (provisional, kept, revert requested, reopened).',
102 '',
103 ].join('\n')
104
105/** @param {Away} away @param {number} tz @param {Pack} [pack] */
106export const mandateText = (away, tz, pack = unreal) =>
107 away.phase === 'review'
108 ? `AWAY WINDOW ENDED (Ather Automata): the user has not reviewed it yet. Until they do, do not retry held actions (${heldText(away)}) and record any decision that would be theirs in ${away.ledgerPath} instead of asking.`
109 : [
110 away.untilDone
111 ? `AUTONOMY WINDOW (Ather Automata): the user is away until the work is done (hard stop ${clockText(away.wakeAt, tz)} local time). When the goal is done, call the mcp__ather-automata__away tool with action "end" so the user gets the review.`
112 : `AUTONOMY WINDOW (Ather Automata): the user is away until ${clockText(away.wakeAt, tz)} local time.`,
113 `Goal: ${away.goal || 'continue the active work'}.`,
114 `Allowed without asking for this window: ${pack.mandate.allowed}. Use them on feature branches; ${pack.mandate.merge}.`,
115 `Do not stop to ask or wait for answers. On any decision that would be the user\'s, take the recommended option, prefer the reversible one, and ${pack.mandate.flags}.`,
116 `Record every such decision when you make it in ${away.ledgerPath} as "### D-<n> · <question>" with the lines Options, Choice, Why, Evidence, Revert, Status: provisional.`,
117 `Held until the user has reviewed the window (they will be refused and parked, do not retry them): ${heldText(away)}.`,
118 'The AGENTS.md safety contract still applies in full. When blocked on one item, move to another instead of waiting.',
119 ].join(' ')
120
121/** @param {string} id @param {string} question @param {readonly string[]} options */
122export const pendingEntry = (id, question, options) =>
123 [`### ${id} · ${question.replace(/\s+/g, ' ').trim()}`, `- Options: ${options.length > 0 ? options.join('; ') : '(none given)'}`, '- Choice: pending (take the recommended option)', '- Why: ', '- Evidence: ', '- Revert: ', '- Status: provisional', ''].join('\n')
124
125// Decision ids continue from the ledger file itself, so nothing else has to count them.
126/** @param {string} markdown */
127export const nextLedgerId = markdown => Math.max(1, ...[...markdown.matchAll(/^###\s+D-(\d+)/gm)].map(match => Number(match[1]) + 1))
128
129/** @param {readonly Parked[]} parked */
130export const nextParkId = parked => `P-${Math.max(0, ...parked.map(one => Number(one.id.slice(2)) || 0)) + 1}`
131
132// The decisions recorded in the latest window of a ledger.
133/** @param {string} markdown */
134export const windowDecisions = markdown => {
135 const parts = markdown.split(/^(?=## Autonomy window from )/m)
136 return [...(parts[parts.length - 1] ?? '').matchAll(/^###\s+(D-\d+)\s*[·:\-–]?\s*(.*)$/gm)].map(match => ({ id: match[1] ?? '', question: (match[2] ?? '').trim() }))
137}
138hooks/guards.mjs 113 lines1// @ts-check
2// Ather Automata: what tool calls and their output mean. Shell commands that
3// must wait while the director is away, builds and tests read from their own
4// output, MCP calls that count as evidence, thin worker briefs, known traps.
5// Pure: no `$`.
6
7import { unreal } from './packs/unreal.mjs'
8import { WEB_HELD, makeWebPack } from './packs/web.mjs'
9import { MAIN, pushTarget, withFolders } from './shell.mjs'
10
11/** @typedef {import('./packs/index.mjs').Pack} Pack */
12
13// Every pack's held actions, by kind: a parked action reads the same whichever pack parked it.
14const WEB = makeWebPack(null, null)
15/** @type {Record<string, string>} */
16export const HELD_LABELS = { merge: 'Merges', 'push-main': 'Pushes to main', ...unreal.held.labels, ...WEB.held.labels }
17// For one held action in a sentence: "held a merge until you are back".
18/** @type {Record<string, string>} */
19export const HELD_NOUNS = { merge: 'a merge', 'push-main': 'a push to main', ...unreal.held.nouns, ...WEB.held.nouns }
20/** @typedef {string} HeldKind merge, push-main, or one of a pack's held kinds */
21// The Unreal pack's kinds, as before packs.
22export const HELD_KINDS = /** @type {HeldKind[]} */ (['merge', 'push-main', 'editor-restart', 'asset-save'])
23/** @param {Pack} pack @returns {string[]} */
24export const heldKindsOf = pack => ['merge', 'push-main', ...pack.held.kinds]
25export { WEB_HELD }
26
27// The Unreal pack's readers, kept here for the modules and tests that read them from the guards.
28export { automationResult, buildResult, isAssetSave, isAutomationCommand, isBuildCommand, isEditorBuild, isLogRead, mcpKind } from './packs/unreal.mjs'
29export { gitFolders, isPiped, isSearchCommand } from './shell.mjs'
30
31const GIT_REWRITE = /\bgit\b(?:\s+-[cC]\s+\S+)*\s+(stash(?!\s+(list|show)\b)|clean\b|reset\s+--hard|sparse-checkout(?!\s+(list|disable)\b)|checkout\b|switch\b|restore\b|rebase\b)/i
32
33// Why a refused tree-rewriting git command was refused, and the safe way.
34/** @param {string} command */
35export const explainGuard = command => {
36 const kind = GIT_REWRITE.exec(command)?.[1]?.split(/\s+/)[0]
37 if (!kind) return null
38 const safe = /\bgit\s+-C\s+\S+/.test(command)
39 ? 'Run it from a separate worktree (git worktree add, then git -C <worktree path> ...), never in the shared checkout.'
40 : 'Pin it: git -C <absolute worktree path> ..., with the path read back from git worktree list. Never after a cd.'
41 return `git ${kind} rewrites the working tree that other sessions share. ${safe}`
42}
43
44/** @param {string} command */
45// A merge or a pull (which merges); never `merge-base` or `merge --abort`.
46export const isMergeCommand = command => /\bgit\b(?:\s+-C\s+\S+)?\s+(merge(?![-\w])|pull\b)(?!.*--abort)/i.test(command)
47
48// A shell command the window holds, if any. `branchOf` gives the branch checked out in a folder
49// (null: the session's folder), or '' when it cannot be told; then only an explicit main is held.
50// With the web pack's with-proof policy (D2), a merge passes once every gate the profile requires has passed:
51// `context.isProven` says so, read from this session's evidence by the caller.
52/**
53 * @param {string} command @param {readonly string[]} held @param {(folder: string | null) => string} branchOf
54 * @param {Pack} [pack] @param {import('./packs/index.mjs').HeldContext} [context] @returns {string | null}
55 */
56export const heldShell = (command, held, branchOf, pack = unreal, context = {}) => {
57 const merges = !(pack.mergePolicy === 'with-proof' && context.isProven === true)
58 for (const { segment, folder } of withFolders(command)) {
59 const branch = branchOf(folder)
60 const isPrMerge = /^gh\s+pr\s+merge\b/i.test(segment) || /^gh\s+api\b.*\bpulls\/\d+\/merge\b/i.test(segment)
61 // A local merge matters only into main; merging main into a feature branch is ordinary work.
62 const isMainMerge = /^git\b(?:\s+-C\s+\S+)?\s+merge\s+(?!--abort)/i.test(segment) && MAIN.test(branch)
63 if (held.includes('merge') && merges && (isPrMerge || isMainMerge)) return 'merge'
64 if (held.includes('push-main') && /^git\b(?:\s+-C\s+\S+)?\s+push\b/i.test(segment) && MAIN.test(pushTarget(segment, branch))) return 'push-main'
65 const kind = pack.heldSegment(segment, held, { ...context, scripts: context.scripts ?? pack.scripts })
66 if (kind && (kind !== 'merge' || merges)) return kind
67 }
68 return null
69}
70
71// "mcp__unreal-mcp__get_actor" → "unreal-mcp": a readback must read from the server that was written to.
72/** @param {string} tool */
73export const mcpServer = tool => tool.split('__')[1] ?? tool
74
75// Only briefs for workers that will change files or the Editor are checked.
76/** @param {string} prompt @param {string | undefined} type @param {Pack} [pack] */
77export const briefIssues = (prompt, type, pack = unreal) => {
78 if (type !== undefined && /^(Explore|Plan|statusline-setup|claude-code-guide)$/i.test(type)) return []
79 if (!/\b(edit|write|implement|fix|change|modify|refactor|add|create|delete|remove|rename|commit|save|build|compile|wire|author)\b/i.test(prompt) || /\bread-only\b|do not (edit|modify|change)|no edits/i.test(prompt)) return []
80 const issues = []
81 if (!pack.briefPaths.test(prompt)) issues.push('exact paths')
82 if (!/acceptance|accept when|done when|success criteria|verify|evidence|proof|report format|deliverable|final (message|report)|report back|return (a|the) (list|report|summary)/i.test(prompt)) issues.push('acceptance checks')
83 // A worker kept to its own folder, or told to leave git alone, already respects the shared tree.
84 if (!/shared (checkout|tree|worktree)|git -C|worktree|no commits|do not commit|don't commit|never (stash|switch|clean)|no git (changes|commands)|work only in|edit nothing (else|outside)/i.test(prompt)) issues.push('the shared-tree rule')
85 return issues
86}
87
88// ---------------------------------------------------------------- known traps
89
90/** @typedef {{ file: string, text: string }} WrittenRule */
91/** @typedef {{ id: string, title: string, fix: string, rule?: WrittenRule }} Trap */
92/** @typedef {Record<string, { title: string, fix: string, count: number }>} TrapHits */
93
94/** @param {string} text @param {Pack} [pack] */
95export const matchGotchas = (text, pack = unreal) => pack.traps.filter(rule => rule.pattern.test(text))
96
97// A trap hit in this many separate sessions becomes a "Needs you" item: make it a rule?
98const RULE_AFTER_SESSIONS = 3
99
100/** @param {TrapHits} hits @param {Trap} rule @returns {TrapHits} */
101export const countGotcha = (hits, rule) => ({ ...hits, [rule.id]: { title: rule.title, fix: rule.fix, count: (hits[rule.id]?.count ?? 0) + 1 } })
102
103// Only the pack's own traps: the counts are kept per machine, across every kind of repository.
104/** @param {TrapHits} hits @param {readonly string[]} ruled @param {Pack} [pack] */
105export const recurringGotchas = (hits, ruled, pack = unreal) =>
106 Object.entries(hits)
107 .filter(([id, hit]) => hit.count >= RULE_AFTER_SESSIONS && !ruled.includes(id) && pack.traps.some(trap => trap.id === id))
108 .map(([id, hit]) => ({ id, ...hit }))
109 .sort((a, b) => b.count - a.count)
110
111/** @param {string} id @param {Pack} [pack] @returns {WrittenRule | undefined} */
112export const writtenRuleOf = (id, pack = unreal) => pack.traps.find(one => one.id === id)?.rule
113hooks/model.mjs 578 lines1// @ts-check
2// Ather Automata: what the S2 workflow's files say. Intents, people, time, the
3// Editor lock, the four stages and the one next step. Pure: no `$`.
4
5import { unreal } from './packs/unreal.mjs'
6
7// The Unreal pack's names, kept here for the modules and tests that read them from the model.
8export { ROLES, ROLE_LABELS, OWNERS, AREAS, parseRole, parseEditorLock } from './packs/unreal.mjs'
9
10/** @typedef {import('./packs/index.mjs').Pack} Pack */
11
12export const STAGE_LABELS = { plan: 'Plan', build: 'Build', prove: 'Prove', ship: 'Ship', close: 'Ready to close', shipped: 'Shipped' }
13
14// The issue an intent names: "#28887", "sipherxyz/S2#28887", a link ending "issues/28887", or "28887".
15/** @param {string} text */
16const issueNumber = text => {
17 const match = /#(\d+)|issues\/(\d+)/.exec(text) ?? /^\s*(\d+)\s*$/.exec(text)
18 return match ? Number(match[1] ?? match[2]) : null
19}
20
21/** @param {string} text @param {Pack} [pack] */
22export const normalizeArea = (text, pack = unreal) => pack.normalizeArea(text)
23
24/** @param {string} name */
25const personKey = name => name.toLowerCase().replace(/[^a-z0-9]/g, '')
26
27// "Tin Nguyen" matches "TinNguyen"; "TienPham" matches "TienPhamProducerAther".
28/** @param {string} a @param {string} b */
29export const isSamePerson = (a, b) => {
30 const x = personKey(a)
31 const y = personKey(b)
32 return x !== '' && y !== '' && (x.startsWith(y) || y.startsWith(x))
33}
34
35// "a", "a and b", "a, b and c".
36/** @param {readonly string[]} items */
37export const andList = items => (items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`)
38
39/** @param {number} count @param {string} one @param {string} [many] */
40export const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`
41
42// The closest of a few command words to a typo ("tuor" → "tour"), or null.
43/** @param {string} word @param {readonly string[]} words */
44export const closestWord = (word, words) => {
45 // Edits between two words, a swap of neighbours counting as one ("tuor" is one edit from "tour").
46 const distance = (/** @type {string} */ a, /** @type {string} */ b) => {
47 const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)))
48 const at = (/** @type {number} */ i, /** @type {number} */ j) => d[i]?.[j] ?? 0
49 for (let i = 1; i <= a.length; i += 1) {
50 for (let j = 1; j <= b.length; j += 1) {
51 const cost = a[i - 1] === b[j - 1] ? 0 : 1
52 let best = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + cost)
53 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) best = Math.min(best, at(i - 2, j - 2) + 1)
54 const row = d[i]
55 if (row) row[j] = best
56 }
57 }
58 return at(a.length, b.length)
59 }
60 const ranked = words.map(candidate => ({ candidate, cost: distance(word.toLowerCase(), candidate) })).sort((x, y) => x.cost - y.cost)
61 return ranked[0] && ranked[0].cost <= 1 && word.length >= 3 ? ranked[0].candidate : null
62}
63
64/** @param {string} me */
65export const personId = me => personKey(me) || 'anyone'
66
67// ---------------------------------------------------------------- text
68
69/** @param {string} markdown */
70const plainText = markdown =>
71 markdown
72 .replace(/\*\*(.+?)\*\*/g, '$1')
73 .replace(/`([^`]+)`/g, '$1')
74 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
75 .replace(/\s+/g, ' ')
76 .trim()
77
78// A row title: plain text, no "Found:" label, the first sentence, cut at a word.
79/** @param {string} text @param {number} [max] */
80export const shortTitle = (text, max = 80) => {
81 const plain = plainText(text).replace(/^(found|finding|problem|issue|question|context|summary|note|observed)\s*:\s*/i, '')
82 if (plain === '') return ''
83 const capital = plain.charAt(0).toUpperCase() + plain.slice(1)
84 const sentence = (/^(.+?[.?!])(\s|$)/.exec(capital)?.[1] ?? capital).replace(/\.$/, '')
85 if (sentence.length <= max) return sentence
86 const cut = sentence.slice(0, max)
87 const space = cut.lastIndexOf(' ')
88 return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,;:\s]+$/, '')}…`
89}
90
91// A label cut at a word past `max`, ended with "…" when cut.
92/** @param {string} text @param {number} max */
93export const cutWords = (text, max) => (text.length <= max ? text : `${text.slice(0, max - 1).replace(/\s+\S*$/, '')}…`)
94
95// The same text whole, for a surface that wraps it: no label, every sentence, cut only past `max`.
96/** @param {string} text @param {number} [max] */
97export const fullTitle = (text, max = 400) => {
98 const plain = plainText(text).replace(/^(found|finding|problem|issue|question|context|summary|note|observed)\s*:\s*/i, '').trim()
99 const capital = plain.charAt(0).toUpperCase() + plain.slice(1)
100 return capital.length <= max ? capital : `${capital.slice(0, max - 1)}…`
101}
102
103// ---------------------------------------------------------------- intents
104
105/** @param {string} text @param {string} name */
106const field = (text, name) => new RegExp(`^\\s*-\\s*${name}:\\s*(.+)$`, 'mi').exec(text)?.[1]?.trim() ?? ''
107
108/** @param {string} text @param {string} heading */
109export const section = (text, heading) => {
110 const start = new RegExp(`^##\\s+${heading}\\b.*$`, 'mi').exec(text)
111 if (!start) return ''
112 const rest = text.slice(start.index + start[0].length)
113 const end = /^##\s+/m.exec(rest)
114 return end ? rest.slice(0, end.index) : rest
115}
116
117// Every section headed so, in order: "## Acceptance" and a later "## Acceptance, rev 2" both count.
118/** @param {string} text @param {string} heading */
119const sections = (text, heading) =>
120 text
121 .split(/^(?=##\s)/m)
122 .filter(part => new RegExp(`^##\\s+${heading}\\b`, 'i').test(part))
123 .map(part => part.replace(/^.*$/m, ''))
124 .join('\n')
125
126const CLOSED = /\b(accepted|rejected|resolved|closed|superseded|withdrawn|answered)\b/i
127
128// ---------------------------------------------------------------- a finding's options and resolution
129
130/** @typedef {{ letter: string, label: string, text: string, isRecommended: boolean }} FindingOption `label`: its first clause, cut to fit a button; `text`: the whole option, plain */
131
132// A clause that only sets the scene ("In the next C++ build", "After the import"): no action of its own.
133const LEADING = /^(in|on|at|after|before|when|whenever|if|once|until|unless|during|while|by|from|then|first)\b/i
134
135// An option's first clause ("keep it as is, with the confirmation (…)" → "Keep it as is"), at least
136// a few words long ("no, drops only" stays whole), cut at a word past `max`. A clause of fewer than
137// three words, or one that only sets the scene, says too little alone: the whole text is cut instead.
138/** @param {string} text @param {number} [max] */
139export const optionLabel = (text, max = 60) => {
140 const plain = plainText(text)
141 const end = [...plain.matchAll(/[,;:(]|\s[—–-]\s|\.(?=\s|$)/g)].map(match => match.index ?? 0).find(at => at >= 12) ?? plain.length
142 const first = plain.slice(0, end).trim()
143 const clause = LEADING.test(first) || first.split(/\s+/).length < 3 ? plain : first
144 const capital = clause.charAt(0).toUpperCase() + clause.slice(1)
145 if (capital.length <= max) return capital
146 const cut = capital.slice(0, max - 1)
147 const space = cut.lastIndexOf(' ')
148 return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,;:\s]+$/, '')}…`
149}
150
151// "Recommendation: (a)", "Recommendation: B": the letter, upper case, or ''.
152/** @param {string} body */
153const recommendationOf = body => (/\brecommendation\**[ \t]*:\**[ \t]*\(?([a-z])\)?(?![\w])/i.exec(body)?.[1] ?? '').toUpperCase()
154
155// The options a finding writes, in either of the two formats the findings use:
156// **Options:** or inline, in one paragraph or list item that
157// - A (recommended): prove both on a map … says Options or Recommendation:
158// - B: add a lab rig … Options: (a) keep it as is; (b) add a hook …
159// Recommendation: (a).
160// A finding updated later ("**Options now:**") is read from its last options heading. Lettered
161// evidence ("- (a) Before Kawaii: …") with neither word nearby is not options. The recommended one is
162// marked "(recommended)", named by a "Recommendation:" line, or (only when neither says) the one
163// option whose text says "recommended". Fewer than two found: [].
164/** @param {string} body @returns {FindingOption[]} */
165export const parseOptions = body => {
166 const lines = body.split(/\r?\n/)
167 /** @type {{ letter: string, text: string, isMarked: boolean }[]} */
168 let found = []
169 // The list format: an "Options:" line on its own, then "- <Letter>[ (recommended)]: <text>" items and their indented continuations.
170 const head = lines.findLastIndex(line => /^\s*[-*]?\s*\**\s*options(?:\s+\w+)?\s*\**\s*:\s*\**\s*$/i.test(line))
171 if (head >= 0) {
172 for (const line of lines.slice(head + 1)) {
173 const item = /^\s*[-*]\s+\**\s*([A-Za-z])\b\s*(?:\(([^)]*)\))?\s*\**\s*[:.)]\s*\**\s*(.*)$/.exec(line)
174 const last = found[found.length - 1]
175 if (item) found.push({ letter: (item[1] ?? '').toUpperCase(), text: item[3] ?? '', isMarked: /recommended/i.test(item[2] ?? '') })
176 else if (last && /^\s{2,}\S/.test(line) && !/^\s*[-*]\s/.test(line)) last.text += ` ${line.trim()}`
177 else if (found.length > 0 || line.trim() !== '') break
178 }
179 }
180 // The inline format: "(a) … (b) …" in one paragraph or list item, its continuation lines joined.
181 if (found.length === 0) {
182 // Each "(a)" line's paragraph or list item, whole: back to where it starts, on to where the next begins.
183 const starts = (/** @type {string} */ line) => line.trim() === '' || /^\s?[-*]\s|^\s*#|^\s*\*\*\w/.test(line)
184 const paragraphs = lines.flatMap((line, at) => {
185 if (!/\(a\)\s/.test(line)) return []
186 let from = at
187 while (from > 0 && !starts(lines[from] ?? '') && (lines[from - 1] ?? '').trim() !== '') from -= 1
188 let to = at + 1
189 while (to < lines.length && !starts(lines[to] ?? '')) to += 1
190 return [lines.slice(from, to).join(' ').replace(/\s+/g, ' ')]
191 })
192 const paragraph = paragraphs.find(text => /\b(options?|recommendation)\b/i.test(text))
193 if (paragraph) {
194 const from = paragraph.slice(paragraph.indexOf('(a)')).replace(/\s*\brecommendation\**\s*:.*$/i, '')
195 const parts = from.split(/\(([a-z])\)\s+/).slice(1)
196 for (let index = 0; index + 1 < parts.length; index += 2) {
197 const letter = (parts[index] ?? '').toUpperCase()
198 const last = found[found.length - 1]
199 // Letters in order only: a later "(x)" inside an option's text stays in it.
200 if (letter.charCodeAt(0) !== 65 + found.length) {
201 if (last) last.text += ` (${parts[index]}) ${parts[index + 1] ?? ''}`
202 continue
203 }
204 found.push({ letter, text: parts[index + 1] ?? '', isMarked: false })
205 }
206 found = found.map(one => ({ ...one, text: one.text.replace(/[\s;,.]+(or|and)?\s*$/i, '').trim(), isMarked: /\(recommended\)/i.test(one.text) }))
207 }
208 }
209 if (found.length < 2) return []
210 const named = recommendationOf(body)
211 const marked = found.filter(one => one.isMarked)
212 const saying = found.filter(one => /\brecommended\b/i.test(one.text))
213 const pick = named || (marked.length === 1 ? (marked[0]?.letter ?? '') : saying.length === 1 ? (saying[0]?.letter ?? '') : '')
214 return found.map(one => {
215 const text = plainText(one.text.replace(/\s*\(recommended\)/i, ''))
216 return { letter: one.letter, label: optionLabel(text), text: text.charAt(0).toUpperCase() + text.slice(1), isRecommended: one.letter === pick }
217 })
218}
219
220// A finding's lines about itself: "Status: …", "**Resolution:** …", "- Resolution (orchestrator, 2026-10-05): …".
221const SELF_LINE = /^[ \t]*[-*]?[ \t]*\**[ \t]*(status|resolution)[ \t]*(?:\([^)\n]*\))?[ \t]*\**[ \t]*:[ \t]*\**[ \t]*(.*)$/gim
222
223// What a filled Resolution says when it settles nothing: "open; reported for …", "open (Q3).",
224// "partly addressed …", "not yet", "pending", "tbd", a template's "<pending owner>", "-".
225const UNSETTLED = /^(open|pending|partly|not yet|tbd)\b|^[<\-—–]/i
226
227// Open findings. Template heading: "## F-<n> (date, rev r) | blocking: yes|no | status: open (owner)".
228/** @param {string} findings @param {string} prompt */
229export const parseFindings = (findings, prompt) => {
230 const decisions = section(prompt, 'Decisions')
231 return findings
232 .split(/^(?=##\s+F)/m)
233 .filter(part => /^##\s+F[\w-]*\d/.test(part))
234 .map(part => {
235 const heading = /^##\s+(F[\w-]*)\s*(.*)$/m.exec(part)
236 const id = heading?.[1] ?? 'F?'
237 const rest = heading?.[2] ?? ''
238 const body = part.slice((heading?.[0] ?? '').length)
239 const headStatus = /status:\s*(\w+)(?:\s*\(([^)]*)\))?/i.exec(rest)
240 const headBlocking = /blocking:\s*(yes|no)\b/i.exec(rest)
241 const owner = headStatus?.[2]?.trim() ?? ''
242 const said = [...body.matchAll(SELF_LINE)].map(match => ({ kind: (match[1] ?? '').toLowerCase(), text: (match[2] ?? '').replace(/[*_`]/g, '').trim() })).filter(line => line.text !== '')
243 // A settled Resolution closes it, whatever the heading says; an unsettled one ("pending Director",
244 // often left behind) never reopens a heading that says it is closed ("status: resolved (…)").
245 // Without one, the heading's status, else a closing word in the heading or a Status line, else the Decisions.
246 const resolution = said.filter(line => line.kind === 'resolution').at(-1)
247 const statusLine = said.find(line => line.kind === 'status')
248 const isHeadClosed = headStatus !== null && (headStatus[1] ?? '').toLowerCase() !== 'open'
249 const isClosed = resolution
250 ? !UNSETTLED.test(resolution.text) || isHeadClosed
251 : headStatus
252 ? isHeadClosed
253 : CLOSED.test(rest) || (statusLine !== undefined && CLOSED.test(statusLine.text)) || new RegExp(`\\b${id.replace(/-/g, '\\-')}\\b`).test(decisions)
254 // "(open, not blocking)" and "non-blocking for this change" say it does not block.
255 const isBlocking = headBlocking ? headBlocking[1]?.toLowerCase() === 'yes' : /blocking:\s*yes/i.test(part) || (/\bblocking\b/i.test(rest) && !/\b(non|not)[\s-]+blocking\b/i.test(rest))
256 const isDirectorCall = owner ? /director|producer|design|production|user/i.test(owner) : /\(a\)\s/.test(body) || /director|design lead|production call/i.test(body)
257 const headTitle = shortTitle(rest.replace(/\|\s*(blocking|status):.*$/i, '').replace(/^\([^)]*\)\s*:?\s*/, '').replace(/^:\s*/, ''))
258 const bodyTitle = shortTitle(body.trim().split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('#')) ?? '')
259 const headFull = fullTitle(rest.replace(/\|\s*(blocking|status):.*$/i, '').replace(/^\([^)]*\)\s*:?\s*/, '').replace(/^:\s*/, ''))
260 const bodyFull = fullTitle(body.trim().split('\n').find(line => line.trim() !== '' && !line.trim().startsWith('#')) ?? '')
261 // `source`: the finding as written (capped), for the pane's finding view.
262 return { id, title: headTitle || bodyTitle || id, full: headFull || bodyFull || id, isBlocking, isDirectorCall, options: parseOptions(body), source: part.trim().slice(0, 6000), isOpen: !isClosed }
263 })
264 .filter(one => one.isOpen)
265 .map(({ isOpen: _open, ...one }) => one)
266}
267
268// ---------------------------------------------------------------- acceptance and PRs
269//
270// One writer per fact (.agents/skills/intent/SKILL.md): prompt.md's Acceptance lists the items
271// ("- A1: ..."), progress.md's Acceptance table says which are met, progress.md's "- PR:" line
272// names the PRs. Intents from before that rule tick "- [x]" boxes in prompt.md instead.
273
274// "A1", "B3", "SL15", "A12a": an acceptance id at the start of an item or a table cell.
275const ITEM_ID = /^\**([A-Z]{1,3}[0-9]+[a-z]?)\**(?=[\s:.(]|$)/
276// A verdict that counts as met: its leading word ("met on main", "Pass", "passed", "done", "✓", "✅").
277const MET = /^(met|pass|passed|done|✓|✔|✅)(?![\p{L}\p{N}])/iu
278
279// progress.md's Acceptance table as id → verdict, or null when it has none (or no rows yet).
280// The verdict column is the one headed Verdict, Status or Result, else the second.
281/** @param {string} progress */
282const acceptanceVerdicts = progress => {
283 const rows = section(progress, 'Acceptance')
284 .split(/\r?\n/)
285 .filter(line => /^\s*\|/.test(line))
286 .map(line => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => plainText(cell)))
287 const header = rows[0]
288 if (!header) return null
289 const named = header.findIndex(cell => /^(verdict|status|result)$/i.test(cell))
290 const column = named < 0 ? 1 : named
291 /** @type {Map<string, string>} */
292 const verdicts = new Map()
293 for (const row of rows.slice(1)) {
294 const id = ITEM_ID.exec(row[0] ?? '')?.[1]
295 if (id) verdicts.set(id, row[column] ?? '')
296 }
297 return verdicts.size > 0 ? verdicts : null
298}
299
300/** @typedef {{ id: string, text: string, isDone: boolean }} AcceptanceItem `text` is what follows the id ('' id: a legacy box without one) */
301
302// "A2 (owed): Sand look." → { id: 'A2', text: '(owed): Sand look.' }
303/** @param {string} line */
304const splitItem = line => {
305 const match = ITEM_ID.exec(line)
306 return { id: match?.[1] ?? '', text: match ? line.slice(match[0].length).trim() : line }
307}
308
309// The acceptance items and which are done. Ids come from prompt.md's top-level items; met-ness
310// from progress.md's table, whose rows for ids prompt.md does not list are ignored. Without a
311// table, legacy "- [x]" boxes count as before; without either, every listed item is open.
312/** @param {string} prompt @param {string} progress @returns {AcceptanceItem[]} */
313export const acceptanceItems = (prompt, progress) => {
314 const lines = sections(prompt, 'Acceptance').split(/\r?\n/)
315 /** @type {Map<string, string>} */
316 const listed = new Map()
317 for (const line of lines) {
318 const item = splitItem(/^-\s+(?:\[[ xX]\]\s*)?(.+)$/.exec(line)?.[1] ?? '')
319 if (item.id !== '' && !listed.has(item.id)) listed.set(item.id, item.text)
320 }
321 const verdicts = acceptanceVerdicts(progress)
322 if (verdicts && listed.size > 0) return [...listed].map(([id, text]) => ({ id, text, isDone: MET.test(verdicts.get(id) ?? '') }))
323 const boxes = lines.flatMap(line => {
324 const box = /^\s*-\s*\[( |x|X)\]\s*(.*)$/.exec(line)
325 return box ? [{ ...splitItem(box[2] ?? ''), isDone: box[1] !== ' ' }] : []
326 })
327 if (boxes.length > 0) return boxes
328 return [...listed].map(([id, text]) => ({ id, text, isDone: false }))
329}
330
331// The text before a file's first "## " heading: where its "- Field:" lines live.
332/** @param {string} text */
333const headerOf = text => text.split(/^##\s/m)[0] ?? ''
334
335// The PR numbers on progress.md's "- PR:" line, then any on a legacy prompt.md one.
336// "- PR: #32372, #32398", "- PRs: sipherxyz/s2#1", "- PR: none yet".
337/** @param {string} progress @param {string} prompt @returns {number[]} */
338export const intentPrs = (progress, prompt) => {
339 const numbers = [progress, prompt].flatMap(text =>
340 [...headerOf(text).matchAll(/^\s*-\s*PRs?\s*:\s*(.+)$/gim)].flatMap(line => [...(line[1] ?? '').matchAll(/#(\d+)|pull\/(\d+)/g)].map(match => Number(match[1] ?? match[2]))),
341 )
342 return [...new Set(numbers)]
343}
344
345/**
346 * @typedef {'MERGED' | 'OPEN' | 'CLOSED' | 'UNREAD'} PrState what gh last said about a PR; UNREAD when it could not say
347 * @typedef {Readonly<Record<string, PrState>>} PrStates PR number → its last read state
348 */
349
350// Open, with a checklist whose every item is met: the only intents whose PRs decide anything.
351/** @param {Intent} intent */
352export const isAllMet = intent => intent.status !== 'completed' && intent.acceptanceTotal > 0 && intent.acceptanceDone === intent.acceptanceTotal
353
354// Every item met and every named PR merged, yet not closed: the orchestrator's Close step is owed.
355// An intent with no PR named is not ready: nothing says the work has landed.
356/** @param {Intent} intent @param {PrStates} prs */
357export const isReadyToClose = (intent, prs) => isAllMet(intent) && intent.prs.length > 0 && intent.prs.every(number => prs[number] === 'MERGED')
358
359// "#32372 MERGED", "#32398 not read yet": each PR the intent names, with what gh last said.
360/** @param {Intent} intent @param {PrStates} prs */
361export const prStatusList = (intent, prs) => intent.prs.map(number => `#${number} ${prs[number] === 'UNREAD' ? 'could not be read' : (prs[number] ?? 'not read yet')}`)
362
363/**
364 * @typedef {{ slug: string, prompt: string, findings: string, progress: string, files: readonly string[], hasDebrief: boolean, updatedAt: number, source: 'main' | 'local', firstAuthor: string }} IntentFiles
365 * `updatedAt`: when it last changed (its last commit on main, or its files'); `source`: where it was read; `firstAuthor`: who first committed its folder
366 * @typedef {ReturnType<typeof parseIntent>} Intent
367 */
368
369/** @param {IntentFiles} input @param {Pack} [pack] */
370export const parseIntent = (input, pack = unreal) => {
371 const { prompt, progress } = input
372 // "parked: weather presets merged…" is a status too: take the leading word.
373 const status = /^[a-z]+/.exec(field(prompt, 'Status').toLowerCase())?.[0] ?? 'unknown'
374 const items = acceptanceItems(prompt, progress)
375 return {
376 slug: input.slug,
377 title: /^#\s+(.+)$/m.exec(prompt)?.[1]?.trim() ?? input.slug,
378 goal: shortTitle(/^(.+?[.!?])(\s|$)/.exec(section(prompt, 'Goal').replace(/\s+/g, ' ').trim())?.[1] ?? section(prompt, 'Goal').replace(/\s+/g, ' ').trim(), 110),
379 area: normalizeArea(field(prompt, 'Area'), pack),
380 owner: field(prompt, 'Owner'),
381 issue: issueNumber(field(prompt, 'Issue')),
382 status,
383 // Why it is parked (or blocked): what follows the status word.
384 statusNote: field(prompt, 'Status').replace(/^[a-z]+\s*[:\-–—]?\s*/i, '').trim(),
385 acceptanceDone: items.filter(item => item.isDone).length,
386 acceptanceTotal: items.length,
387 prs: intentPrs(progress, prompt),
388 findings: parseFindings(input.findings, prompt),
389 hasReview: input.files.some(name => /review/i.test(name)) || /\b(plan|opus|design)[- ]review\b|reviewed by|after (an? )?(opus )?review/i.test(prompt + progress.slice(0, 20000)),
390 hasWorker: /^\s*[-*]?\s*\**worker\**\s*[:=-]\s*\S/im.test(progress) || /^(###\s+S\d+|-\s+S\d+\b)/m.test(progress),
391 hasDebrief: input.hasDebrief,
392 updatedAt: input.updatedAt,
393 source: input.source,
394 firstAuthor: input.firstAuthor,
395 }
396}
397
398/** @param {Intent | undefined} intent */
399export const directorCalls = intent => (intent && intent.status !== 'completed' ? intent.findings.filter(one => one.isDirectorCall || one.isBlocking) : [])
400
401/** @param {{ owner: string }} intent @param {string} me */
402export const isMine = (intent, me) => me !== '' && isSamePerson(intent.owner, me)
403
404// The open intents that are yours, the tracked one first.
405/** @param {readonly Intent[]} intents @param {string} me @param {string | null} pinned */
406export const ownedIntents = (intents, me, pinned) => {
407 const mine = intents.filter(one => one.status !== 'completed' && isMine(one, me))
408 return [...mine.filter(one => one.slug === pinned), ...mine.filter(one => one.slug !== pinned)]
409}
410
411// The Owner line of an intent's prompt.md.
412/** @param {string} prompt */
413export const intentOwner = prompt => field(prompt, 'Owner')
414
415// Who to offer first when picking: yours, then your area, then open decisions, then the most recent.
416/** @param {readonly Intent[]} intents @param {string} me @param {string} area */
417export const pickCandidates = (intents, me, area) => {
418 const rank = (/** @type {Intent} */ one) => (me !== '' && isSamePerson(one.owner, me) ? 0 : 4) + (area !== '' && one.area !== area ? 2 : 0) + (directorCalls(one).length > 0 ? 0 : 1)
419 return intents.filter(one => one.status === 'active' || one.status === 'parked').sort((a, b) => rank(a) - rank(b) || b.updatedAt - a.updatedAt)
420}
421
422/** @param {readonly Intent[]} intents @param {string} text */
423export const searchIntents = (intents, text) => {
424 const words = text.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length >= 3)
425 return intents.filter(one => {
426 const hay = `${one.slug} ${one.title} ${one.area} ${one.owner}`.toLowerCase().split(/[^a-z0-9]+/)
427 return one.status !== 'completed' && words.length > 0 && words.every(word => hay.some(part => part.startsWith(word)))
428 })
429}
430
431/** @param {Intent} one @param {string} me @param {PrStates} [prs] */
432export const intentLabel = (one, me, prs = {}) => {
433 const mine = isMine(one, me)
434 const calls = mine ? directorCalls(one).length : 0
435 const progress = one.acceptanceTotal > 0 ? `${one.acceptanceDone}/${one.acceptanceTotal}${isReadyToClose(one, prs) ? ' · ready to close' : ''}` : 'no checklist'
436 return `${one.slug} · ${one.area} · ${progress}${calls > 0 ? ` · ${calls} need${calls === 1 ? 's' : ''} you` : ''}${!mine && one.owner ? ` · ${one.owner}` : ''}${one.status === 'parked' ? ` · parked${one.statusNote ? `: ${shortTitle(one.statusNote, 90)}` : ''}` : ''}`
437}
438
439// ---------------------------------------------------------------- time and the Editor lock
440
441/** @param {number} ms @param {number} tz */
442export const localMinutes = (ms, tz) => {
443 const local = new Date(ms + tz * 60000)
444 return local.getUTCHours() * 60 + local.getUTCMinutes()
445}
446
447/** @param {number} ms @param {number} tz */
448export const clockText = (ms, tz) => {
449 const minutes = localMinutes(ms, tz)
450 return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
451}
452
453/** @param {number} ms */
454export const durationText = ms => {
455 const total = Math.max(0, Math.round(ms / 60000))
456 return total >= 60 ? `${Math.floor(total / 60)}h${String(total % 60).padStart(2, '0')}m` : `${total}m`
457}
458
459/** @param {string} text */
460export const parseTzOffset = text => {
461 const match = /([+-])(\d{2}):?(\d{2})/.exec(text.trim())
462 return match ? (match[1] === '-' ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3])) : null
463}
464
465// Evening by the person's clock: when "Heading off?" is offered unasked.
466/** @param {number} ms @param {number} tz */
467export const isEvening = (ms, tz) => {
468 const hour = Math.floor(localMinutes(ms, tz) / 60)
469 return hour >= 20 || hour < 5
470}
471
472/** @typedef {import('./packs/unreal.mjs').EditorLock} EditorLock */
473
474// A session's name from Claude Code's record of it (the lines a grep for its titles found):
475// the last title the person or the session set, else the last one Claude Code generated.
476/** @param {string} lines @returns {string} */
477export const sessionTitle = lines => {
478 const rows = lines.split(/\r?\n/)
479 /** @param {string} kind */
480 const last = kind => {
481 for (let index = rows.length - 1; index >= 0; index -= 1) {
482 const found = new RegExp(`"${kind}":"([^"]*)"`).exec(rows[index] ?? '')
483 if (found) return found[1] ?? ''
484 }
485 return ''
486 }
487 const raw = last('customTitle') || last('aiTitle')
488 let title = raw
489 try {
490 title = JSON.parse(`"${raw}"`)
491 } catch {
492 // an escape grep cut in half: the raw text is close enough
493 }
494 return title.replace(/[\u0000-\u001f]+/g, ' ').trim().slice(0, 60)
495}
496
497// ---------------------------------------------------------------- stages and the next step
498
499/** @typedef {import('./packs/index.mjs').Rung} Rung */
500/** @typedef {Record<string, Rung>} Evidence the pack's rungs; the Unreal pack's are build, automation, readback, pie and editor */
501
502/** @param {Pack} [pack] @returns {Evidence} */
503export const emptyEvidence = (pack = unreal) => pack.emptyEvidence()
504
505// A Status of completed wins; then every item met with every PR merged is ready to close, whatever proof this session saw.
506/** @param {Intent | undefined} intent @param {Evidence} evidence @param {string} role @param {PrStates} [prs] @param {Pack} [pack] @returns {keyof typeof STAGE_LABELS} */
507export const currentStage = (intent, evidence, role, prs = {}, pack = unreal) => {
508 if (intent?.status === 'completed') return intent.hasDebrief ? 'shipped' : 'ship'
509 if (!intent || intent.acceptanceTotal === 0) return 'plan'
510 if (isReadyToClose(intent, prs)) return 'close'
511 if (intent.acceptanceDone < intent.acceptanceTotal) return 'build'
512 return pack.isProven(evidence, role) ? 'ship' : 'prove'
513}
514
515// Asking the session what an intent is and where it stands, changing nothing. One that this checkout
516// does not have (or has as it is on main) is read from origin/main itself, read-only.
517/** @param {string} slug @param {boolean} fromMain */
518export const aboutIntentPrompt = (slug, fromMain) => {
519 const files = ['prompt.md', 'findings.md', 'progress.md', 'log.md']
520 const read = fromMain
521 ? `Read it from GitHub main, since this checkout may not have it or may be behind: use \`git show origin/main:docs/intent/${slug}/<file>\` for ${files.join(', ')} (those that exist) and \`git log -5 --format="%cs %an %s" origin/main -- docs/intent/${slug}\` for its recent history, with GIT_OPTIONAL_LOCKS=0. Do not fetch, pull, check out, track it or write anything.`
522 : `Read docs/intent/${slug}/ only; change nothing.`
523 return `Tell me about intent ${slug} in under ten lines: what it is for, who owns it, its status and stage (Plan, Build, Prove, Ship) and why, its checklist progress, which decisions are open and whose they are, and what the next step would be. ${read}`
524}
525
526/**
527 * The one next step for the tracked intent and the person's role.
528 * @param {string} role @param {Intent | undefined} intent @param {Evidence} evidence @param {number} workers @param {string} me @param {PrStates} [prs] @param {Pack} [pack]
529 * @returns {{ key: string, label: string, prompt: string, hint: string, isDraft?: boolean } | undefined}
530 */
531export const nextStep = (role, intent, evidence, workers, me, prs = {}, pack = unreal) => {
532 if (!intent) return { key: 'start', label: 'Start an intent', prompt: '/intent ', hint: 'Type what you want after /intent; the intent skill takes it from there.', isDraft: true }
533 const slug = intent.slug
534 if (!isMine(intent, me)) {
535 return { key: 'follow', label: 'See where it stands', hint: `${intent.owner || 'Its owner'}'s intent: a short summary, nothing is changed.`, prompt: aboutIntentPrompt(slug, intent.source === 'main') }
536 }
537 const stage = currentStage(intent, evidence, role, prs, pack)
538 if (stage === 'close') {
539 const named = andList(intent.prs.map(number => `#${number}`))
540 return {
541 key: 'close',
542 label: 'Close the intent',
543 hint: `Every item is met and ${intent.prs.length === 1 ? `PR ${named} is` : `PRs ${named} are`} merged; the session closes it the intent skill's way.`,
544 prompt: `Close intent ${slug} as step 5 (Close) of .agents/skills/intent/SKILL.md says. Ather reads every acceptance row in docs/intent/${slug}/progress.md as met and ${intent.prs.length === 1 ? `PR ${named} as` : `PRs ${named} as`} merged: confirm both from the files and gh first, and stop and tell me if either is not so. Then set Status: completed in prompt.md, add the changelog line, and commit as the skill says.`,
545 }
546 }
547 if (stage === 'plan') {
548 return { key: 'checklist', label: 'Write the "done" checklist', hint: 'The session drafts it and shows you before any work starts.', prompt: `Draft the acceptance checklist for intent ${slug} in docs/intent/${slug}/prompt.md: items with ids (A1, A2, ...), each with the proof that will show it is done, and no checkboxes (whether an item is met lives in progress.md). Show it to me before the worker starts.` }
549 }
550 if (stage === 'build') {
551 if (!intent.hasReview && !intent.hasWorker && intent.acceptanceDone === 0 && workers === 0) {
552 return { key: 'review', label: 'Get the plan checked', hint: 'A second agent looks for gaps and wrong assumptions before anyone builds.', prompt: `Have an Opus agent review the plan for intent ${slug} (docs/intent/${slug}/prompt.md) against the repository before any worker starts: gaps, risks, wrong assumptions. Fold the accepted findings into the intent and show me what changed.` }
553 }
554 if (intent.hasWorker || workers > 0) {
555 return { key: 'progress', label: 'See how the work is going', hint: 'A five-line status against the checklist.', prompt: `Summarise intent ${slug} against its checklist: what is done with evidence, what is next, what is blocked. Five lines.` }
556 }
557 return { key: 'brief', label: 'Start the work', hint: pack.prompts.briefHint, prompt: pack.prompts.brief(role, slug) }
558 }
559 if (stage === 'prove') {
560 const missing = role === '' ? [pack.anyProofText] : pack.requiredRungs(role).filter(rung => evidence[rung]?.state !== 'pass').map(rung => pack.rungLabels[rung] ?? rung)
561 const prompt = pack.prompts.prove(role, slug)
562 const own = pack.ownCheck && role === pack.ownCheck.role ? pack.ownCheck.proveHint : ''
563 return { key: 'prove', label: 'Prove it works', hint: `Still needed: ${andList(missing)}. Ather reads this from tool output, not from what the session says.${own}`, prompt }
564 }
565 if (stage === 'ship' && intent.status === 'completed') {
566 return {
567 key: 'debrief',
568 label: 'Write up what was learned',
569 hint: 'What was proven, lost and decided, and rules worth keeping.',
570 prompt: `Debrief intent ${slug}. List what was proven and with what evidence, what was lost or overwritten (lost optimisation vs broken feature), every decision taken on my behalf, and the gotchas we hit. Write it to ${pack.debriefPath(slug)}, and propose which recurring gotchas should become a skill or AGENTS.md rule for the owners (${pack.owners}).`,
571 }
572 }
573 if (stage === 'ship') {
574 return { key: 'land', label: 'Ship it', hint: pack.prompts.shipHint(role), prompt: pack.prompts.ship(role, slug) }
575 }
576 return undefined
577}
578hooks/state.mjs 607 lines1// @ts-check
2// Ather Automata: the one owner of what the two halves share. The store keys,
3// how stored values are read back, and every change. Changes run one at a time
4// so no read-modify-write can interleave with another; reads never wait. Both
5// halves import this module, so its change queue and version are shared.
6//
7// It takes an Io (closures over `$`, built in each half) because `$` itself may
8// only be passed to functions in the file that holds it.
9
10import { isHolding, isRecordingQuestions, ledgerWithWindow, newWindow, nextLedgerId, nextParkId, offAway, pendingEntry } from './away.mjs'
11import { countGotcha, recurringGotchas, writtenRuleOf } from './guards.mjs'
12import { emptyEvidence, intentOwner, isSamePerson, personId } from './model.mjs'
13import { forgetPack, packFor } from './packs/index.mjs'
14import { unreal } from './packs/unreal.mjs'
15import { groupByOf } from './worklist.mjs'
16
17/**
18 * @typedef {{
19 * get: (key: string) => Promise<unknown>, set: (key: string, value: unknown) => Promise<void>, remove: (key: string) => Promise<void>, keys: () => Promise<string[]>,
20 * read: (path: string) => Promise<string | null>, write: (path: string, text: string) => Promise<void>, exists: (path: string) => Promise<boolean>,
21 * sessionId: () => Promise<string>, root: () => Promise<string>, gitUser: () => Promise<string>, redraw: () => void,
22 * list?: (path: string) => Promise<{ name: string, kind: string, mtimeMs?: number }[]>
23 * }} Io
24 * @typedef {import('./packs/index.mjs').Pack} Pack
25 * @typedef {import('./away.mjs').Away} Away
26 * @typedef {import('./model.mjs').Evidence} Evidence
27 */
28
29const KEY = {
30 away: (/** @type {string} */ sid) => `away:${sid}`,
31 pinned: (/** @type {string} */ sid) => `pinned:${sid}`,
32 evidence: (/** @type {string} */ sid) => `evidence:${sid}`,
33 lost: (/** @type {string} */ sid) => `lost:${sid}`,
34 // The intents this session stopped tracking: a write into one does not track it again.
35 untracked: (/** @type {string} */ sid) => `untracked:${sid}`,
36 // A pack's roles are its own: a tech artist in S2 is not a role in a web repository. The Unreal pack's key is unprefixed.
37 role: (/** @type {string} */ me, /** @type {string} */ prefix = '') => `role:${prefix}${personId(me)}`,
38 area: (/** @type {string} */ me) => `area:${personId(me)}`,
39 tour: (/** @type {string} */ me) => `tour:${personId(me)}`,
40 nudged: (/** @type {string} */ me) => `nudged:${personId(me)}`,
41 // How this person groups the teammates' intents in Everything open (person, area, stage or none).
42 groupBy: (/** @type {string} */ me) => `groupBy:${personId(me)}`,
43 // The sessions holding an away window for this person, so a new session finds them without a scan.
44 windows: (/** @type {string} */ person) => `windows:${person}`,
45 issues: (/** @type {string} */ me) => `issues:${personId(me)}`,
46 last: (/** @type {string} */ me) => `last:${personId(me)}`,
47 // What edits recorded in an intent, newest last: shared by every session on the machine.
48 changes: (/** @type {string} */ slug) => `changes:${slug}`,
49 // What gh last said about the PRs intents name: shared by every session on the machine.
50 prs: 'prStates',
51 tz: 'tz',
52 hits: 'gotchaHits',
53 ruled: 'gotchaRuled',
54 score: 'score',
55}
56// What belongs to this lane and follows it to a new session id after /clear.
57const LANE_KEYS = [KEY.away, KEY.pinned, KEY.evidence, KEY.lost, KEY.untracked]
58
59let queue = Promise.resolve()
60/** @template T @param {() => Promise<T>} task @returns {Promise<T>} */
61const serial = task => {
62 const run = queue.then(task)
63 queue = run.then(
64 () => undefined,
65 () => undefined,
66 )
67 return run
68}
69
70// An issue list older than this is not shown: gh may have stopped answering.
71const ISSUES_TTL_MS = 24 * 60 * 60 * 1000
72
73// Bumped on every change, so a drawing can tell its cached view is stale.
74let version = 0
75export const stateVersion = () => version
76/** @param {Io} io */
77const changed = io => {
78 version += 1
79 io.redraw()
80}
81
82// ---------------------------------------------------------------- the lane
83
84/** @type {Map<string, Promise<{ root: string, isS2: boolean, me: string, pack: Pack }>>} */
85const lanes = new Map()
86// Roots read without intents. One that has them at a later read was set up in this session
87// (/ather setup): its profile is new, so its pack is chosen again and each half is told.
88/** @type {Set<string>} */
89const bare = new Set()
90/** @type {Map<string, (pack: Pack) => unknown>} */
91const setUpHandlers = new Map()
92
93// What a half does when a repository is set up mid-session; one handler per `who`, the last one kept.
94/** @param {string} who @param {(pack: Pack) => unknown} handler */
95export const onSetUp = (who, handler) => void setUpHandlers.set(who, handler)
96
97// Who and where, read once per checkout and shared by both halves. `isS2`: the repository runs intents
98// (a docs/intent folder), whatever its kind; `pack` says which kind (packs/index.mjs, once per session).
99/** @param {Io} io @param {string} cwd */
100export const lane = (io, cwd) => {
101 const cached = lanes.get(cwd)
102 if (cached) return cached
103 const read = (async () => {
104 const root = (await io.root().catch(() => cwd)) || cwd
105 const list = io.list ?? (async () => [])
106 const isS2 = await io.exists(`${root}/docs/intent`)
107 const isSetUp = isS2 && bare.delete(root)
108 if (!isS2) bare.add(root)
109 if (isSetUp) await forgetPack({ sessionId: io.sessionId }, root)
110 const { pack } = await packFor({ read: io.read, exists: io.exists, list, sessionId: io.sessionId }, root).catch(() => ({ pack: unreal }))
111 // A handler that fails must not cost the reading.
112 if (isSetUp) for (const handler of setUpHandlers.values()) await Promise.resolve().then(() => handler(pack)).catch(() => undefined)
113 return { root, isS2, me: await io.gitUser().catch(() => ''), pack }
114 })()
115 lanes.set(cwd, read)
116 // A git name that failed to read (a slow first start) is asked again next time, never kept.
117 void read.then(found => {
118 if (found.me === '' && lanes.get(cwd) === read) lanes.delete(cwd)
119 })
120 return read
121}
122
123// For /ather where there were no intents: /ather setup may have added them in this session. A kept
124// reading without intents is dropped once the folder is there, and the checkout read again. A lane
125// that runs intents is kept as read.
126/** @param {Io} io @param {string} cwd */
127export const laneAgain = async (io, cwd) => {
128 const kept = lane(io, cwd)
129 const found = await kept
130 if (found.isS2 || !(await io.exists(`${found.root}/docs/intent`))) return found
131 if (lanes.get(cwd) === kept) lanes.delete(cwd)
132 return lane(io, cwd)
133}
134
135/** @param {Io} io */
136export const sessionId = io => io.sessionId()
137
138// After /clear the process goes on under a new session id and no session.start fires:
139// this lane's window, tracked intent, evidence, lost-edits flag and untracked intents move to it. Only
140// /clear moves them; a resume returns to another conversation, whose state is its own.
141/** @param {Io} io @param {string} from @param {string} to */
142export const moveLane = (io, from, to) =>
143 serial(async () => {
144 const away = /** @type {Away | undefined} */ (await io.get(KEY.away(from)))
145 for (const key of LANE_KEYS) {
146 const value = await io.get(key(from))
147 if (value === undefined) continue
148 if ((await io.get(key(to))) === undefined) await io.set(key(to), value)
149 await io.remove(key(from))
150 }
151 if (away?.person) await setIndex(io, away.person, list => [...list.filter(sid => sid !== from), to])
152 changed(io)
153 })
154
155/** @param {Io} io @param {string} person @param {(list: string[]) => string[]} change */
156const setIndex = async (io, person, change) => {
157 const list = /** @type {string[]} */ ((await io.get(KEY.windows(person))) ?? [])
158 const next = [...new Set(change(list))]
159 if (next.length === 0) await io.remove(KEY.windows(person))
160 else await io.set(KEY.windows(person), next)
161}
162
163// ---------------------------------------------------------------- reading
164
165/** @param {Io} io */
166export const readAway = async io => /** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(KEY.away(await io.sessionId()))) ?? {}) })
167// Where evidence is kept: with the tracked intent, so yesterday's build and tests still count
168// today; with the session when nothing is tracked. Each record is good for a day (EVIDENCE_TTL_MS).
169/** @param {Io} io */
170export const evidenceScope = async io => {
171 const pinned = /** @type {string | undefined} */ (await io.get(KEY.pinned(await io.sessionId())))
172 return pinned ?? io.sessionId()
173}
174
175// Proof older than this no longer counts: the code has likely moved on since.
176const EVIDENCE_TTL_MS = 24 * 60 * 60 * 1000
177
178/** @param {Io} io @param {string} scope from evidenceScope @param {Pack} [pack] @returns {Promise<Evidence>} */
179export const readEvidence = async (io, scope, pack = unreal) => {
180 const stored = /** @type {Record<string, { state: string, detail: string, at?: number }>} */ ((await io.get(KEY.evidence(scope))) ?? {})
181 const fresh = Object.fromEntries(Object.entries(stored).filter(([, rung]) => Date.now() - (rung.at ?? 0) < EVIDENCE_TTL_MS))
182 return /** @type {Evidence} */ ({ ...emptyEvidence(pack), ...fresh })
183}
184
185// A session as people see it named: the first 8 hex of its id, as the Editor lock and the tab list show it.
186/** @param {string} sid */
187export const shortSession = sid => sid.slice(0, 8)
188
189// Writes one scope's evidence, each record stamped with when it was seen and `by` the session that saw it.
190/** @param {Io} io @param {string} scope @param {Partial<Evidence>} change */
191const writeEvidence = async (io, scope, change) => {
192 const stored = /** @type {object} */ ((await io.get(KEY.evidence(scope))) ?? {})
193 const by = shortSession(await io.sessionId())
194 const stamped = Object.fromEntries(Object.entries(change).map(([rung, value]) => [rung, { ...value, at: Date.now(), by }]))
195 await io.set(KEY.evidence(scope), { ...stored, ...stamped })
196 changed(io)
197}
198/** @param {Io} io @returns {Promise<string | null>} */
199export const readPinned = async io => /** @type {string | null} */ ((await io.get(KEY.pinned(await io.sessionId()))) ?? null)
200/** @param {Io} io @returns {Promise<{ paths: string[], isDisclosed: boolean } | null>} */
201export const readLost = async io => /** @type {any} */ ((await io.get(KEY.lost(await io.sessionId()))) ?? null)
202/** @param {Io} io */
203export const readTz = async io => Number(await io.get(KEY.tz)) || 0
204/** @param {Io} io @param {string} me @param {Pack} [pack] */
205export const readProfile = async (io, me, pack = unreal) => ({
206 role: String((await io.get(KEY.role(me, pack.roleKey))) ?? ''),
207 area: String((await io.get(KEY.area(me))) ?? ''),
208 tourDone: /** @type {{ isDone?: boolean } | undefined} */ (await io.get(KEY.tour(me)))?.isDone === true,
209 isNudged: (await io.get(KEY.nudged(me))) === true,
210})
211// A trap whose rule is already written in the checkout is not offered again, however many sessions hit it.
212/** @param {Io} io @param {Pack} [pack] */
213export const readRecurring = async (io, pack = unreal) => {
214 const recurring = recurringGotchas(/** @type {any} */ ((await io.get(KEY.hits)) ?? {}), /** @type {string[]} */ ((await io.get(KEY.ruled)) ?? []), pack)
215 if (recurring.length === 0) return recurring
216 const root = await io.root().catch(() => '')
217 const isWritten = await Promise.all(recurring.map(async one => {
218 const rule = writtenRuleOf(one.id, pack)
219 return Boolean(rule && root && (await io.read(`${root}/${rule.file}`))?.includes(rule.text))
220 }))
221 return recurring.filter((_, index) => !isWritten[index])
222}
223/** @param {Io} io @param {string} me @returns {Promise<import('./issues.mjs').Issue[]>} */
224export const readIssues = async (io, me) => {
225 const cached = /** @type {{ at?: number, list?: import('./issues.mjs').Issue[] } | undefined} */ (await io.get(KEY.issues(me)))
226 return cached?.list && Date.now() - (cached.at ?? 0) < ISSUES_TTL_MS ? cached.list : []
227}
228/** @param {Io} io @returns {Promise<Record<string, import('./prs.mjs').PrRecord>>} */
229export const readPrRecords = async io => /** @type {Record<string, import('./prs.mjs').PrRecord>} */ ((await io.get(KEY.prs)) ?? {})
230// PR number → its last read state, for the pure readers in model.mjs.
231/** @param {Io} io @returns {Promise<import('./model.mjs').PrStates>} */
232export const readPrStates = async io => Object.fromEntries(Object.entries(await readPrRecords(io)).map(([number, record]) => [number, record.state]))
233/** @param {Io} io @param {string} me @returns {Promise<string | null>} */
234export const readLast = async (io, me) => /** @type {string | null} */ ((await io.get(KEY.last(me))) ?? null)
235// The person's grouping for Everything open; Person until they choose another.
236/** @param {Io} io @param {string} me */
237export const readGroupBy = async (io, me) => groupByOf(await io.get(KEY.groupBy(me)))
238/** @param {Io} io */
239export const readScore = async io => /** @type {Record<string, number>} */ ((await io.get(KEY.score)) ?? {})
240
241// ---------------------------------------------------------------- changing
242
243/** @param {Io} io @param {string} me @param {import('./issues.mjs').Issue[]} issues */
244export const setIssues = (io, me, issues) =>
245 serial(async () => {
246 await io.set(KEY.issues(me), { at: Date.now(), list: issues })
247 changed(io)
248 })
249
250// A PR record not read again for this long is dropped: its intent has closed or moved on.
251const PRS_TTL_MS = 30 * 24 * 60 * 60 * 1000
252
253// What gh just said about some PRs ('UNREAD' when it could not say), each stamped `at`.
254/** @param {Io} io @param {import('./model.mjs').PrStates} states @param {number} at */
255export const setPrStates = (io, states, at) =>
256 serial(async () => {
257 if (Object.keys(states).length === 0) return
258 const kept = Object.entries(await readPrRecords(io)).filter(([, record]) => at - record.at < PRS_TTL_MS)
259 await io.set(KEY.prs, { ...Object.fromEntries(kept), ...Object.fromEntries(Object.entries(states).map(([number, value]) => [number, { state: value, at }])) })
260 changed(io)
261 })
262
263const CHANGES_KEPT = 20
264const CHANGES_TTL_MS = 36 * 60 * 60 * 1000
265
266/** @typedef {import('./changes.mjs').Change & { at: number }} Recorded */
267
268// The lines an intent gained, newest first, from `since` on (the start of the person's day).
269/** @param {Io} io @param {string} slug @param {number} since @returns {Promise<Recorded[]>} */
270export const readChanges = async (io, slug, since) => (/** @type {Recorded[]} */ ((await io.get(KEY.changes(slug))) ?? [])).filter(one => one.at >= since).reverse()
271
272// An edit's lines join the intent's; the same line again (a re-tick, a rewrite) replaces the older one.
273/** @param {Io} io @param {string} slug @param {readonly import('./changes.mjs').Change[]} lines @param {number} at */
274export const noteChanges = (io, slug, lines, at) =>
275 serial(async () => {
276 if (lines.length === 0) return
277 const kept = /** @type {Recorded[]} */ ((await io.get(KEY.changes(slug))) ?? []).filter(one => at - one.at < CHANGES_TTL_MS && !lines.some(line => line.kind === one.kind && line.id === one.id))
278 await io.set(KEY.changes(slug), [...kept, ...lines.map(line => ({ ...line, at }))].slice(-CHANGES_KEPT))
279 changed(io)
280 })
281
282/** @param {Io} io @param {number} offset */
283export const setTz = (io, offset) => serial(() => io.set(KEY.tz, offset))
284
285// Before 0.9 the role was kept per machine under "coach"; it becomes this person's.
286/** @param {Io} io @param {string} me */
287export const migrateRole = (io, me) =>
288 serial(async () => {
289 const legacy = /** @type {{ role?: string } | undefined} */ (await io.get('coach'))
290 if (!legacy) return
291 if ((await io.get(KEY.role(me))) === undefined && legacy.role) await io.set(KEY.role(me), legacy.role)
292 await io.remove('coach')
293 })
294
295/** @param {Io} io @param {string} me @param {{ role?: string, area?: string, tourDone?: boolean, isNudged?: boolean }} fields @param {Pack} [pack] */
296export const setProfile = (io, me, fields, pack = unreal) =>
297 serial(async () => {
298 if (fields.role !== undefined) await io.set(KEY.role(me, pack.roleKey), fields.role)
299 if (fields.area !== undefined) await io.set(KEY.area(me), fields.area)
300 if (fields.tourDone !== undefined) await io.set(KEY.tour(me), { isDone: fields.tourDone })
301 if (fields.isNudged !== undefined) await io.set(KEY.nudged(me), fields.isNudged)
302 changed(io)
303 })
304
305/** @param {Io} io @param {string} me @param {import('./worklist.mjs').GroupBy} by */
306export const setGroupBy = (io, me, by) =>
307 serial(async () => {
308 await io.set(KEY.groupBy(me), by)
309 changed(io)
310 })
311
312// Whether this checkout has the intent's folder: what tracking it needs (asking about it does not).
313/** @param {Io} io @param {string} root @param {string} slug */
314export const hasIntentFolder = (io, root, slug) => io.exists(`${root}/docs/intent/${slug}/prompt.md`)
315
316// Tracks an intent, if it exists. The one path for /ather, the profile tool and a write into an intent.
317// `isAuto`: a write into the intent, which never tracks one this session stopped tracking; tracking one
318// on purpose lifts that stop. `me`: the person's "Continue …" moves to it too.
319/** @param {Io} io @param {string} root @param {string} slug @param {{ onlyIfNone?: boolean, isAuto?: boolean, me?: string }} [options] */
320export const track = (io, root, slug, options = {}) =>
321 serial(async () => {
322 if (!(await hasIntentFolder(io, root, slug))) return false
323 const sid = await io.sessionId()
324 const stopped = /** @type {string[]} */ ((await io.get(KEY.untracked(sid))) ?? [])
325 if (options.isAuto && stopped.includes(slug)) return false
326 if (options.onlyIfNone && (await io.get(KEY.pinned(sid))) !== undefined) return false
327 await io.set(KEY.pinned(sid), slug)
328 if (options.me) await io.set(KEY.last(options.me), slug)
329 if (!options.isAuto && stopped.includes(slug)) await setList(io, KEY.untracked(sid), stopped.filter(one => one !== slug))
330 await beat(io)
331 changed(io)
332 return true
333 })
334
335// Stops tracking: the session's pin, the person's "Continue …" when it names the same intent, and a
336// stop on a write tracking it again in this session. Proof recorded so far stays with the intent.
337// Refused while an away window runs: its mandate and ledger were set up for the tracked intent.
338/** @param {Io} io @param {string} me @returns {Promise<{ result: 'untracked' | 'none' | 'away', slug: string }>} */
339export const untrack = (io, me) =>
340 serial(async () => {
341 const sid = await io.sessionId()
342 const slug = /** @type {string | undefined} */ (await io.get(KEY.pinned(sid)))
343 if (slug === undefined) return { result: /** @type {const} */ ('none'), slug: '' }
344 const away = /** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(KEY.away(sid))) ?? {}) })
345 if (away.phase === 'running') return { result: /** @type {const} */ ('away'), slug }
346 await io.remove(KEY.pinned(sid))
347 if ((await io.get(KEY.last(me))) === slug) await io.remove(KEY.last(me))
348 await setList(io, KEY.untracked(sid), [.../** @type {string[]} */ ((await io.get(KEY.untracked(sid))) ?? []), slug])
349 await beat(io)
350 changed(io)
351 return { result: /** @type {const} */ ('untracked'), slug }
352 })
353
354/** @param {Io} io @param {string} key @param {string[]} list */
355const setList = async (io, key, list) => {
356 const kept = [...new Set(list)]
357 if (kept.length === 0) await io.remove(key)
358 else await io.set(key, kept)
359}
360
361// ---------------------------------------------------------------- lanes: the heartbeats of the sessions on this checkout
362
363// A heartbeat older than this, or one that says it ended, is no longer a live session.
364const LANE_STALE_MS = 10 * 60 * 1000
365
366/**
367 * One session's heartbeat, a file under the pack's local folder: what it tracks, on which branch,
368 * when it was written and when the session last took a prompt or ran a tool.
369 * @typedef {{ sessionId: string, intent: string | null, branch: string, updatedAt: number, lastActiveAt?: number, away: string, hasEnded: boolean }} Lane
370 */
371
372// When this session last took a prompt or ran a tool; a hot reload starts it again.
373let activeAt = Date.now()
374/** @param {number} [at] */
375export const markActive = (at = Date.now()) => {
376 activeAt = at
377}
378
379/** @type {{ path: string, lane: Lane } | null} */
380let lastBeat = null
381
382// Writes this session's heartbeat; after any change in hand, so it never undoes one.
383/** @param {Io} io @param {{ root: string, localDir: string, branch: string, hasEnded: boolean }} at */
384export const writeHeartbeat = (io, at) =>
385 serial(async () => {
386 const sid = await io.sessionId()
387 const away = await readAway(io)
388 /** @type {Lane} */
389 const lane = { sessionId: sid, intent: await readPinned(io), branch: at.branch, updatedAt: Date.now(), lastActiveAt: activeAt, away: away.phase, hasEnded: at.hasEnded }
390 const path = `${at.root}/${at.localDir}/lanes/${sid}.json`
391 await io.write(path, JSON.stringify(lane))
392 lastBeat = { path, lane }
393 })
394
395// The heartbeat again, at once, when what the session tracks changes: peers see it before the next tick.
396/** @param {Io} io */
397const beat = async io => {
398 const sid = await io.sessionId()
399 if (lastBeat === null || lastBeat.lane.sessionId !== sid) return
400 const { path } = lastBeat
401 // Only over a heartbeat that is still there: never brings back one a cleanup removed.
402 if (!(await io.exists(path))) return
403 const lane = { ...lastBeat.lane, intent: /** @type {string | undefined} */ (await io.get(KEY.pinned(sid))) ?? null, updatedAt: Date.now(), lastActiveAt: activeAt }
404 await io.write(path, JSON.stringify(lane)).catch(() => undefined)
405 lastBeat = { path, lane }
406}
407
408// One session's heartbeat on this checkout, or null when it has none here (it may live in another checkout).
409/** @param {Io} io @param {string} root @param {string} localDir @param {string} sid @returns {Promise<Lane | null>} */
410export const readLane = async (io, root, localDir, sid) => {
411 try {
412 return JSON.parse((await io.read(`${root}/${localDir}/lanes/${sid}.json`)) ?? '')
413 } catch {
414 return null
415 }
416}
417
418/** @param {Lane} lane */
419export const isLaneLive = lane => !lane.hasEnded && Date.now() - Number(lane.updatedAt) < LANE_STALE_MS
420
421// The other sessions alive on this checkout: a fresh heartbeat that has not said it ended.
422/** @param {Io} io @param {string} root @param {string} localDir @returns {Promise<Lane[]>} */
423export const readPeers = async (io, root, localDir) => {
424 const dir = `${root}/${localDir}/lanes`
425 const sid = await io.sessionId()
426 const list = io.list ?? (async () => [])
427 /** @type {Lane[]} */
428 const out = []
429 for (const entry of await list(dir).catch(() => [])) {
430 if (entry.kind !== 'file' || entry.name === `${sid}.json` || Date.now() - Number(entry.mtimeMs) > LANE_STALE_MS) continue
431 try {
432 const lane = JSON.parse((await io.read(`${dir}/${entry.name}`)) ?? '')
433 if (!lane.hasEnded) out.push(lane)
434 } catch {
435 // a half-written heartbeat; the next tick reads it
436 }
437 }
438 return out
439}
440
441/** @param {Io} io @param {string} scope @param {keyof Evidence} rung @param {import('./model.mjs').Rung} value */
442export const setRung = (io, scope, rung, value) => serial(() => writeEvidence(io, scope, { [rung]: { state: value.state, detail: value.detail.slice(0, 120) } }))
443
444// MCP evidence: a write waits for a read back on the same server; PIE counts when it started.
445/** @param {Io} io @param {string} scope @param {'write' | 'read' | 'pie'} kind @param {string} server @param {boolean} isOk */
446export const noteMcp = (io, scope, kind, server, isOk) =>
447 serial(async () => {
448 const evidence = { ...emptyEvidence(), .../** @type {object} */ ((await io.get(KEY.evidence(scope))) ?? {}) }
449 const pending = `pending readback on ${server}`
450 /** @type {Partial<Evidence>} */
451 let change = {}
452 if (kind === 'write' && isOk) change = { readback: { state: 'none', detail: pending } }
453 if (kind === 'read' && isOk && evidence.readback.detail === pending) change = { readback: { state: 'pass', detail: `read back on ${server}` } }
454 if (kind === 'pie') change = { pie: { state: isOk ? 'pass' : 'fail', detail: server } }
455 if (Object.keys(change).length === 0) return
456 await writeEvidence(io, scope, change)
457 })
458
459/** @param {Io} io @param {readonly import('./guards.mjs').Trap[]} traps traps first seen in this session */
460export const countTraps = (io, traps) =>
461 serial(async () => {
462 let hits = /** @type {import('./guards.mjs').TrapHits} */ ((await io.get(KEY.hits)) ?? {})
463 for (const trap of traps) hits = countGotcha(hits, trap)
464 await io.set(KEY.hits, hits)
465 changed(io)
466 })
467
468/** @param {Io} io @param {string[]} paths */
469export const flagLost = (io, paths) =>
470 serial(async () => {
471 await io.set(KEY.lost(await io.sessionId()), { paths, isDisclosed: false })
472 changed(io)
473 })
474
475/** @param {Io} io @param {string} key */
476export const bump = (io, key) =>
477 serial(async () => {
478 const score = /** @type {Record<string, number>} */ ((await io.get(KEY.score)) ?? {})
479 await io.set(KEY.score, { ...score, [key]: (score[key] ?? 0) + 1 })
480 })
481
482// What else changes once an item has been delivered to the session.
483/** @param {Io} io @param {import('./home.mjs').Item} item */
484export const settleItem = (io, item) =>
485 serial(async () => {
486 const sid = await io.sessionId()
487 if (item.kind === 'lost') await io.remove(KEY.lost(sid))
488 if (item.kind === 'rule') await io.set(KEY.ruled, [.../** @type {string[]} */ ((await io.get(KEY.ruled)) ?? []), ...item.ruleIds])
489 changed(io)
490 })
491
492// ---------------------------------------------------------------- the away window
493
494/** @param {Io} io @param {(away: Away) => Promise<{ away?: Away, result: T }>} change @template T @returns {Promise<T>} */
495const withAway = (io, change) =>
496 serial(async () => {
497 const key = KEY.away(await io.sessionId())
498 const away = /** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(key)) ?? {}) })
499 const { away: next, result } = await change(away)
500 if (next) {
501 const sid = key.slice('away:'.length)
502 if (isHolding(next)) await io.set(key, next)
503 else await io.remove(key)
504 const person = next.person || away.person
505 if (person) await setIndex(io, person, list => (isHolding(next) ? [...list, sid] : list.filter(one => one !== sid)))
506 changed(io)
507 }
508 return result
509 })
510
511/**
512 * Opens a window, unless one is running or waiting for review.
513 * @param {Io} io @param {import('./away.mjs').WindowChoice} choice @param {{ root: string, tz: number, now: number, me: string, pack?: Pack }} at
514 * @returns {Promise<Away | null>}
515 */
516export const startAway = (io, choice, at) =>
517 withAway(io, async away => {
518 if (away.phase !== 'off') return { result: null }
519 const pinned = /** @type {string | undefined} */ (await io.get(KEY.pinned(await io.sessionId())))
520 const owner = pinned ? intentOwner((await io.read(`${at.root}/docs/intent/${pinned}/prompt.md`)) ?? '') : ''
521 const stamp = new Date(at.now + at.tz * 60000).toISOString().slice(0, 16).replace(/[:T]/g, '-')
522 const ledgerPath = pinned && isSamePerson(owner, at.me) ? `${at.root}/docs/intent/${pinned}/decisions.md` : `${at.root}/${(at.pack ?? unreal).localDir}/away/${stamp}.md`
523 const started = newWindow({ ...choice, held: choice.held ?? [...(at.pack ?? unreal).held.defaults] }, at.now, ledgerPath, { person: personId(at.me), root: at.root })
524 await io.write(ledgerPath, ledgerWithWindow((await io.read(ledgerPath)) ?? '', started, at.tz, at.pack ?? unreal))
525 return { away: started, result: started }
526 })
527
528// Ends a running window; the review waits in "Needs you". Resolves whether it was running.
529/** @param {Io} io */
530export const endAway = io => withAway(io, async away => (away.phase === 'running' ? { away: { ...away, phase: /** @type {const} */ ('review'), endedAt: Date.now() }, result: true } : { result: false }))
531
532/** @param {Io} io */
533export const closeAway = io => withAway(io, async away => (away.phase === 'off' ? { result: false } : { away: { ...offAway(), person: away.person }, result: true }))
534
535// Puts a window back, when the review that closed it could not be delivered.
536/** @param {Io} io @param {Away} saved */
537export const restoreAway = (io, saved) => withAway(io, async away => (isHolding(away) || !isHolding(saved) ? { result: false } : { away: saved, result: true }))
538
539// Records a held action; resolves the parked entry, or null when no running window holds this kind.
540/** @param {Io} io @param {string} kind @param {string} command @param {number} now */
541export const park = (io, kind, command, now) =>
542 withAway(io, async away => {
543 if (!isHolding(away) || !away.held.includes(kind)) return { result: null }
544 const parked = { id: nextParkId(away.parked), kind, command: command.slice(0, 400), at: now }
545 return { away: { ...away, parked: [...away.parked, parked] }, result: { parked, away } }
546 })
547
548// Writes the model's questions to the ledger instead of asking; resolves their ids, or null when the person
549// can be asked (no window, or back since it ended: `lastPersonAt` is when they last typed).
550/** @param {Io} io @param {readonly { question: string, options: readonly { label: string }[] }[]} questions @param {number} lastPersonAt */
551export const deferQuestions = (io, questions, lastPersonAt) =>
552 withAway(io, async away => {
553 if (!isRecordingQuestions(away, lastPersonAt)) return { result: null }
554 const text = (await io.read(away.ledgerPath)) ?? ''
555 const first = nextLedgerId(text)
556 const ids = questions.map((_, index) => `D-${first + index}`)
557 await io.write(away.ledgerPath, `${text.trimEnd()}\n\n${questions.map((one, index) => pendingEntry(ids[index] ?? '', one.question, one.options.map(option => option.label))).join('\n')}`)
558 return { result: { ids, away } }
559 })
560
561// A new session (the next morning, an app restart) picks up this person's window from an
562// earlier session that has ended, so its holds and its review are not lost. A window in a
563// session that is still alive stays where it is. Resolves the window taken over, or null.
564/** @param {Io} io @param {{ me: string, root: string, isAlive: (sid: string) => Promise<boolean> }} lane */
565export const adoptWindow = async (io, lane) => {
566 const sid = await io.sessionId()
567 if (isHolding(/** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(KEY.away(sid))) ?? {}) }))) return null
568 /** @type {{ from: string, away: Away } | null} */
569 let found = null
570 for (const from of /** @type {string[]} */ ((await io.get(KEY.windows(personId(lane.me)))) ?? [])) {
571 if (from === sid) continue
572 const away = /** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(KEY.away(from))) ?? {}) })
573 if (!isHolding(away) || away.root !== lane.root) continue
574 if (await lane.isAlive(from)) continue
575 if (!found || away.startedAt > found.away.startedAt) found = { from, away }
576 }
577 if (!found) return null
578 await moveLane(io, found.from, sid)
579 const isOver = found.away.phase === 'review' || Date.now() >= found.away.wakeAt
580 if (found.away.phase === 'running' && isOver) await endAway(io)
581 return { away: found.away, isOver }
582}
583
584// Removes what sessions that have ended left behind (tracked intent, evidence, a lost-edits
585// flag, the intents it stopped tracking, an empty window), so the store stays small. The store spans every checkout on the
586// machine, so only sessions `isGone` can vouch for are touched (their heartbeat is in this
587// checkout and says ended or stale); a holding window is kept for adoption.
588// Runs in the background; reads every key once.
589/** @param {Io} io @param {(sid: string) => Promise<boolean>} isGone */
590export const prune = async (io, isGone) => {
591 const current = await io.sessionId()
592 /** @type {Map<string, string[]>} */
593 const bySession = new Map()
594 for (const key of await io.keys()) {
595 const sid = /^(?:away|pinned|evidence|lost|untracked):(.+)$/.exec(key)?.[1]
596 if (sid && sid !== current) bySession.set(sid, [...(bySession.get(sid) ?? []), key])
597 }
598 for (const [sid, keys] of bySession) {
599 if (!(await isGone(sid))) continue
600 // A session that ended while its window still holds keeps its whole lane for adoption.
601 if (isHolding(/** @type {Away} */ ({ ...offAway(), .../** @type {object} */ ((await io.get(KEY.away(sid))) ?? {}) }))) continue
602 await serial(async () => {
603 for (const key of keys) await io.remove(key)
604 })
605 }
606}
607hooks/workers.mjs 83 lines1// @ts-check
2// Ather Automata: the background workers this session dispatched, as the watch half sees them
3// (spawned with this brief and model, called these tools) for the console half to draw. Held in
4// memory for the session: workers do not outlive it. Pure: no `$`.
5
6import { classifyWorker, kindOfAgent, propForTool } from './squad.mjs'
7
8/** @typedef {import('./squad.mjs').Kind} Kind */
9/** @typedef {import('./squad.mjs').Prop} Prop */
10/**
11 * `origin`: 'seen' when Ather saw it dispatched (its tool calls are all counted), 'adopted' when it
12 * was found running after Ather loaded (start and model from Claude Code's record; calls before then uncounted).
13 * `endedAt`: when its latest turn ended; a worker resumed afterwards is running again by its status.
14 * `lastAt`, `lastTool`: when it was last heard from (a call's start or end) and its latest call: the one clock
15 * the pane and the quiet-worker toast read.
16 * @typedef {{ id: string, title: string, kind: Kind, model: string, origin: Exclude<import('./squad.mjs').Origin, 'unknown'>, startedAt: number, lastAt: number,
17 * tools: number, lastTool: string, prop: Prop | null, trail: Prop[], endedAt?: number }} Worker
18 */
19
20/** @type {Map<string, Worker>} */
21const known = new Map()
22const MOST = 40
23const TRAIL = 6
24
25export const resetWorkers = () => known.clear()
26
27/** @param {Worker} worker */
28const remember = worker => {
29 known.set(worker.id, worker)
30 // A long session keeps the most recent workers only.
31 while (known.size > MOST) known.delete(/** @type {string} */ (known.keys().next().value))
32}
33
34// The kind is fixed here, from the dispatch: a worker never changes body while it runs.
35/** @param {{ agentId: string, subagentType: string, prompt: string, description: string, model: string, at: number }} spawn */
36export const recordSpawn = spawn =>
37 remember({ id: spawn.agentId, title: spawn.description || spawn.subagentType, kind: classifyWorker(spawn), model: spawn.model, origin: 'seen', startedAt: spawn.at, lastAt: spawn.at, tools: 0, lastTool: '', prop: null, trail: [] })
38
39// A worker found running after Ather loaded: its kind from its description, its start from
40// Claude Code's record. Nothing seen of it yet is not idleness, so it was last heard from now.
41/** @param {{ id: string, type: string, description: string, model: string, startedAt: number, now: number }} found */
42export const adoptWorker = found =>
43 remember({ id: found.id, title: found.description || found.type, kind: kindOfAgent(found.type, found.description), model: found.model, origin: 'adopted', startedAt: found.startedAt, lastAt: found.now, tools: 0, lastTool: '', prop: null, trail: [] })
44
45// One tool call inside a worker's loop: what it is doing now, and the step in its trail.
46/** @param {string} agentId @param {string} tool @param {Record<string, unknown>} input @param {number} at */
47export const recordTool = (agentId, tool, input, at) => {
48 const worker = known.get(agentId)
49 if (!worker) return
50 worker.tools += 1
51 worker.lastAt = at
52 worker.lastTool = tool
53 const prop = propForTool(tool, input)
54 if (!prop) return
55 worker.prop = prop
56 // The trail is the work done: a question to the person is not a step of it.
57 if (prop !== 'idle' && prop !== 'asking' && worker.trail.at(-1) !== prop) worker.trail = [...worker.trail, prop].slice(-TRAIL)
58}
59
60// One of its calls settled (it ran, was refused or threw): heard from now, so a long call's end
61// starts the quiet clock, not its start.
62/** @param {string} agentId @param {number} at */
63export const recordHeard = (agentId, at) => {
64 const worker = known.get(agentId)
65 if (worker) worker.lastAt = Math.max(worker.lastAt, at)
66}
67
68// A worker's turn ended (its answer): the end of its clock, until it is resumed.
69/** @param {string} agentId @param {number} at */
70export const recordEnd = (agentId, at) => {
71 const worker = known.get(agentId)
72 if (worker) worker.endedAt = at
73}
74
75// How long it has run: to now while it runs, to its last turn's end once finished; null for one
76// that finished without Ather seeing its turn end (before Ather loaded): its time is not known.
77/** @param {Worker} worker @param {boolean} isLive @param {number} now @returns {number | null} */
78export const workerElapsed = (worker, isLive, now) =>
79 isLive ? now - worker.startedAt : worker.endedAt === undefined ? null : worker.endedAt - worker.startedAt
80
81/** @param {string} agentId */
82export const workerOf = agentId => known.get(agentId)
83hooks/inflight.mjs 157 lines1// @ts-check
2// Ather Automata: the tool calls in flight, per model loop (a worker's, or the main loop's), as the
3// watch half sees them: what each call is, when it started, whether it waits on a permission
4// decision, and for a foreground Agent call the worker it started. A call is recorded when the
5// tool.call hook passes it on and cleared when that settles (it ran, was refused, or threw), when
6// the hook's dispatch is aborted, or when its loop's turn ends. So a long build is never read as a
7// quiet worker, and a call waiting on permission is never read as running. Pure: no `$`.
8
9/**
10 * `loop`: the worker's agent id, '' for the main loop. `what`: the call's own description, or a short
11 * form of its command. `childId`: the worker a foreground Agent call started, once agent.spawn reports it.
12 * `askedAt`: when a permission dialog for the call was shown (classic.PermissionRequest); kept until the call
13 * settles, since no event says the dialog was answered: words built on it say only that it was asked.
14 * @typedef {{ token: number, loop: string, toolUseId: string, tool: string, what: string, command: string, startedAt: number, childId?: string, askedAt?: number }} Call
15 */
16
17/** @type {Map<number, Call>} */
18const calls = new Map()
19let nextToken = 1
20// A runaway session keeps at most this many open calls (with aborts and turn ends clearing loops, rarely reached).
21const MOST = 200
22
23export const resetCalls = () => {
24 calls.clear()
25 nextToken = 1
26}
27
28// What a call is, in its own words: the description the model gave it, or its command's first line, cut.
29/** @param {string} tool @param {Record<string, unknown>} input */
30export const callWhat = (tool, input) => {
31 const description = String(input.description ?? '').trim()
32 if (description) return description
33 const command = String(input.command ?? '').split(/\r?\n/)[0]?.trim() ?? ''
34 return command ? (command.length <= 60 ? command : `${command.slice(0, 59)}…`) : tool
35}
36
37// Over the cap, the call let go first: the oldest still asking, else the oldest in the loop holding the most
38// calls (a leak piles up in one loop), so one long live call is never dropped for leaked ones.
39const evict = () => {
40 const open = [...calls.values()]
41 const asking = open.filter(one => one.askedAt !== undefined)
42 const perLoop = new Map()
43 for (const one of open) perLoop.set(one.loop, (perLoop.get(one.loop) ?? 0) + 1)
44 const busiest = [...perLoop.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
45 const victim = asking[0] ?? open.find(one => one.loop === busiest)
46 if (victim) calls.delete(victim.token)
47}
48
49// A call goes out; its token clears it.
50/** @param {{ loop?: string, toolUseId?: string, tool: string, input: Record<string, unknown>, at: number }} call @returns {number} */
51export const startCall = ({ loop = '', toolUseId = '', tool, input, at }) => {
52 const token = nextToken++
53 calls.set(token, { token, loop, toolUseId, tool, what: callWhat(tool, input), command: typeof input.command === 'string' ? input.command : '', startedAt: at })
54 while (calls.size > MOST) evict()
55 return token
56}
57
58/** @param {number} token */
59export const endCall = token => void calls.delete(token)
60
61// A loop's turn ended: nothing in it is in flight any more, whatever did not settle.
62/** @param {string} loop */
63export const endLoop = loop => {
64 for (const one of [...calls.values()]) if (one.loop === loop) calls.delete(one.token)
65}
66
67// The call `run` makes is in flight until it settles, whichever way (an answer, a refusal or a throw), or until
68// `signal` aborts (the person's Esc: a pending call is not sure to settle). `onSettled` runs after, also
69// whichever way, and never throws into the call.
70/** @template T @param {Parameters<typeof startCall>[0]} call @param {() => Promise<T>} run @param {() => void} [onSettled] @param {AbortSignal} [signal] @returns {Promise<T>} */
71export const during = async (call, run, onSettled, signal) => {
72 const token = startCall(call)
73 const onAbort = () => endCall(token)
74 signal?.addEventListener('abort', onAbort, { once: true })
75 if (signal?.aborted) endCall(token)
76 try {
77 return await run()
78 } finally {
79 endCall(token)
80 signal?.removeEventListener('abort', onAbort)
81 try {
82 onSettled?.()
83 } catch {
84 // Bookkeeping only: the call's own answer stands.
85 }
86 }
87}
88
89// A permission dialog was shown to the person (classic.PermissionRequest, which names no tool_use_id): the call it
90// is for waits on their decision, not running. It is the loop's newest call of that tool not yet asking, the one
91// with the same command when there is one. Nothing matches: nothing is marked.
92/** @param {{ loop?: string, tool: string, input: unknown, at: number }} dialog */
93export const markAsking = ({ loop = '', tool, input, at }) => {
94 const command = input && typeof input === 'object' && typeof (/** @type {{ command?: unknown }} */ (input)).command === 'string' ? String(/** @type {{ command: string }} */ (input).command) : ''
95 const open = callsIn(loop).filter(one => one.tool === tool && one.askedAt === undefined)
96 const call = (command ? open.filter(one => one.command === command).at(-1) : undefined) ?? open.at(-1)
97 if (call) call.askedAt = at
98}
99
100// agent.spawn reported the worker an Agent call started: the call that waits on it, by the call's id,
101// or else the loop's latest Agent call not yet linked. A background spawn is waited on by nobody.
102/** @param {{ loop?: string, toolUseId?: string, childId: string, isBackground?: boolean }} spawn */
103export const linkChild = ({ loop = '', toolUseId = '', childId, isBackground = false }) => {
104 if (isBackground || !childId) return
105 const open = [...calls.values()].filter(one => one.tool === 'Agent')
106 const call = (toolUseId ? open.find(one => one.toolUseId === toolUseId) : undefined) ?? open.filter(one => one.loop === loop && !one.childId).at(-1)
107 if (call) call.childId = childId
108}
109
110// The loop's calls in flight, oldest first, asking ones included.
111/** @param {string} [loop] @returns {Call[]} */
112export const callsIn = (loop = '') => [...calls.values()].filter(one => one.loop === loop).sort((a, b) => a.startedAt - b.startedAt)
113
114// The loop's calls waiting on a permission decision, oldest first.
115/** @param {string} [loop] */
116export const askingIn = (loop = '') => callsIn(loop).filter(one => one.askedAt !== undefined)
117
118// Something is running in the loop: a call in flight that is not waiting on permission.
119/** @param {string} [loop] */
120export const isInFlight = (loop = '') => [...calls.values()].some(one => one.loop === loop && one.askedAt === undefined)
121
122// The one rule for a quiet worker, in the pane and in the stuck-permission toast: nothing running,
123// and nothing heard for the threshold. A call running is never quiet, however long it runs.
124/** @param {{ isInFlight: boolean, lastAt: number, now: number, quietMs: number }} one */
125export const isSilent = ({ isInFlight, lastAt, now, quietMs }) => !isInFlight && now - lastAt > quietMs
126
127export const LONG_CALL_MS = 60000
128export const STUCK_CALL_MS = 25 * 60000
129
130// A shell or Monitor call running (not asking) for more than a minute, the oldest, if any.
131/** @param {readonly Call[]} open oldest first @param {number} now */
132export const longShell = (open, now) => open.find(one => one.askedAt === undefined && /^(Bash|PowerShell|Monitor)$/.test(one.tool) && now - one.startedAt > LONG_CALL_MS)
133
134/** @param {number} ms */
135const agoText = ms => (ms < 60000 ? 'just now' : `${Math.floor(ms / 60000)} min ago`)
136
137/**
138 * What a worker waits on, from facts only: `wait`, the amber ⏳ line (a call permission was asked for, and when;
139 * a foreground Agent call's worker; or a shell or Monitor call past a minute, quoted as described, with the pack's
140 * lock line when the command names its lock file); `stuck`, the ⚠ line when one call has gone on 25 minutes or more.
141 * @param {readonly Call[]} open the loop's calls in flight, oldest first @param {number} now
142 * @param {(childId: string) => string} titleOf @param {(command: string) => string} [lockOf]
143 * @returns {{ wait: string, stuck: string }}
144 */
145export const waitWords = (open, now, titleOf, lockOf = () => '') => {
146 const asking = open.find(one => one.askedAt !== undefined)
147 const running = open.filter(one => one.askedAt === undefined)
148 const agent = running.find(one => one.tool === 'Agent' && one.childId)
149 const shell = longShell(running, now)
150 const lock = shell && shell.command ? lockOf(shell.command) : ''
151 const wait = asking ? `⏳ asked permission ${agoText(now - (asking.askedAt ?? now))}: ${asking.what}` : agent?.childId ? `⏳ waiting on ${titleOf(agent.childId)}` : shell ? `⏳ ${shell.what}${lock ? ` · ${lock}` : ''}` : ''
152 // A call asked about is still a call: an approved build that runs on still warns.
153 const [oldest] = open
154 const stuck = oldest && now - oldest.startedAt >= STUCK_CALL_MS ? `⚠ one call running ${Math.floor((now - oldest.startedAt) / 60000)} min` : ''
155 return { wait, stuck }
156}
157hooks/changes.mjs 87 lines1// @ts-check
2// Ather Automata: what an edit to an intent changed, in one plain line each. Three kinds only:
3// done (an acceptance item met), yours (a decision now waiting on the person), changed (the
4// goal, the scope, or a decision taken). Notes and log entries are the detail behind these and
5// are not listed. Pure: no `$`.
6
7import { acceptanceItems, parseFindings, section, shortTitle } from './model.mjs'
8
9/** @typedef {{ kind: 'done' | 'yours' | 'changed', id: string, text: string }} Change */
10/** @typedef {'prompt.md' | 'findings.md' | 'progress.md'} IntentFile */
11
12// The acceptance items with an id, read the way the pane counts them (model.mjs acceptanceItems):
13// "A12 (proof…): Ice tracks keep their depth. More…" → { A12: { isDone, title: 'Ice tracks keep their depth' } }
14/** @param {string} prompt @param {string} progress */
15const checklist = (prompt, progress) => {
16 /** @type {Map<string, { isDone: boolean, title: string }>} */
17 const items = new Map()
18 for (const item of acceptanceItems(prompt, progress)) {
19 if (item.id === '') continue
20 const words = item.text.replace(/^\([^)]*\)\s*/, '').replace(/^[:.\-–—]\s*/, '')
21 items.set(item.id, { isDone: item.isDone, title: shortTitle(/^(.+?)[.:](\s|$)/.exec(words)?.[1] ?? words, 48) })
22 }
23 return items
24}
25
26// Items that went from open to done, as "<verb> A2 · Sand look".
27/** @param {Map<string, { isDone: boolean, title: string }>} was @param {Map<string, { isDone: boolean, title: string }>} now @param {string} verb @returns {Change[]} */
28const newlyDone = (was, now, verb) => [...now].filter(([id, item]) => item.isDone && was.get(id)?.isDone === false).map(([id, item]) => ({ kind: /** @type {const} */ ('done'), id, text: `${verb} ${id} · ${item.title}` }))
29
30const SCOPE_HEADINGS = ['Scope', 'Out of scope', 'Non-goals', 'Constraints']
31/** @param {string} text */
32const plain = text => text.replace(/\s+/g, ' ').trim()
33
34// A ticked legacy box reads "Ticked"; once progress.md has its table, prompt.md edits change no verdict.
35/** @param {string} before @param {string} after @param {string} progress @returns {Change[]} */
36const promptChanges = (before, after, progress) => {
37 const changes = newlyDone(checklist(before, progress), checklist(after, progress), 'Ticked')
38 if (before !== '' && plain(section(before, 'Goal')) !== plain(section(after, 'Goal'))) changes.push({ kind: 'changed', id: 'goal', text: 'Goal changed' })
39 if (before !== '' && SCOPE_HEADINGS.some(heading => plain(section(before, heading)) !== plain(section(after, heading)))) changes.push({ kind: 'changed', id: 'scope', text: 'Scope changed' })
40 return changes
41}
42
43/** @param {string} before @param {string} after @param {string} prompt @returns {Change[]} */
44const findingChanges = (before, after, prompt) => {
45 /** @type {Change[]} */
46 const changes = []
47 const wasOpen = new Map(parseFindings(before, prompt).map(one => [one.id, one]))
48 const isOpen = new Map(parseFindings(after, prompt).map(one => [one.id, one]))
49 for (const [id, one] of isOpen) {
50 const isNew = !new RegExp(`^##\\s+${id}\\b`, 'm').test(before)
51 if (isNew && one.isDirectorCall) changes.push({ kind: 'yours', id, text: `New decision ${id} · yours` })
52 }
53 for (const [id, one] of wasOpen) {
54 if (!isOpen.has(id) && new RegExp(`^##\\s+${id}\\b`, 'm').test(after)) changes.push({ kind: 'changed', id, text: `Decided ${id} · ${shortTitle(one.title, 40)}` })
55 }
56 return changes
57}
58
59// What one edit to an intent file changed. `intent` holds the intent's other files as they are now:
60// findings and progress rows are read against prompt.md (a decision recorded there closes its
61// finding; its ids name the rows), prompt.md against progress.md (its table says what is met).
62/** @param {IntentFile} file @param {string} before @param {string} after @param {{ prompt?: string, progress?: string }} [intent] @returns {Change[]} */
63export const intentChanges = (file, before, after, intent = {}) => {
64 if (before === after) return []
65 if (file === 'prompt.md') return promptChanges(before, after, intent.progress ?? '')
66 if (file === 'progress.md') return newlyDone(checklist(intent.prompt ?? '', before), checklist(intent.prompt ?? '', after), 'Met')
67 return findingChanges(before, after, intent.prompt ?? '')
68}
69
70// The intent file an edit touches: its folder name and which file, or null for anything else.
71/** @param {unknown} path @returns {{ slug: string, file: IntentFile } | null} */
72export const intentFileOf = path => {
73 const match = typeof path === 'string' ? /docs[\\/]intent[\\/]([^\\/]+)[\\/](prompt|findings|progress)\.md$/i.exec(path) : null
74 return match ? { slug: match[1], file: /** @type {IntentFile} */ (`${match[2].toLowerCase()}.md`) } : null
75}
76
77// The intent a session's own orchestration writes: its prompt.md or log.md (the orchestrator's files,
78// .agents/skills/intent/SKILL.md), as its folder name and which file; null for anything else.
79/** @param {unknown} path @returns {{ slug: string, file: 'prompt.md' | 'log.md' } | null} */
80export const orchestrationFileOf = path => {
81 const match = typeof path === 'string' ? /docs[\\/]intent[\\/]([^\\/]+)[\\/](prompt|log)\.md$/i.exec(path) : null
82 return match ? { slug: match[1], file: match[2].toLowerCase() === 'prompt' ? 'prompt.md' : 'log.md' } : null
83}
84
85/** @param {Change['kind']} kind */
86export const changeGlyph = kind => (kind === 'done' ? '✓' : kind === 'yours' ? '◆' : '✎')
87hooks/home.mjs 388 lines1// @ts-check
2// Ather Automata: what the console shows, and what the session is asked when
3// the person picks something. Pure: no `$`.
4
5import { windowDecisions } from './away.mjs'
6import { callId, findingAnswers, ruleAnswers, rulePrompt } from './decide.mjs'
7import { issueLabel, issuePrompt } from './issues.mjs'
8import { STAGE_LABELS, clockText, currentStage, directorCalls, durationText, intentLabel, isEvening, isMine, nextStep, ownedIntents, pickCandidates, plural } from './model.mjs'
9import { unreal } from './packs/unreal.mjs'
10import { isParkedBare, listStage, needsAttention, ownerName } from './worklist.mjs'
11
12/** @typedef {import('./packs/index.mjs').Pack} Pack */
13
14// The Unreal pack's lists and words, kept here for the modules and tests that read them from home.
15export { CREATE_GROUPS, SKILL_GROUPS, TOUR_PROMPT } from './packs/unreal.mjs'
16
17/** @typedef {import('./model.mjs').Intent} Intent */
18/** @typedef {import('./away.mjs').Away} Away */
19
20// ---------------------------------------------------------------- what the session is asked
21
22/** @param {Intent} intent @param {{ id: string }} finding */
23const callPrompt = (intent, finding) =>
24 `Walk me through decision ${finding.id} on intent ${intent.slug} (docs/intent/${intent.slug}/findings.md): what it is about, the options and your recommendation. Then ask me to choose with a question dialog, and record my answer in the intent.`
25
26/** @param {Away} away @param {readonly { id: string, question: string }[]} decisions */
27const reviewPrompt = (away, decisions) =>
28 [
29 'I am back. Walk me through the away window, one item at a time with a question dialog each.',
30 decisions.length > 0 ? `Decisions taken for me (${away.ledgerPath}): ${decisions.map(one => `${one.id} ${one.question}`).join('; ')}. For each, show the choice and why, and ask me: keep it, undo it, or talk it through; then set its Status in the ledger.` : '',
31 away.parked.length > 0 ? `Actions held while I was away: ${away.parked.map(one => `${one.id} ${one.command}`).join('; ')}. For each, ask me: run it now, or drop it.` : '',
32 ]
33 .filter(Boolean)
34 .join(' ')
35
36export const NEW_INTENT_PROMPT =
37 'Start a new intent with the intent skill (.agents/skills/intent/SKILL.md). Interview me first, one question at a time and at most three: what I want to make or change, how I will know it is done, and what it must not break. Then start it with my area and my name as Owner, and show me its prompt.md before anything is built.'
38
39export const CREATE_SHOWN = 3
40// Home's preview of teammates' intents: four rows, then "+N more ›" (D7).
41export const TEAM_SHOWN = 4
42
43/** @param {string} name */
44export const skillFolder = name => (name.includes('/') ? name : `.agents/skills/${name}`)
45
46/** @param {string} name @param {string} target */
47const skillPrompt = (name, target) =>
48 `Run the ${name.split('/').pop()} skill (${skillFolder(name)}/SKILL.md)${target ? ` ${target}` : ''}: read it, tell me in two lines what it will do here, then follow it.`
49
50/** @param {string} question @param {Pack} [pack] */
51export const askPrompt = (question, pack = unreal) => pack.prompts.ask(question)
52
53// What working on an intent in this session means, said wherever a press tracks one without a view (D5).
54/** @param {Pack} [pack] */
55export const trackConsequence = (pack = unreal) =>
56 `This session gets its next step, your ${pack.id === 'unreal' ? 'builds and PIE' : 'tests and builds'} count as its proof, other sessions see you on it; /ather untrack undoes it.`
57
58// "fluid-snow-sand-look: Build, 8/17 done, Tin Nguyen's. Also tracked in 1 other session · active 3m ago.":
59// where an intent stands, in one line, for a surface without a pane.
60/** @param {Intent} intent @param {string} stage @param {string} me @param {string} [heldBy] */
61export const intentStands = (intent, stage, me, heldBy = '') =>
62 `${intent.slug}: ${stage}, ${intent.acceptanceTotal > 0 ? `${intent.acceptanceDone}/${intent.acceptanceTotal} done` : 'no checklist yet'}, ${isMine(intent, me) ? 'yours' : intent.owner ? `${intent.owner}'s` : 'no owner named'}.${heldBy ? ` ${heldBy}.` : ''}`
63
64// What stopping tracking said: done (proof stays with the intent), nothing tracked, or refused while away.
65/** @param {{ result: 'untracked' | 'none' | 'away', slug: string }} outcome */
66export const untrackText = outcome =>
67 outcome.result === 'untracked' ? `Stopped tracking ${outcome.slug}. Its proof so far stays with the intent.` : outcome.result === 'away' ? 'End the away window first.' : 'Nothing is tracked in this session.'
68
69/** @param {readonly Item[]} items */
70export const batchPrompt = items => `Take me through these one at a time, with a question dialog for each: ${items.map((one, index) => `(${index + 1}) ${one.prompt}`).join(' ')}`
71
72// ---------------------------------------------------------------- items
73
74/**
75 * What waits on the person. Every item goes to the session with its prompt; `kind`
76 * says what else changes once it has been delivered (see settleItem in state.mjs).
77 * @typedef {{ id: string, label: string, title: string, question: string, prompt: string, detail?: string, answers?: import('./decide.mjs').Answers }} ItemText `detail`: the pane's second line under `label`; `answers`: what answers it in place (0.2.0)
78 * @typedef {ItemText & ({ kind: 'call', slug: string } | { kind: 'review' } | { kind: 'lost' } | { kind: 'editor' } | { kind: 'rule', ruleIds: string[] } | { kind: 'away-end' })} Item
79 */
80
81/**
82 * @typedef {{ id: string, label: string, hint: string, prompt: string, isDraft?: boolean, isTour?: boolean, work?: Work, action?: 'checked' }} Next
83 * Something to work on: an open intent to track, or an assigned GitHub issue to start an intent from.
84 * An intent's `owner` is its Owner line as written (people are matched on it), `who` the name shown,
85 * `stage` where it stands in the list, `source` where it was read (origin/main, or only this checkout).
86 * @typedef {{ id: string, kind: 'intent', slug: string, label: string, hint: string, isMine: boolean, area: string, owner: string, who: string, updatedAt: number,
87 * stage: import('./worklist.mjs').ListStage, done: number, total: number, source: 'main' | 'local', warn: string }
88 * | { id: string, kind: 'issue', issue: import('./issues.mjs').Issue, label: string, hint: string, prompt: string, isMine: true, area: string, updatedAt: number, stage: '' }} Work
89 * @typedef {{
90 * intents: readonly Intent[], pinned: string | null, me: string, role: string, area: string, tourDone: boolean,
91 * evidence: import('./model.mjs').Evidence, away: Away, ledger: string, lost: { paths: string[], isDisclosed: boolean } | null,
92 * lock: import('./model.mjs').EditorLock, recurring: readonly { id: string, title: string, fix: string, count: number }[],
93 * issues: readonly import('./issues.mjs').Issue[], last?: string | null, sent: readonly string[], workers: number, now: number, tz: number,
94 * skills?: readonly { name: string, description: string }[], prs?: import('./model.mjs').PrStates, week?: Week | null, pack?: Pack
95 * }} HomeInput
96 */
97
98/**
99 * This week's figures from the week-calendar plugin, when this PC runs it.
100 * @typedef {{ prsMerged: number, productive: number | null }} Week
101 */
102
103// ~/.calendar/latest.json, written by week-calendar: its figures while its week is still running.
104/** @param {string | null} text @param {number} now @returns {Week | null} */
105export const parseWeek = (text, now) => {
106 if (!text) return null
107 try {
108 const data = JSON.parse(text)
109 const start = Number(data?.week?.startMs), end = Number(data?.week?.endMs)
110 if (!(now >= start && now < end) || !data.metrics) return null
111 const productive = data.machineHours?.productiveUtilization
112 return { prsMerged: Number(data.metrics.prsMerged) || 0, productive: typeof productive === 'number' ? productive : null }
113 } catch {
114 return null
115 }
116}
117
118// "This week: 3 PRs merged · 68% productive"
119/** @param {Week | null | undefined} week */
120export const weekText = week =>
121 week ? [`This week: ${plural(week.prsMerged, 'PR')} merged`, week.productive === null ? '' : `${Math.round(week.productive * 100)}% productive`].filter(Boolean).join(' · ') : ''
122
123// What to work on, in one list: your open intents, then your GitHub issues that have no intent
124// yet (most urgent, then most recent), then teammates' intents you could follow.
125/** @param {readonly Intent[]} intents @param {readonly import('./issues.mjs').Issue[]} issues @param {string} me @param {string} area @param {number} now @param {string} [role] @param {import('./model.mjs').PrStates} [prs] @param {Pack} [pack] @returns {Work[]} */
126export const workList = (intents, issues, me, area, now, role = 'set', prs = {}, pack = unreal) => {
127 const linked = new Set(intents.map(one => one.issue).filter(Boolean))
128 const ranked = pickCandidates(intents, me, area)
129 /** @param {Intent} one @returns {Work} */
130 const toIntent = one => ({
131 id: `intent:${one.slug}`, kind: 'intent', slug: one.slug, label: one.slug, hint: intentLabel(one, me, prs), isMine: isMine(one, me), area: one.area,
132 owner: one.owner, who: ownerName(one.owner, one.firstAuthor, pack), updatedAt: one.updatedAt, stage: listStage(one, prs), done: one.acceptanceDone, total: one.acceptanceTotal, source: one.source,
133 warn: isParkedBare(one) ? 'parked, no reason' : '',
134 })
135 return [
136 ...ranked.filter(one => isMine(one, me)).map(toIntent),
137 ...issues.filter(issue => !linked.has(issue.number)).map(issue => (/** @type {Work} */ ({ id: `issue:${issue.number}`, kind: 'issue', issue, label: `#${issue.number} ${issue.name}`, hint: issueLabel(issue, now), prompt: issuePrompt(issue, me, role, pack.roleWords), isMine: true, area: issue.area, updatedAt: issue.updatedAt, stage: '' }))),
138 ...ranked.filter(one => !isMine(one, me)).map(toIntent),
139 ]
140}
141
142// ---------------------------------------------------------------- the work list: sources, search, people
143
144// Where each piece of work comes from, in the order the list shows them. `key` is workGroup's answer.
145export const WORK_GROUPS = /** @type {const} */ ([
146 { key: 'mine', title: 'Your intents' },
147 { key: 'issues', title: 'Assigned issues' },
148 { key: 'others', title: "Teammates' intents" },
149])
150
151/** @param {Work} one @returns {'mine' | 'issues' | 'others'} */
152export const workGroup = one => (one.kind === 'issue' ? 'issues' : one.isMine ? 'mine' : 'others')
153
154// The work that matches every word of `query`: in its title, area, an issue's own title, or an intent's
155// owner as written or as shown.
156/** @param {readonly Work[]} work @param {string} query */
157export const filterWork = (work, query) => {
158 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
159 return work.filter(one => {
160 const text = `${one.label} ${one.area} ${one.kind === 'issue' ? one.issue.title : `${one.owner} ${one.who}`}`.toLowerCase()
161 return words.every(word => text.includes(word))
162 })
163}
164
165// Eight colours that read on the pane's dark page: one per person, so a name is always the same colour.
166export const PEOPLE_COLOURS = ['#7aa2ff', '#ff8f6b', '#4fd1a5', '#d68cff', '#ffd166', '#5fd0e8', '#ff7eb6', '#a3d977']
167
168// A colour for each of these people: it starts at the hash of the name and steps on to the next free
169// colour when someone in the list already has it, so no two of them share one (up to eight).
170/** @param {readonly string[]} names @returns {Record<string, string>} */
171export const personColours = names => {
172 const taken = new Set()
173 /** @type {Record<string, string>} */
174 const out = {}
175 for (const name of [...new Set(names)].sort()) {
176 let hash = 0
177 for (const char of name.trim().toLowerCase()) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
178 let at = hash % PEOPLE_COLOURS.length
179 for (let tries = 0; tries < PEOPLE_COLOURS.length && taken.has(at); tries += 1) at = (at + 1) % PEOPLE_COLOURS.length
180 taken.add(at)
181 out[name] = PEOPLE_COLOURS[at] ?? '#7aa2ff'
182 }
183 return out
184}
185
186// A colour dimmed by `amount` (0.3: 30%), blended toward the page it sits on: a terminal has no opacity.
187/** @param {string} hex '#rrggbb' @param {number} amount @param {string} [backdrop] */
188export const dimColour = (hex, amount, backdrop = '#1a1b1e') => {
189 const part = (/** @type {string} */ colour, /** @type {number} */ at) => parseInt(colour.slice(1 + at * 2, 3 + at * 2), 16)
190 return `#${[0, 1, 2].map(at => Math.round(part(hex, at) * (1 - amount) + part(backdrop, at) * amount).toString(16).padStart(2, '0')).join('')}`
191}
192
193/** @param {HomeInput} input */
194export const buildHome = input => {
195 const { intents, pinned, me, evidence, away, now, tz } = input
196 const prs = input.prs ?? {}
197 const pack = input.pack ?? unreal
198 // Unset ('') until the person says it: then any role's proof counts, and the Editor is assumed not needed.
199 const role = input.role
200 const intent = intents.find(one => one.slug === pinned)
201 const owned = ownedIntents(intents, me, pinned)
202 // New until they take the tour, skip it or say their role, and while they own no intent.
203 const isNewcomer = me !== '' && !input.tourDone && input.role === '' && owned.length === 0
204 const stage = currentStage(intent, evidence, role, prs, pack)
205 const roleText = input.role ? `${pack.roleLabels[input.role] ?? input.role}` : isNewcomer ? '' : 'Role not set · /ather role'
206 const decisions = away.phase === 'off' ? [] : windowDecisions(input.ledger)
207 const lock = input.lock
208 const lockText = lock.state === 'free' ? 'Editor free' : lock.state === 'held' ? `Editor busy · ${lock.holder || 'another session'}${lock.until ? ` until ${lock.until}` : ''}` : ''
209
210 if (away.phase === 'running') {
211 const so = decisions.length + away.parked.length === 0 ? 'nothing for you yet' : `${plural(decisions.length, 'decision')} · ${away.parked.length} held`
212 /** @type {Item} */
213 const end = { kind: 'away-end', id: 'away-end', label: "I'm back: end the window", title: "End the window (I'm back)", question: `End the away window and review it (${so})`, prompt: '' }
214 const progress = away.untilDone ? 'until done' : `until ${clockText(away.wakeAt, tz)}`
215 return { actions: [], skills: [], create: [], editor: { isHeld: false, isFree: false, holder: '', until: '' }, header: { title: intent?.slug ?? 'Ather', stage: 'Away', progress, track: '', stages: [], proof: '', done: 0, total: 0, sentence: so, lock: lockText, role: roleText, week: weekText(input.week) }, items: [end], open: [end], next: undefined, work: workList(intents, input.issues, me, input.area, now, 'set', {}, pack), own: [], teamPreview: { rows: [], total: 0 }, attention: [], isNewcomer: false, offerAway: false }
216 }
217
218 /** @type {Item[]} */
219 const items = []
220 if (away.phase === 'review') {
221 const what = [decisions.length > 0 ? plural(decisions.length, 'decision') : '', away.parked.length > 0 ? plural(away.parked.length, 'held action') : ''].filter(Boolean).join(', ') || 'nothing recorded'
222 items.push({ kind: 'review', id: `review:${away.startedAt}`, label: `Review: ${what}`, title: `Review what happened while you were away (${what})`, question: `While you were away: ${what}`, prompt: reviewPrompt(away, decisions) })
223 }
224 if (input.lost && !input.lost.isDisclosed) {
225 const paths = input.lost.paths
226 const named = `${(paths[0] ?? '').split('/').pop()?.replace(/\.(uasset|umap)$/i, '') ?? ''}${paths.length > 1 ? ` and ${plural(paths.length - 1, 'more')}` : ''}`
227 items.push({ kind: 'lost', id: 'lost', label: 'See what a merge lost', title: `A merge dropped your edits to ${named}`, question: `A merge dropped your edits to ${named}`, prompt: `The last merge kept the other side of these binary assets, so this branch's edits to them are gone: ${paths.join(', ')}. List them for me, itemised, each marked as a lost optimisation or a broken feature, and propose how to re-apply each.` })
228 }
229 // A session that tracks an intent answers for that intent only: pressing another intent's call here would put it
230 // in this session's chat. With nothing tracked, every call of yours is offered, each named by its intent.
231 for (const one of intent ? owned.filter(each => each.slug === pinned) : owned) {
232 for (const finding of directorCalls(one)) {
233 items.push({ kind: 'call', slug: one.slug, id: callId(one.slug, finding.id), label: `Decide ${finding.id} on ${one.slug}`, title: `${finding.id} · ${one.slug === pinned ? '' : `${one.slug} · `}${finding.title}`, detail: finding.full, question: `${finding.id} on ${one.slug}: ${finding.title}`, prompt: callPrompt(one, finding), answers: findingAnswers(one.slug, finding) })
234 }
235 }
236 if (intent && pack.lockRoles.includes(role) && (stage === 'build' || stage === 'prove') && lock.state === 'held' && !lock.isStale) {
237 const holder = lock.holder || 'another lane'
238 items.push({ kind: 'editor', id: 'editor', label: 'Ask for the Editor', title: `Editor held by ${holder}${lock.until ? ` until ${lock.until}` : ''}: ask for a window`, question: `The Editor is held by ${holder}`, prompt: `Find the session that holds the Editor owner lock (${lock.raw}) and ask it for a short window for my next step. Wait for its answer before touching the Editor.` })
239 }
240 // Problems that keep coming back wait as one item, however many: eight rows of them buried the rest.
241 const recurring = input.recurring
242 if (recurring.length === 1) {
243 const [one] = recurring
244 items.push({
245 kind: 'rule',
246 ruleIds: [one.id],
247 id: `rule:${one.id}`,
248 label: 'Turn a repeated problem into a rule?',
249 title: `Keeps coming back: ${one.title}`,
250 detail: `"${one.title}" has come up in ${one.count} sessions.`,
251 answers: ruleAnswers([one], pack.owners),
252 question: `"${one.title}" has come up in ${one.count} sessions`,
253 prompt: rulePrompt([one], pack.owners),
254 })
255 } else if (recurring.length > 1) {
256 items.push({
257 kind: 'rule',
258 ruleIds: recurring.map(one => one.id),
259 id: `rule:${recurring.map(one => one.id).join('+')}`,
260 label: 'Turn repeated problems into rules?',
261 title: `${recurring.length} problems keep coming back: make them rules?`,
262 detail: `${recurring.length} problems have each come up in 3 or more sessions.`,
263 answers: ruleAnswers(recurring, pack.owners),
264 question: `${recurring.length} problems have each come up in 3 or more sessions`,
265 prompt: rulePrompt(recurring, pack.owners),
266 })
267 }
268 const open = items.filter(one => !input.sent.includes(one.id))
269 // Work handed to the session in this session (an issue being started) leaves the list.
270 const work = workList(intents, input.issues, me, input.area, now, role, prs, pack).filter(one => !input.sent.includes(one.id))
271 const step = nextStep(role, intent, evidence, input.workers, me, prs, pack)
272 const lastWork = work.find(one => one.kind === 'intent' && one.slug === input.last && one.isMine)
273 /** @type {Next | undefined} */
274 let next
275 const own = pack.ownCheck
276 const isLostOpen = open.some(one => one.kind === 'lost')
277 if (isLostOpen) next = undefined
278 else if (isNewcomer && !intent) next = { id: 'next:tour', label: 'Take the tour', hint: 'Six short steps. Ends with your first intent started.', prompt: pack.prompts.tour, isTour: true }
279 // A tech artist with PIE proven has one proof left that only they can give: their own Editor check.
280 else if (intent && stage === 'prove' && own && role === own.role && evidence[own.after]?.state === 'pass' && evidence[own.rung]?.state !== 'pass') next = { id: `next:${intent.slug}:checked`, label: own.label, hint: own.hint, prompt: '', action: 'checked' }
281 else if (intent && step) next = { id: `next:${intent.slug}:${step.key}`, ...step }
282 // Nothing tracked in this session: offer to continue the intent the person last worked on.
283 else if (!intent && lastWork) next = { id: lastWork.id, label: `Continue ${lastWork.label}`, hint: lastWork.hint, prompt: '', work: lastWork }
284 else if (!intent && work[0]?.kind === 'intent') next = { id: work[0].id, label: `Pick up ${work[0].slug}`, hint: work[0].hint, prompt: '', work: work[0] }
285 else if (!intent && work[0]?.kind === 'issue') next = { id: work[0].id, label: `Start issue #${work[0].issue.number}`, hint: `${work[0].issue.name} · ${work[0].hint}`, prompt: work[0].prompt, work: work[0] }
286 else if (step) next = { id: 'next:start', ...step }
287 // The quick actions under the header: start something new, or pick a skill from the short list.
288 const rest = work.filter(one => one !== next?.work)
289 const team = rest.filter(one => !one.isMine)
290 const present = new Map((input.skills ?? []).map(one => [one.name, one.description]))
291 const skills = pack.skillGroups.flatMap(({ group, names }) =>
292 names.filter(name => present.has(name)).map(name => ({ id: `skill:${name.split('/').pop()}`, group, name: name.split('/').pop() ?? name, description: present.get(name) ?? '', prompt: skillPrompt(name, intent ? `for intent ${intent.slug}` : '') })),
293 )
294 // The Editor's state decides what the session does first when making something there.
295 const editor = { isHeld: lock.state === 'held' && !lock.isStale, isFree: lock.state === 'free', holder: lock.holder, until: lock.until }
296 const order = pack.createOrder[role] ?? pack.createOrder[pack.roles[pack.roles.length - 1] ?? ''] ?? []
297 const create = [...pack.createGroups]
298 .sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group))
299 .map(({ group, items }) => ({ group, items: items.filter(item => item.isGlobal || present.has(item.name)).map(item => ({ id: `create:${item.name.split('/').pop()}`, name: item.name.split('/').pop() ?? item.name, verb: item.verb, description: present.get(item.name) ?? '', prompt: pack.createPrompt(item.verb, item.name, editor) })) }))
300 .filter(one => one.items.length > 0)
301 /** @type {{ id: string, label: string, prompt?: string, opens?: 'skills' | 'create', isPrimary?: boolean }[]} */
302 const actions = [{ id: 'action:new-intent', label: '+ New intent', prompt: NEW_INTENT_PROMPT, isPrimary: true }]
303 if (skills.length > 0) actions.push({ id: 'action:skills', label: '▶ Skills', opens: 'skills' })
304 if (create.length > 0) actions.push({ id: 'action:create', label: '✦ Create', opens: 'create' })
305 return {
306 actions,
307 skills,
308 create,
309 editor,
310 header: {
311 title: intent?.slug ?? 'Ather',
312 // A checklist with nothing done and no one working yet is planned, not being built.
313 stage: !intent ? '' : stage === 'build' && intent.acceptanceDone === 0 && !intent.hasWorker && input.workers === 0 ? 'Planned' : STAGE_LABELS[stage],
314 progress: intent && intent.acceptanceTotal > 0 ? `${intent.acceptanceDone} of ${intent.acceptanceTotal} done` : '',
315 track: intent ? stageTrack(stage) : '',
316 stages: intent ? stageList(stage) : [],
317 proof: proofText(evidence, pack),
318 done: intent?.acceptanceDone ?? 0,
319 total: intent?.acceptanceTotal ?? 0,
320 sentence: away.phase === 'review' || isNewcomer ? '' : open.length > 0 ? 'waiting on you' : input.workers > 0 ? 'agents working' : intent ? '' : 'no intent yet',
321 lock: lockText,
322 role: roleText,
323 week: weekText(input.week),
324 },
325 items,
326 open,
327 next,
328 work,
329 // With nothing tracked, the person's own other work after Next; for everyone, the team's first rows and how many (D7).
330 own: intent ? [] : rest.filter(one => one.isMine).slice(0, 5),
331 teamPreview: { rows: team.slice(0, TEAM_SHOWN), total: team.length },
332 // The person's own intents that want a press: every item met, or parked with no reason (D5).
333 attention: needsAttention(intents, me, prs).filter(one => !input.sent.includes(one.id)),
334 isNewcomer,
335 offerAway: !isNewcomer && away.phase === 'off' && (isEvening(now, tz) || (input.workers > 0 && open.length === 0)),
336 }
337}
338
339const STAGE_ORDER = ['plan', 'build', 'prove', 'ship']
340// How far along the four stages the work is; shipped, and ready to close, are past Ship.
341/** @param {string} stage */
342const stageIndex = stage => (stage === 'shipped' || stage === 'close' ? STAGE_ORDER.length : STAGE_ORDER.indexOf(stage))
343
344// Each stage with where the work is: done, now or to do; the pane colours them.
345/** @param {string} stage @returns {{ label: string, state: 'done' | 'now' | 'todo' }[]} */
346const stageList = stage => {
347 const at = stageIndex(stage)
348 return STAGE_ORDER.map((key, index) => ({ label: STAGE_LABELS[/** @type {keyof typeof STAGE_LABELS} */ (key)], state: index < at ? 'done' : index === at ? 'now' : 'todo' }))
349}
350
351// "Plan ✓ Build ✓ Prove ● Ship ○": where the work is, at a glance.
352/** @param {string} stage */
353const stageTrack = stage => {
354 const at = stageIndex(stage)
355 return STAGE_ORDER.map((key, index) => `${STAGE_LABELS[/** @type {keyof typeof STAGE_LABELS} */ (key)]} ${index < at ? '✓' : index === at ? '●' : '○'}`).join(' ')
356}
357
358// "build ✓ by session 1a2b3c4d · tests ✗": an intent's proof so far, each record another session wrote named
359// by that session (its title when known, `names`), so proof a helper produced is never taken for this one's.
360/** @param {import('./model.mjs').Evidence} evidence @param {Pack} pack @param {string} mine this session's first 8 hex @param {Readonly<Record<string, string>>} [names] */
361export const proofLine = (evidence, pack, mine, names = {}) =>
362 Object.entries(pack.proofWords)
363 .filter(([rung]) => (evidence[rung]?.state ?? 'none') !== 'none')
364 .map(([rung, word]) => {
365 const by = evidence[rung]?.by
366 const elsewhere = by && by !== mine ? ` by ${names[by] ? `"${names[by]}"` : `session ${by}`}` : ''
367 return `${word} ${evidence[rung]?.state === 'pass' ? '✓' : '✗'}${elsewhere}`
368 })
369 .join(' · ')
370
371// "Also tracked in 2 other sessions · active 4m ago": the live sessions on this checkout that track
372// the same intent, and when the latest of them last did something; '' when there are none.
373/** @param {readonly { intent: string | null, updatedAt: number, lastActiveAt?: number }[]} peers @param {string} slug @param {number} now */
374export const heldByLine = (peers, slug, now) => {
375 const same = peers.filter(lane => slug !== '' && lane.intent === slug)
376 if (same.length === 0) return ''
377 const ago = now - Math.max(...same.map(lane => Number(lane.lastActiveAt ?? lane.updatedAt) || 0))
378 return `Also tracked in ${plural(same.length, 'other session')} · ${ago < 60000 ? 'active now' : `active ${durationText(ago)} ago`}`
379}
380
381// "build ✓ · tests ✗": the proof seen so far, so a failed test is never hidden.
382/** @param {import('./model.mjs').Evidence} evidence @param {Pack} pack */
383const proofText = (evidence, pack) =>
384 Object.entries(pack.proofWords)
385 .filter(([rung]) => (evidence[rung]?.state ?? 'none') !== 'none')
386 .map(([rung, word]) => `${word} ${evidence[rung]?.state === 'pass' ? '✓' : '✗'}`)
387 .join(' · ')
388hooks/team.mjs 274 lines1// @ts-check
2// Ather Automata: the team's intents as origin/main has them, each dated by its folder's
3// last commit there, merged with the folders only this checkout has (D1-D3). Pure: git
4// and the files come through a Repo of closures built where `$` lives (console.mjs).
5// Every git call here reads, and never the working tree or the index; the fetch writes
6// only the remote-tracking ref.
7
8/**
9 * @typedef {{ exitCode: number, stdout: string, stderr?: string }} Ran
10 * @typedef {{
11 * git: (args: readonly string[], options?: { stdin?: string, timeoutMs?: number }) => Promise<Ran>,
12 * read: (path: string) => Promise<string | null>,
13 * list: (path: string) => Promise<readonly { name: string, kind: string }[]>,
14 * mtime: (path: string) => Promise<number>,
15 * }} 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
16 * @typedef {{ files: string[], at: number, firstAuthor: string, prompt: string, progress: string, findings: string }} MainFolder `at`: the folder's last commit, ms
17 * @typedef {{ sha: string, folders: Map<string, MainFolder> }} MainSnapshot what origin/main held at `sha`
18 * @typedef {{ key: string, dirty: Set<string>, committed: Set<string> }} LocalState which folders are uncommitted, and which this branch committed since main, as of `key`
19 * @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
20 * @typedef {import('./model.mjs').IntentFiles} IntentFiles
21 */
22
23// Every git call Ather makes reads without taking the index lock, and never asks for a password (D2).
24export const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
25export const MAIN = 'origin/main'
26const INTENTS = 'docs/intent'
27// The fetch (D2): origin's main into origin/main by an explicit refspec (a narrowed remote.origin.fetch
28// would not move it otherwise), no tags, no FETCH_HEAD, no submodules, and no automatic gc or
29// maintenance: a background fetch in a shared checkout must not start a repack.
30export const FETCH_ARGS = ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false', 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', 'origin', '+refs/heads/main:refs/remotes/origin/main']
31export const FETCH_EVERY_MS = 10 * 60 * 1000
32// As long as the engine lets a process run: a slow fetch is not a failed one.
33export const FETCH_TIMEOUT_MS = 10 * 60 * 1000
34export const EMPTY_CACHE = /** @type {TeamCache} */ ({ main: null, local: null })
35
36/** @param {string} text */
37const lf = text => text.replace(/\r\n/g, '\n')
38
39// The intent folder a repository path is in: "docs/intent/lead-vfx/prompt.md" → "lead-vfx".
40/** @param {string} path */
41const slugOf = path => /^docs\/intent\/([^/]+)\/./.exec(path.trim())?.[1] ?? ''
42
43// `git ls-tree -r --name-only` under docs/intent: each folder with the names of its files.
44/** @param {string} text @returns {Map<string, string[]>} */
45export const parseTree = text => {
46 /** @type {Map<string, string[]>} */
47 const folders = new Map()
48 for (const line of lf(text).split('\n')) {
49 const slug = slugOf(line)
50 if (!slug) continue
51 folders.set(slug, [...(folders.get(slug) ?? []), line.trim().slice(`${INTENTS}/${slug}/`.length)])
52 }
53 return folders
54}
55
56// `git log --format=%x00%ct%x09%an --name-only` under docs/intent, newest first: each folder's
57// last commit time (ms) and the author of its first commit.
58/** @param {string} text @returns {Map<string, { at: number, firstAuthor: string }>} */
59export const parseLog = text => {
60 /** @type {Map<string, { at: number, firstAuthor: string }>} */
61 const folders = new Map()
62 for (const record of lf(text).split('\0').slice(1)) {
63 const [head = '', ...paths] = record.split('\n')
64 const [seconds = '', author = ''] = head.split('\t')
65 const at = Number(seconds) * 1000
66 if (!Number.isFinite(at) || at <= 0) continue
67 for (const slug of new Set(paths.map(slugOf).filter(Boolean))) folders.set(slug, { at: folders.get(slug)?.at ?? at, firstAuthor: author.trim() })
68 }
69 return folders
70}
71
72// `git cat-file --batch` output, one entry per object asked for: its text, or null when missing.
73// Sizes count bytes, so the text is walked as UTF-8.
74/** @param {string} text @param {number} count @returns {(string | null)[]} */
75export const parseBatch = (text, count) => {
76 const bytes = new TextEncoder().encode(text)
77 const decoder = new TextDecoder()
78 /** @type {(string | null)[]} */
79 const out = []
80 let at = 0
81 while (out.length < count && at < bytes.length) {
82 const end = bytes.indexOf(10, at)
83 if (end < 0) break
84 const head = decoder.decode(bytes.subarray(at, end))
85 at = end + 1
86 const blob = / blob (\d+)$/.exec(head)
87 if (!blob) {
88 out.push(null)
89 continue
90 }
91 const size = Number(blob[1])
92 out.push(decoder.decode(bytes.subarray(at, at + size)))
93 at += size + 1
94 }
95 while (out.length < count) out.push(null)
96 return out
97}
98
99// `git status --porcelain=v1 -z` under docs/intent: the folders with uncommitted or untracked files.
100/** @param {string} text */
101export const parseStatus = text => new Set(text.split('\0').map(entry => slugOf(entry.replace(/^.. /, ''))).filter(Boolean))
102
103/** @param {string} prompt */
104const isCompleted = prompt => /^\s*-\s*Status:\s*completed/im.test(prompt)
105
106/** @param {Repo} repo @param {string} sha @param {readonly string[]} paths */
107const blobs = async (repo, sha, paths) => {
108 if (paths.length === 0) return []
109 const ran = await repo.git(['cat-file', '--batch'], { stdin: paths.map(path => `${sha}:${path}\n`).join('') })
110 return ran.exitCode === 0 ? parseBatch(ran.stdout, paths.length) : paths.map(() => null)
111}
112
113/** @param {string} a @param {string} b */
114const isSamePath = (a, b) => {
115 const norm = (/** @type {string} */ path) => path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
116 return norm(a) === norm(b)
117}
118
119/** @param {Repo} repo @param {string} ref */
120const shaOf = async (repo, ref) => {
121 const ran = await repo.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
122 return ran.exitCode === 0 ? ran.stdout.trim() : ''
123}
124
125// What origin/main holds under docs/intent: every folder's files, last commit and first author, its
126// prompt.md, and the progress and findings of open ones. Read again only when the ref moved:
127// `previous` is returned as it is while origin/main is still at its commit. null: no origin/main.
128/** @param {Repo} repo @param {MainSnapshot | null} previous @returns {Promise<MainSnapshot | null>} */
129export const readMain = async (repo, previous) => {
130 const sha = await shaOf(repo, MAIN)
131 if (!sha) return null
132 if (previous?.sha === sha) return previous
133 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])])
134 if (tree.exitCode !== 0 || log.exitCode !== 0) return previous
135 const files = parseTree(tree.stdout)
136 const dates = parseLog(log.stdout)
137 const slugs = [...files.keys()].filter(slug => files.get(slug)?.includes('prompt.md'))
138 const prompts = await blobs(repo, sha, slugs.map(slug => `${INTENTS}/${slug}/prompt.md`))
139 const open = slugs.filter((_, index) => !isCompleted(prompts[index] ?? ''))
140 const more = await blobs(repo, sha, open.flatMap(slug => [`${INTENTS}/${slug}/progress.md`, `${INTENTS}/${slug}/findings.md`]))
141 /** @type {Map<string, MainFolder>} */
142 const folders = new Map()
143 slugs.forEach((slug, index) => {
144 const at = open.indexOf(slug)
145 folders.set(slug, {
146 files: files.get(slug) ?? [],
147 at: dates.get(slug)?.at ?? 0,
148 firstAuthor: dates.get(slug)?.firstAuthor ?? '',
149 prompt: lf(prompts[index] ?? ''),
150 progress: at < 0 ? '' : lf(more[at * 2] ?? ''),
151 findings: at < 0 ? '' : lf(more[at * 2 + 1] ?? ''),
152 })
153 })
154 return { sha, folders }
155}
156
157// Which local folders are uncommitted, and which this branch committed since main. Both calls load
158// the whole index of a large checkout, so readTeam asks only when `key` (main, HEAD and the intent
159// files' times) moved.
160/** @param {Repo} repo @param {string} sha @param {string} key @returns {Promise<LocalState>} */
161const readLocal = async (repo, sha, key) => {
162 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])])
163 return { key, dirty: parseStatus(status.exitCode === 0 ? status.stdout : ''), committed: new Set(parseLog(ahead.exitCode === 0 ? ahead.stdout : '').keys()) }
164}
165
166// The checkout's copy of an intent main has too wins when it says something else and is newer:
167// uncommitted, or committed on this branch since main (D1). Main's progress and findings count only
168// where main kept them (for open intents).
169/** @param {{ prompt: string, progress: string, findings: string }} local @param {MainFolder} onMain @param {boolean} isNewer */
170export const localWins = (local, onMain, isNewer) =>
171 isNewer && (local.prompt !== onMain.prompt || (onMain.progress !== '' && local.progress !== onMain.progress) || (onMain.findings !== '' && local.findings !== onMain.findings))
172
173// The intents to show: origin/main's, and the checkout's own folders. A folder on both is read from
174// main unless the checkout's copy wins (localWins); the tracked one (`pinned`) always from the
175// checkout, where its session writes. Main's are dated by their last commit there, the checkout's by
176// their files (D3). `isRepo` false: not a git checkout of its own, so only its folders are read, untagged.
177/**
178 * @param {Repo} repo @param {string} root
179 * @param {{ cache: TeamCache, pinned: string | null }} options
180 * @returns {Promise<{ isRepo: boolean, cache: TeamCache, intents: IntentFiles[] }>}
181 */
182export const readTeam = async (repo, root, { cache, pinned }) => {
183 const top = await repo.git(['rev-parse', '--show-toplevel'])
184 const isRepo = top.exitCode === 0 && isSamePath(top.stdout, root)
185 const main = isRepo ? await readMain(repo, cache.main) : null
186 const folders = []
187 for (const entry of await repo.list(`${root}/${INTENTS}`).catch(() => [])) {
188 if (entry.kind !== 'dir') continue
189 const dir = `${root}/${INTENTS}/${entry.name}`
190 const prompt = await repo.read(`${dir}/prompt.md`)
191 if (prompt === null) continue
192 const isOpen = !isCompleted(prompt)
193 const [progress, findings] = await Promise.all([repo.read(`${dir}/progress.md`), repo.read(`${dir}/findings.md`)])
194 const times = await Promise.all(['prompt.md', 'progress.md', 'findings.md', 'log.md'].map(name => repo.mtime(`${dir}/${name}`)))
195 const text = { prompt: lf(prompt), progress: isOpen || entry.name === pinned ? lf(progress ?? '') : '', findings: isOpen ? lf(findings ?? '') : '' }
196 folders.push({ slug: entry.name, dir, text, times })
197 }
198 const key = main ? [main.sha, await shaOf(repo, 'HEAD'), ...folders.map(one => `${one.slug}:${one.times.join(',')}`)].join('|') : ''
199 const local = !main ? null : cache.local?.key === key ? cache.local : await readLocal(repo, main.sha, key)
200 /** @type {Map<string, IntentFiles>} */
201 const out = new Map()
202 for (const [slug, folder] of main?.folders ?? []) {
203 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 })
204 }
205 for (const { slug, dir, text, times } of folders) {
206 const onMain = main?.folders.get(slug)
207 if (onMain && slug !== pinned && !localWins(text, onMain, Boolean(local && (local.dirty.has(slug) || local.committed.has(slug))))) continue
208 const [prompt = 0, progress = 0, , log = 0] = times
209 out.set(slug, {
210 slug,
211 ...text,
212 files: slug === pinned ? (await repo.list(dir).catch(() => [])).map(one => one.name) : [],
213 hasDebrief: false,
214 updatedAt: Math.max(prompt, progress, log),
215 source: 'local',
216 firstAuthor: onMain?.firstAuthor ?? '',
217 })
218 }
219 return { isRepo, cache: { main, local }, intents: [...out.values()] }
220}
221
222/**
223 * Where the background fetch stands, for the sync line. `lock`: the git lock a failed fetch ran into.
224 * @typedef {{ isRepo: boolean, hasMain: boolean, isFetching: boolean, triedAt: number, fetchedAt: number, failedAt: number, error: string, lock: string }} Sync
225 */
226
227/** @type {Sync} */
228export const NO_SYNC = { isRepo: false, hasMain: false, isFetching: false, triedAt: 0, fetchedAt: 0, failedAt: 0, error: '', lock: '' }
229
230// A fetch may start on its own: a git checkout, none running, the last try at least ten minutes ago (or never).
231/** @param {Sync} sync @param {number} now */
232export const isFetchDue = (sync, now) => sync.isRepo && !sync.isFetching && (sync.triedAt === 0 || now - sync.triedAt >= FETCH_EVERY_MS)
233
234// ↻ fetches at once, except after a fetch that ran into a git lock: that one waits for its next due time.
235/** @param {Sync} sync @param {number} now */
236export const canFetchNow = (sync, now) => sync.isRepo && !sync.isFetching && (!sync.lock || isFetchDue(sync, now))
237
238// The lock a failed git call names ("refs/remotes/origin/main.lock", "index.lock"), inside .git; '' when none.
239/** @param {string} text */
240export const lockOf = text => {
241 const path = (/([^\s'"]+\.lock)\b/.exec(text)?.[1] ?? '').replace(/\\/g, '/')
242 return path.includes('/.git/') ? path.slice(path.lastIndexOf('/.git/') + 6) : path.split('/').pop() ?? ''
243}
244
245// Fetches origin's main. Synced means git said so and origin/main resolves after it (`moved`: it moved).
246// On a failure: git's last line of complaint, and the lock it ran into, if any.
247/** @param {Repo} repo @returns {Promise<{ error: string, lock: string, moved: boolean }>} */
248export const fetchMain = async repo => {
249 const before = await shaOf(repo, MAIN)
250 const ran = await repo.git(FETCH_ARGS, { timeoutMs: FETCH_TIMEOUT_MS })
251 const after = await shaOf(repo, MAIN)
252 if (ran.exitCode === 0 && after) return { error: '', lock: '', moved: after !== before }
253 const stderr = ran.stderr ?? ''
254 return { error: (stderr.trim().split('\n').pop() || `git fetch exited with ${ran.exitCode}`).slice(0, 200), lock: lockOf(stderr), moved: false }
255}
256
257/** @param {number} ms */
258const agoText = ms => {
259 const minutes = Math.floor(Math.max(0, ms) / 60000)
260 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
261}
262
263// "synced 4 min ago ↻": how fresh the list is. A failed fetch says so, naming the git lock it ran
264// into, and keeps the last good time.
265/** @param {Sync} sync @param {number} now */
266export const syncText = (sync, now) => {
267 if (!sync.isRepo) return ''
268 if (sync.isFetching) return 'syncing…'
269 const synced = sync.fetchedAt ? `synced ${agoText(now - sync.fetchedAt)}` : ''
270 if (sync.failedAt > sync.fetchedAt) return `${sync.lock ? `sync waits on ${sync.lock}` : 'sync failed'}${synced ? ` · ${synced}` : ''} ↻`
271 if (synced) return `${synced} ↻`
272 return sync.hasMain ? 'not synced yet ↻' : 'no origin/main ↻'
273}
274