SLOPSHOPPER

ather-automata

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…

newpanebandguardcommandtoast
v0.2.3no licenseupdated 2026-10-09AskTinNguyen/ather-mods/ather-automata
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ather-automata
│ ┃ ather ✕ › fix the failing auth test and add an audit log call │ ┃ What next? │ ┃ no intent yet ● ather-automata: Ather: git user.name could not be read; the pane tr │ ┃ Role not set · /ather role ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Yours 0 · Workers 0 · Needs you 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ + New intent ] [ ✦ Create ] ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ N E X T │ ┃ ╭──────────────────────────────────────────╮ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ n: Start an intent │ │ ┃ │ Type what you want after /intent; the │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ intent skill takes it from there. │ │ ┃ ╰──────────────────────────────────────────╯ › /ather │ ┃ ⎿ ather-automata: Asked the session to set up intents here. To add │ ┃ i: Everything open › │ ┃ │ ┃ Enter chooses · Esc closes │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ather
What next? no intent yet Role not set · /ather role Yours 0 · Workers 0 · Needs you 0 [ + New intent ] [ ✦ Create ] N E X T ╭──────────────────────────────────────────────────────────╮ │ n: Start an intent │ │ Type what you want after /intent; the intent skill │ │ takes it from there. │ ╰──────────────────────────────────────────────────────────╯ i: Everything open › Enter chooses · Esc closes
README

Ather Automata

Working the S2 way with Claude Code: what needs you, what to do next, and a safe pair of hands while you are away.

In 30 seconds

  • Type /ather whenever you wonder what to do. What needs you comes first, then the next step for your work, ready to send.
  • New here? /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.
  • Leaving? /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.

What you see

WhereWhat
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 promptOne line: what needs you, a running window, or just the name; then ☾ away, ⤢ open the pane, ✕ hide it until something is new.
Pop-upsA 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.

What to work on

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.

Going away

  • Allowed without asking: pushing branches and opening draft PRs and PRs to main.
  • Held until you review the window: merges into main (also through 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.
  • Questions the session would ask go to a decision ledger with its choice and reasons, while you are away and after the window ends, until you type anything.
  • Coming back: "I'm back" ends the window and walks you through every decision (keep, undo, talk it through) and every held action (run it now, or drop it). A new session the next morning picks up last night's window.

Proof

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.

Commands

CommandDoes
/atherWhat needs you and what is next
/ather tour, /ather skipThe 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 untrackStop 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 checkedRecord your own Editor check
/ather setupAdd 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.

Install

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.

Setting up a repository

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:

PieceWhere
The intent skill.agents/skills/intent/, also made loadable by Claude Code under .claude/skills/intent
The folder rules and the area listdocs/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 skillAGENTS.md, or CLAUDE.md when that is the repository's instruction file
  • It asks before writing. The session reads the repository, proposes the areas and the gates (the commands that count as proof), and asks you to confirm them in one question. Ather itself writes no file of the repository.
  • It never overwrites. A piece that is already there is left as it is and not named to the session; existing intents are not touched, no sample intent is made, and nothing is committed until you say so. With nothing missing, /ather setup only reports ("Intents are set up here: pack web, 4 gates, 9 areas. Nothing to add.").
  • Afterwards /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.
  • By hand. The files come from 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.

Web projects

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:

  • Which pack: the repository's .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.
  • Profile format (v1, as han-viet's 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.

  • Proof: five rungs, read from tool output only: 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).
  • Merge policy: 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.
  • Held while away: production deploys (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.
  • Traps with their fix: a port in use, POSIX env syntax in npm scripts on Windows, hydration mismatch, a stale .next or Vite cache, lockfile drift, Node version against engines, a missing NEXT_PUBLIC_*, missing Playwright browsers, a held .next lock.
  • Create offers frontend-design, run, code-review, security-review and simplify.
  • Local files: lane heartbeats, and an away ledger when no intent is tracked, go to .ather/local/ (add it to .gitignore); debriefs to docs/intent/<slug>/debrief.md.

Limits

  • Where a surface has no pane (the mobile app), /ather asks one question instead; option descriptions may not show there, so labels stand alone.
  • Your role, the tour, trap counts and the issue list are kept on your machine, not shared across the team.
  • It never merges, commits or pushes on its own. It is a safety net, not a permission system, and it does not stop tree-rewriting git in the shared checkout (deny_root_paths.py does that, outside this plugin).

For maintainers

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.

Changes

  • 0.2.3 Titles keep their characters: when the session's record is read through Windows PowerShell (no grep on the machine), emoji and dashes in a session's title came back as "?"; the search now writes and reads UTF-8.
  • 0.2.2 Set up intents in a repository. Where there is no 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.
  • 0.2.1 Questions open again. Claude Code refuses a mod's own call of the question tool (seen on 2.1.287, 2.1.289 and 2.1.295), and Ather took the refusal for a dismissal: /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.
  • 0.2.0 Decide in place. In Needs you the first decision that waits is opened: its whole question, then its options as buttons, read from the finding itself (the 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.
  • 0.1.9 Who waits on whom, and a team list that groups. Workers are drawn under the worker that started them ("started by" only when that one is not right above, "(finished)" when it is done; past three finished, "+N finished"). Each tool call is tracked while it is in flight, so a long build, PIE run or foreground worker is never shown as quiet and never raises the stuck-permission pop-up, while a call waiting on the permission dialog reads "⏳ asking permission: …" and raises the pop-up once it has waited ten minutes; a worker says what it waits on, from facts only ("⏳ waiting on <worker>", "⏳ <a shell or Monitor call's own description>" past a minute, with the Editor lock file's first line when the command names it), and "⚠ one call running N min" past 25 minutes. The heading reads "Workers · Running N · Waiting on M" (waiting on only when some are); Claude Code's pending workers read "queued", and a worker's question to you reads "asking you". In Everything open, Group (g in the terminal) sorts the teammates' intents by Person (the default), Area, Stage or None, in foldable sub-groups with counts (more than six start folded), remembered for you; each group's parked intents fold into "‖ Parked · N" at its end; a search shows "x of y" on the heads and opens the sub-groups and Parked blocks that hold its matches.
  • 0.1.8 Desktop work rows line up: the progress bar is drawn as a small SVG in exact pixels (its text glyphs took the font's widths and ran into the count, so 3/3 read as 8/3), the count and age sit at their columns' right edges with room for wider digits, and a long title is clipped in its own box instead of pushing the right-hand columns off the pane. The terminal is unchanged.
  • 0.1.7 Avatar frame: a running worker's avatar is a round badge on the pane again in the dark theme, not in a white square (its animated frame now takes the app's colour scheme).
  • 0.1.6 Ask about any intent, even one this checkout does not have yet: its view offers Ask about it (the session reads it from 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.
  • 0.1.5 The team's real state: intents are read from 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.
  • 0.1.4 The work list, by source: Everything open is three groups, each in its own colour and foldable (your intents, your assigned issues, teammates' intents), with a Search button (title, issue number, area or owner; /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.
  • 0.1.2 Track guard: looking at an intent never tracks it (rows and matching words open its view; Work on this here tracks it), Stop tracking and /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.
  • 0.1.1 Every worker counted, true clocks: the worker list shows every agent Claude Code lists (those another worker started too), a worker's clock ends at its turn's end, a worker running before Ather loaded takes its start and model from Claude Code's record, and a worker's kind comes from its description first.
  • 0.1.0 Web projects: one plugin with packs. S2's behaviour moved unchanged into the Unreal pack; a web pack (piloted on Thính, han-viet) reads .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.
  • 0.0.7 This week's figures from week-calendar (PRs merged, productive agent time) on the pane's meta line, when that plugin runs on the PC.
  • 0.0.4 ✦ Create: the skills that make content in the Unreal Editor, grouped by what is made (VFX and look, characters and animation, AI and encounters, enemies, levels and cinematics, audio), led by what they do, three per group with More for the rest, ordered by role; each asks what you want first, records an intent and respects the Editor lock.
  • 0.0.3 The intent at a glance: after a turn that changed the intent, one line above the prompt (ticked A12 · new decision F-11) with See; the Intent view lists today's changes (✓ done, ◆ yours, ✎ changed) and explains any of them on a click.
  • 0.0.2 The worker squad: each background worker with an avatar (body and colour for its kind, a ring for its state, the prop it holds for what it is doing), a trail of what finished workers did, a summary strip (checklist, workers running, decisions waiting on you) and the proof in colour.
  • 0.0.1 First shared release. The pane (terminal and desktop side panel) with what needs you, Next, your intents and assigned GitHub issues, New intent and a curated Skills list; issue cards (start an intent, open on GitHub, copy link); the band with away, open and close; away windows with held merges and a decision ledger; evidence read from tool output; the newcomer tour.
Source 27 files
hooks/ather.mjs 15 lines
1// @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}
15
hooks/watch.mjs 616 lines
1// @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}
616
hooks/console.mjs 1616 lines
1// @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 = mode
hooks/away.mjs 138 lines
1// @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}
138
hooks/guards.mjs 113 lines
1// @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
113
hooks/model.mjs 578 lines
1// @ts-check
2// Ather Automata: what the S2 workflow's files say. Intents, people, time, the
3// Editor lock, the four stages and the one next step. Pure: no `$`.
4
5import { unreal } from './packs/unreal.mjs'
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}
578
hooks/state.mjs 607 lines
1// @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}
607
hooks/workers.mjs 83 lines
1// @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)
83
hooks/inflight.mjs 157 lines
1// @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}
157
hooks/changes.mjs 87 lines
1// @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' ? '◆' : '✎')
87
hooks/home.mjs 388 lines
1// @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(' · ')
388
hooks/team.mjs 274 lines
1// @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