SLOPSHOPPER

cairn

Milestone-driven development workflow and tracking system for Claude Code projects: a language-agnostic core with per-repo toolchain profiles (R, Python…

newpanebandguardcommandtoast
★ 4v2.0.0MITupdated 2026-10-09jmgirard/cairn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cairn
│ ┃ cairn ✕ › fix the failing auth test and add an audit log call │ ┃ no cairn ROADMAP found │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cairn-pane │ ⎿ cairn: no cairn ROADMAP found │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cairn
no cairn ROADMAP found
README

cairn

A cairn is built one stone at a time, and marks the trail for whoever comes next.

A Claude Code plugin for milestone-driven development. It keeps a governed LLM Wiki for project state: the agent maintains it, you gate it. One canonical workflow covers planning, implementation, review, hotfixes, releases, and expert escalation, with all project state in plain markdown under cairn/, kept in bounds by weight caps and a self-auditing health check. Rigor scales to stakes: each milestone is classified user-facing or internal when it's planned, and the criteria audit and the review fan-out size themselves to that. The core is language-agnostic; each repo declares a toolchain profile (R, Python, Claude Code plugin, Docker image, or generic) that supplies its language-specific commands. Work lands as small stacked milestones, and any session, today's or next month's, can find the path from the files alone.

cairn grew out of maintaining many R packages with Claude Code and rebuilding similar-but-diverging tracking systems in each. This plugin centralizes the logic (skills, rules, templates) so every repo works identically; each repo holds only its own state.

Release history lives in CHANGELOG.md; design rationale in cairn/DESIGN.md and the append-only decision log it points to.

Install

Two paths; pick one. Running both installs the plugin twice, and the duplicates will confuse skill routing.

Dev install (recommended): clone and symlink into your skills directory. The plugin loads from your checkout, so git pull updates it with no re-install step:

git clone https://github.com/jmgirard/cairn
ln -s /path/to/cairn ~/.claude/skills/cairn

One caution: the symlink is live. Whatever branch the checkout has is what loads at your next session start, in every repo, enforcement hooks included. Keep the checkout on main unless you're developing cairn itself. For a one-off trial without installing anything, use claude --plugin-dir /path/to/cairn (that session only).

Marketplace install: a frozen snapshot; re-install to pick up new releases. In Claude Desktop: Customize → Plugins. From the CLI:

claude plugin marketplace add jmgirard/cairn
claude plugin install cairn@cairn

Either way, the install includes the guardrail hooks: the blocking ones (merge approval, a force-push guard on your default branch), the housekeeping ones (session-start tracking re-injection, the uncommitted-tracking stop guard), and the advisory nudges, none of which block anything you're doing. A nudge fires when an idea gets captured somewhere other than the roadmap, when something durable is headed for Claude's memory instead of your tracking files, and when a commit on your default branch reaches outside cairn/. The hooks activate at the next session start and are no-ops in repos that aren't cairn-tracked.

Then, in your package repo, run /cairn-init. Fresh repos get scaffolding; repos with an older tracking system get an interactive, PR-based migration. Run /milestone any time you're unsure where things stand.

The milestone band

The plugin also ships a Claude Code mod: a band above the prompt that shows one row for the milestone in flight, or between milestones for the next one you can start, or a row to start planning when there is no such milestone. The milestone in flight is the first in-progress row of cairn/ROADMAP.md, else its first review row. While /milestone-review runs, it is the first review row when one exists. The row gives no sign of the other active milestones. The id in bold and the milestone's title sit at the left of the row. The flow track and the percent sit at its right edge. One of the band's test fixtures gives this row when M010 is its only active milestone, shown here with the track as [track]:

M010 Nested tasks and a capital X  [track] 88%

The track is one rounded bar of three equal parts: plan, implement, then review. On a milestone row, plan is full. Implement fills by the checked boxes of ## Tasks, and it is full on a review row. Review fills by the checked boxes of ## Acceptance criteria. Specks fill the track from its left end to the edge of the current phase's fill. They start sparse and gray, and they grow dense near the edge, where more of them take the phase's color: blue or teal for plan, orange for implement, and green for review (blue in the colorblind themes). A pill on that edge names the phase and its counts, such as Implement 1/3 or Review 1/2, in the phase's color. A section with no boxes shows no tasks or no criteria in the pill. The percent of the whole flow follows the track. Each part counts for a third, and the percent rounds down. So it reads 100% only on a review row whose criteria are all checked, and a review row with no criteria reads 66%.

In the desktop app, and on any other surface that can draw an image, the track is an image. Two thin marks divide the three parts. In the current part, a tick marks each item edge past the fill when the items are 6 pixels apart or more. The image cannot follow your theme's colors, so it draws from one fixed palette meant for a light or a dark background: translucent grays, and the phase colors rgb(71,103,158) blue, rgb(152,85,57) orange, and rgb(68,113,81) green. The pill's white text has a contrast of at least 4.5:1 on its color, the WCAG 2.2 minimum for text, whatever the background. In the terminal the track is a run of braille characters on your theme's userMessageBackground color. Its dots are the specks, a dim mark divides the parts past the fill, and the pill is bold text in your theme's inverseText color on the phase's color. There the phase colors are your Claude Code theme's own keys: planMode (teal) for plan, claude (orange) for implement, and success (green) for review. The colorblind themes draw success in blue, and the ANSI themes use your terminal's own colors.

The desktop app draws the track at 7 pixels a column, up to 52 columns and at most 360 pixels. The terminal draws it at most 52 characters wide. In a narrower window the track gets shorter, so that the title keeps 10 columns, or its full width when the title is shorter. The track keeps at least 12 columns. When the whole pill would take more than a third of the track, the pill shows the counts alone, such as 1/3, or none for a section with no boxes. The band counts one column per character, so a short title of wide characters, such as CJK characters or emoji, can get less room than it draws. A title too long for the width is cut at its end, and the right part stays at the right edge.

A row whose File/Archive path names no regular file the band can read shows no milestone file in place of the track, or no file in a narrow window, in your theme's warning color. The rest of the row draws in the theme's gray. If another plugin draws a band in the same place, its rows show under cairn's.

The band draws nothing of the running cairn skill. It keeps track of the skill for two things: the row it shows during /milestone-review, and when a hidden band shows again (below). A cairn skill counts from its start, by its plain name or its cairn: name. It stays until Claude stops with no background work in flight and no one-shot wakeup pending, such as after the skill's closing summary. A question chip the skill asks you waits inside the turn, so the skill stays while you answer it. A question asked in plain text ends the turn. With no background work in flight, the skill ends with it, and otherwise your typed answer ends it. When Claude ends a turn to wait for background work, such as the reviewers that /milestone-review starts, the skill stays through the wait and through the turns that the work's notices start. A prompt you type while Claude is idle ends the skill. A prompt you type while Claude works keeps it, and so does a message from another session. An interrupt with Esc keeps it too, because an interrupted turn has no stop, so the skill stays until your next prompt or until Claude next stops with no background work in flight. A cairn skill that starts again, the same one included, or a session end, a /clear included, also ends it. A subagent that loads a cairn skill also counts, because the skill event does not say which agent loaded it.

When Claude stops while a one-shot wakeup (ScheduleWakeup) is pending, the skill stays. A prompt you type that a hook blocks or drops keeps the skill too.

The rule has three limits. A recurring scheduled task, such as a /loop with a fixed interval, does not keep the skill, so a skill that waits on one ends when Claude stops. Background work that the skill did not start, such as a server or a monitor started earlier, keeps a finished skill until your next prompt, because the band does not tell the skill's own work from other work. A one-shot wakeup that the skill did not set also keeps a finished skill, for the same reason. A /loop with no interval schedules one-shot wakeups, so it keeps a finished skill this way.

With no milestone active, the band shows an idle row for the next milestone you can start, whether or not a cairn skill runs. That milestone is the first planned row whose Depends on milestones are all done, by priority and then by id, the one cairn_next.py recommends. A dependency is done when its row is done or its file is in cairn/milestones/archive/. The idle row draws the id in bold and the title, then a track with plan full and the pill Planned:

M021 Waiting to start  [track]

After the track, an Implement button and a Status button show while no cairn skill runs and Claude is not working, where the row has room for them. After a cairn skill ends, a Clear button also shows before them. A press of Implement runs /clear and then, in the cleared conversation, /cairn:milestone-implement with the milestone's id, as if you typed both. It does not ask first, because the press is your consent to drop the conversation, as a press of Clear is. The second command starts when the session that /clear ends is over. Until then, a press of a next-step, Status, or Clear button does nothing. If the session refuses /clear, the second command does not run, and /clear goes after the text in the prompt box with a toast that says why. If the session ends for another reason first, such as a resume, the second command does not run, and its command line goes into the prompt box with a toast. The row draws no command as text, so while the buttons do not show, the row shows the id, the title, the track, and the ≡ and close buttons. In a narrow window the idle row drops its track, so that the title keeps room.

With no active milestone and no workable planned milestone, the band shows the empty row: No milestone ready in gray, with no id and no track. A planned milestone that waits on a dependency, or a blocked one, does not make a milestone workable, so the empty row shows then too. The row carries two buttons before its ≡ and close buttons. They show while no cairn skill runs and Claude is not working, in a band 37 columns wide or more. Plan runs /clear and then /cairn:milestone-plan with no arguments in the cleared conversation, as Implement does with its command. Status runs /cairn:milestone. A press runs the command as if you typed it. A second press while the first run is going does nothing. So does a press while Plan waits for its /clear to end the session. If the run is refused, the command goes after the text in the prompt box, and a toast says why. If the session refuses Plan's /clear, /clear goes there and planning does not run. If the planning run itself is refused, /cairn:milestone-plan goes there.

When a cairn skill ends, a Clear button shows on any row that carries the next-step and Status buttons, before them. A press runs /clear at once and does not ask first. It does not check that the skill's work is committed. The button goes away when you send a prompt while Claude is idle and no hook drops it, when a cairn skill starts, or when the session ends. A /clear that the session refuses goes into the prompt box, as above. Where the row has no room for three buttons, it drops Clear first.

The row ends in a close button: × in the terminal, and in the desktop app a ✕ that is dim at rest. On a row drawn from a ROADMAP, a ≡ button before it opens the cairn pane (below). Pressing the close button hides the band. Until the session ends, the band stays hidden while the list of active milestones stays the same: their ids, their statuses, and their ROADMAP order. For example, a milestone that moves from implement to review, or a milestone that becomes active or leaves both statuses, shows the band again. A cairn skill that starts or ends keeps it hidden, unless /milestone-review moves the band to another row, a review row when an in-progress row would show without it. A hidden idle row shows again when another milestone takes its place, or when no milestone is workable any more and the empty row takes its place. A hidden empty row shows again when a milestone becomes active or workable. A checked box or an edited title does not bring the band back. A session end shows a hidden band again, whatever its reason. In the desktop app, a /clear stops the session, and the band draws again at your next message. If the band finds the ROADMAP but cannot read it, or the file is empty, the band keeps its row. It keeps the row only when the row came from the same repo. The failed read alone does not hide or show it. After the session moves to another repo, a failed read shows no row. A working directory that cannot be read also shows no row. So the band never shows another repo's milestones. A press of the close button that lands as the session ends does nothing, so the band shows after the session end.

The band finds the ROADMAP in the session's working directory or the nearest directory above it. On Windows, the search stops at a network share's root (\\server\share). It reads the files when the session starts and at the end of each turn. It also reads them when a cairn skill starts or ends, and after Claude edits or writes a file under a cairn/ directory, so a box Claude checks moves the track inside a long turn. A box you check or a status you change shows after the next of these. Outside a cairn repo, it draws nothing. It also gives way while Claude Code shows a survey there. The band draws on the terminal and in the desktop app.

The cairn pane

The /cairn-pane command opens a pane that shows more than the band. Run it again to close the pane, or use the pane's own close mark. The band's ≡ button also opens it. In the desktop app the pane docks beside the conversation. In the terminal it docks beside a fullscreen conversation of 110 columns or more, and otherwise it sits above the prompt.

For each in-progress or review milestone, the pane shows the phase, id, and title, with the band's percent for the milestone at the end of that line when its file reads, and the goal from the milestone file. It lists every task and acceptance criterion, ✓ for a checked box and ○ for an open one, and the five newest work-log lines. Each item takes one line, a long item ends in …, and the goal wraps. Each section starts after a blank row with a ▎ and its name in gray capitals. The TASKS and CRITERIA headings show their count and eight squares, ■ for the checked share and □ for the rest. The next command sits in a pill. While no cairn skill runs or waits on its background work, a button after the pill reads Implement, Resume, Review, or Plan, the label of the band's next-step button, and a press runs the command as that button does, so Plan and Implement run /clear first. A press while Claude works outside a cairn skill runs the command when Claude is idle. At the same times, the Next line carries a Status button after that button, which runs /cairn:milestone. When a cairn skill finishes, the line also carries a Clear button before Status, which runs /clear. When you send a prompt while Claude is idle, or start another cairn skill, Clear goes away. Each press runs the command of the band's button of the same name. As with the next-step button, a press while Claude works outside a cairn skill runs when Claude is idle. In a narrow pane, the pill is cut first. In the desktop app, a click on the pane while it does not have keyboard focus only gives it focus. Then press the button again. The ▎ and ■ marks of a milestone draw in its phase's color, the theme's claude orange for implement and success green for review. The ▎ marks of the queue draw in the plan color, planMode, and the pill draws in the color of the phase that its command runs, with inverseText text. A high-priority candidate's ↑ draws in the implement color. The percent counts boxes as the band does, so a box inside an HTML comment counts there but not in the TASKS and CRITERIA counts, and the two can differ. A missing or unreadable milestone file shows no milestone file. Below the milestones, the pane shows the next command, the workable planned milestones, and the planned milestones that wait on others, as scripts/cairn_next.py gives them. When no milestone is in-progress or review, the pane also lists the ROADMAP's candidate rows under a CANDIDATES heading with their count. Each row takes one line: ↑ for a [high] row, · for a normal one, and ↓ for a [low] one, then the row's text up to its first : . Rows inside an HTML comment, such as the placeholders of a new ROADMAP, are not listed.

Whenever a milestone is blocked, the pane lists it under a BLOCKED heading with the count, after the queue and before the candidates, with or without an active milestone. Each line shows the id and title, and then #<n>, the number of the first GitHub pull request URL in the milestone file's Branch/PR header, before any companion: entry. In guest mode, a milestone you handed to the maintainers is blocked with its PR in that header, so the section lists the PRs that wait on review. scripts/cairn_next.py prints the same number after each "Externally blocked" line, as (PR #<n>).

The pane also shows each PR's state, which it reads from GitHub with gh pr view <url> --json state,reviewDecision. It reads the states when the pane opens, by /cairn-pane, by the band's open button, or by the reopen at a session start. It also reads them when you press Refresh on the BLOCKED heading. It never reads them on a timer or at a turn's end, so a state can be out of date until the next open or Refresh. After a read, each line shows one word after its number:

WordGitHub stateButton
mergedMERGEDFinish runs /cairn:milestone-review <id>
changes requestedOPEN, review decision CHANGES_REQUESTEDRevise runs /cairn:milestone-implement <id>
closedCLOSEDCheck runs /cairn:milestone
approvedOPEN, review decision APPROVEDnone
in reviewOPEN, any other review decision, or nonenone
unknownthe call failed, exited non-zero, or gave no known statenone

None of the Finish, Revise, or Check Buttons runs /clear first. A line not yet read shows no word.

For a PR that GitHub reports as OPEN, the same read also runs one gh api graphql query, and the pane draws a line under the PR such as 3 unresolved threads · 2 unanswered. The first count is the review threads not marked resolved. The second count is the reviews that comment or request changes, and the conversation comments. It counts only the items from anyone but the PR author. Each item must come after the author's latest comment or review, and after the PR's newest commit. That commit can be anyone's, and the time used is its committed date. GitHub does not link a reply to a review or to a conversation comment. So one comment or review by the author clears every earlier item, and so does a newer commit on the PR. Bots count as other people, so a Copilot review counts, and one Copilot review can show in both counts: as a review in the second, and as its unresolved threads in the first. Each count covers the newest 100 threads, reviews, and comments. A part that is zero is left out, and with both zero there is no line. When the query fails, the line is left out, and the state word and its Button stay.

A /hotfix run has no ROADMAP row, so the pane finds its open PR on GitHub. The same read runs one gh pr list --repo <url> --state open --author @me --limit 100 call. The URL is the base remote's: upstream in guest mode when the repo has that remote, else origin. The pane lists each PR from a hotfix- branch under a HOTFIXES heading with the count, after BLOCKED and before the candidates. Each line shows #<n> and the PR's title, and after a read the same word and count line that a blocked line shows. A hotfix line has no Button, because no command resumes an open hotfix PR yet. The heading has a Refresh of its own, which reads everything again, as the BLOCKED heading's does. When the list call fails, the pane keeps the last list it read for the same repo. The call reads at most 100 of your open PRs, so with more than that, some hotfix PRs can be left out. When the base remote has no URL, or the call lists no open hotfix PR, there is no section.

The read needs gh on the session's PATH, signed in to GitHub. The pane writes nothing to GitHub, and /milestone still sets a milestone's status.

The pane reads the files at the same moments as the band. Outside a cairn repo, with no pane open, the command opens no pane and prints no cairn ROADMAP found. A pane that is already open says the same, and the command closes it.

A cairn pane that is shown at a /clear is open after it. A press of a Clear, Plan, or Implement Button clears without restarting Claude Code, and the pane stays. A typed /clear in the desktop app starts a new session at your next message, and a pane that was shown opens again then, in the same folder. A pane behind another pane's tab, or waiting undrawn, does not open again. An app quit or a closed terminal that ends the session with reason other (not checked) also brings the pane back at the next session in that folder. In a terminal narrower than 110 columns, that pane waits undrawn until you open it. This was checked live in the desktop app only, for a typed /clear and both Clear Buttons. A Plan or Implement press clears through the same call as the Clear Buttons.

Mods are on by default from Claude Code 2.1.287 (Getting started with Claude Code mods), so that is the version the band needs with no setup. An earlier version loads the band only where hooks modules are turned on. On an older Claude Code the rest of the plugin still works. In a headless (claude -p) run of 2.1.284, the merge guard denied a merge and the session-start tracking context arrived. The band did not load, and the run printed one line that said hooks modules were not turned on in that process. That line names the early-access switch, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment.

The core loop

Development is a cycle of milestones: PR-sized units of work with explicit acceptance criteria. One command starts a run. You answer one question set when the plan is made, and Claude then implements and reviews the milestone in the same run and stops at the merge question:

flowchart LR
    idea["idea"] --> plan["/milestone-plan (question set)"]
    plan --> implement["/milestone-implement"]
    implement --> review["/milestone-review (merge question)"]
    review --> merged["merged"]
    review -->|criteria unmet| implement
    merged -->|/cl
Source 7 files
hooks/status/register.tsx 1062 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CairnBandHidden, CairnHotfixRead, CairnStep } from '../../types'
5import type { BandLine, Span } from './band'
6import { actionsFit, cairnSkill, GAP, GRAY, knownStep, mark, PX_PER_COLUMN, same, stepLines, width } from './band'
7import { countsArgv, prCounts } from './counts'
8import type { HotfixPr, PaneButton, PrRead } from './pane'
9import {
10  CHECK_LABEL,
11  CLEAR_LABEL,
12  CLEARS_FIRST,
13  FINISH_LABEL,
14  hotfixPrs,
15  nextLabel as labelOf,
16  NO_ROADMAP,
17  OPEN_WORDS,
18  paneLines,
19  PLAN_LABEL,
20  PR_BUTTON,
21  prWord,
22  REFRESH_LABEL,
23  REVISE_LABEL,
24  STATUS_LABEL,
25} from './pane'
26import type { BandState, FileSource, PaneState } from './reader'
27import { collaborationMode, join, NO_PANE, readCairn } from './reader'
28import { brailleSpans, TRACK_H, TRACK_PX, trackSvg } from './track'
29
30// The milestone band above the prompt (M191, M193 to M201, M206): one row
31// for one `in-progress` or `review` ROADMAP row, refreshed when the session
32// starts, at the end of each turn, when a cairn skill's prompt is expanded,
33// and when a step ends. The row is the bold id and the title, then the flow
34// track and its percent (band.ts picks the row and its widths). With no
35// active milestone, an idle row names the next workable planned milestone,
36// and its `Implement` Button, not a command, starts it (M220). With none
37// workable, a found ROADMAP draws the empty row (M213). A press of `Plan`
38// or `Implement` runs `/clear`, and its command at the session end that
39// the clear brings (M221).
40// The track draws as an `Svg` on the desktop and as braille cells in the
41// terminal (track.ts). The running cairn skill draws nothing of its own:
42// /milestone-review picks the row it shows. A skill's step
43// ends at the first main-loop Stop with no background work in flight and no
44// one-shot wakeup pending (M210) that no hook
45// beneath blocks, or at a prompt the operator types while the session is
46// idle, unless a cairn skill's prompt was expanded since the last prompt,
47// Stop, or turn end, as a typed cairn slash command's is. A typed prompt
48// that a hook beneath drops leaves the step (M210). So the step holds while the skill waits on background work and
49// through the turns that the work's notices start (M201). The row sits
50// above whatever the hooks beneath draw in the same slot. It ends in a
51// close button, which hides the band until the active rows' ids, statuses,
52// or order, the row /milestone-review moves the band to, or the idle row's
53// id change, or the session ends (M200, M206). A found ROADMAP that cannot
54// be read, or is empty, keeps the rows when they came from the same repo
55// root (M210), and the close state is compared against them and the
56// current step.
57//
58// The cairn pane (M205) opens from the `/cairn-pane` command, which also
59// closes it, or from the band's open button, which a row drawn from a found
60// ROADMAP carries beside the close button. It shows the active milestones
61// in full and the queue that scripts/cairn_next.py prints (pane.ts), from
62// the same refreshes as the band. A head line whose file reads ends with
63// the band's percent for its row, so the pane reads the `band` value too
64// (M208). While no cairn skill's step is set, the Next line carries a
65// Button with the band's next-step label after its pill, and a press runs
66// `pressNext`, as the band's does (M218). At the same times, the line
67// carries Status after that Button, with Clear before Status while `ended`
68// is true, and a press runs `pressStatus` or `pressClear` (M219).
69// Each open of the pane, by the command, the band's open button, or the
70// session-start reopen, reads the blocked rows' pull request states once
71// with `gh`, and so does a press of the Blocked heading's Refresh Button
72// (`readPrs`). A blocked line then shows its state word, and a merged,
73// changes-requested, or closed line carries a Button for its next step
74// (M224). The same read counts each open pull request's unresolved review
75// threads and unanswered reviews and comments, which draw on a line under
76// it (M225). The same read lists the operator's open pull requests on the
77// base remote with one `gh pr list` call and keeps the ones from
78// `hotfix-*` branches, which the pane draws, each with its word and counts
79// (M226).
80
81// Each shape tag names a value's layout; a reload whose value was written
82// under another tag reads it as absent. Bump a tag when its type changes.
83// The band's value keeps the repo root of the last read, null when no
84// ROADMAP was found or the working directory could not be read; the tag
85// moved to 4 with the root (M210).
86type StoredBand = BandState & { root: string | null }
87const BAND_REF = { plugin: 'cairn', key: 'band' } as const
88const BAND_SHAPE = 'band-4'
89const band = atom(BAND_REF, { rows: [], workable: [], root: null } as StoredBand, { shape: BAND_SHAPE })
90
91// The active ids and statuses, in ROADMAP order, `milestone-review` while
92// it moves the band to another row, and the idle row's id while no row is
93// active, at the last press of the close button; null while the band shows.
94// The tag moved to 4 when the skill's meaning changed (M206 review). The
95// press writes through `$.state.set`, which takes the tag with the value and
96// a reference written as literals in this file.
97const DISMISSED_REF = { plugin: 'cairn', key: 'dismissed' } as const
98const DISMISSED_SHAPE = 'dismissed-4'
99const dismissed = atom(DISMISSED_REF, null as CairnBandHidden | null, { shape: DISMISSED_SHAPE })
100
101// The running cairn skill, until a main-loop Stop with nothing in flight or
102// an idle typed prompt ends it (the `classic.Stop` and `prompt.submit`
103// hooks). A typed prompt that finds `expanded` set keeps it; null while no
104// cairn skill runs. The chapter it once held went in M206.
105const step = atom({ plugin: 'cairn', key: 'step' } as const, null as CairnStep | null, { shape: 'step-2' })
106
107// True from a cairn skill's prompt until the next prompt, Stop, turn end, or
108// session end.
109// The engine expands a typed slash command before it raises the command's
110// prompt, so that prompt finds this set and keeps the step the command just
111// started (M201).
112const expanded = atom({ plugin: 'cairn', key: 'expanded' } as const, false, { shape: 'expanded-1' })
113
114// True from a main-loop Stop that ends a cairn skill's step until the next
115// idle typed prompt that enters, the next cairn skill's prompt, or the
116// session's end (M216). While it is true, a row that draws the action
117// Buttons carries the Clear Button too, when it has room for it.
118const ended = atom({ plugin: 'cairn', key: 'ended' } as const, false, { shape: 'ended-1' })
119
120// What the cairn pane shows, written at each refresh (M205). The tag moved
121// to 2 when the state gained the candidate rows (M207), to 3 when it
122// gained the blocked rows (M223), and to 4 when each blocked row gained its
123// pull request's URL (M224).
124const pane = atom({ plugin: 'cairn', key: 'pane' } as const, NO_PANE as PaneState, { shape: 'pane-4' })
125
126// Each blocked row's pull request state word by its URL, written by the
127// read at a pane open or a Refresh press (M224). A URL with no entry has
128// not been read, and its line shows no word. Each entry also holds the
129// open pull request's counts, or null; the tag moved to 2 with them (M225).
130const prs = atom({ plugin: 'cairn', key: 'prs' } as const, {} as Record<string, PrRead>, { shape: 'prs-2' })
131
132// The hotfix list that the last read wrote, and the repo root it is for
133// (M226, `readHotfixes` says when it is kept or empty). The pane draws
134// them only while the band's root is that root, so a failed read after a
135// `cd` shows no other repo's.
136const hotfixes = atom({ plugin: 'cairn', key: 'hotfixes' } as const, { root: null, prs: [] } as CairnHotfixRead, {
137  shape: 'hotfixes-1',
138})
139
140// The pane's id, its title, and the command that opens and closes it.
141const PANE = 'cairn'
142const PANE_TITLE = 'cairn'
143const PANE_COMMAND = 'cairn-pane'
144// The key of the Next line's Button (M218).
145const PANE_NEXT = 'cairn-pane-next'
146// The store key that holds the project roots whose session ended with reason
147// `other` while the pane was open and shown (M222). A typed `/clear` in the
148// desktop app ends the process that way, and the next process starts with no
149// pane at the first message after the clear, so its `session.start` opens
150// the pane again. The key is `$.session.root()`, which a shell `cd` does not
151// move, so it matches the folder the next session starts in (M222 review). An app quit or a signal that ends the session with reason
152// `other` also brings the pane back at the next session in that folder. The
153// API does not say which ends those are, and none was checked live.
154const REOPEN_KEY = 'reopen'
155
156// The close button's label. The desktop app draws a dismiss Button in the
157// band as its label text, so a long label reads as text there. On the
158// desktop the label is `✕`, dim at rest, which the operator picked at a
159// live look as closest to the app's own close icon (M197).
160const CLOSE_GLYPH = '×'
161const DESKTOP_CLOSE_GLYPH = '✕'
162// The columns the row's close gap and one-glyph label take on either
163// surface, which the row leaves free when it picks its form.
164const CLOSE_COLUMNS = GAP + width(CLOSE_GLYPH)
165// The open button's one-glyph label and the space after it, which a row
166// drawn from a found ROADMAP also leaves free (M205).
167const OPEN_GLYPH = '≡'
168const OPEN_COLUMNS = width(OPEN_GLYPH) + 1
169
170// The action Buttons (M212): the next step's label, from pane.ts's
171// `nextLabel`, which the pane's Next Button reads too (M218), and the status
172// Button's command. A plugin skill runs as `cairn:<name>` (M195).
173const STATUS_COMMAND = 'cairn:milestone'
174// The built-in command the Clear Button runs (M216). The Status and Clear
175// labels sit in pane.ts, which the pane's Next line reads too (M219).
176const CLEAR_COMMAND = 'clear'
177// `CLEARS_FIRST`, the next steps whose Button runs `/clear` first (M221),
178// sits in pane.ts beside the label map.
179// The key and label of each Button after the Next line's action (M219),
180// of the Blocked heading's Refresh, and of a blocked line's Button, whose
181// key ends in its milestone id (M224).
182const PANE_BUTTONS: Record<PaneButton, { key: string; label: string }> = {
183  clear: { key: 'cairn-pane-clear', label: CLEAR_LABEL },
184  status: { key: 'cairn-pane-status', label: STATUS_LABEL },
185  refresh: { key: 'cairn-pane-refresh', label: REFRESH_LABEL },
186  finish: { key: 'cairn-pane-finish', label: FINISH_LABEL },
187  revise: { key: 'cairn-pane-revise', label: REVISE_LABEL },
188  check: { key: 'cairn-pane-check', label: CHECK_LABEL },
189}
190
191// The command each blocked line's Button runs, and whether the milestone's
192// id is its argument (M224). None runs `/clear` first, as the Review and
193// Resume Buttons keep the conversation.
194const BLOCKED_COMMANDS: Partial<Record<PaneButton, { command: string; withId: boolean }>> = {
195  finish: { command: 'cairn:milestone-review', withId: true },
196  revise: { command: 'cairn:milestone-implement', withId: true },
197  check: { command: STATUS_COMMAND, withId: false },
198}
199
200// How long one `gh pr view` or `gh api graphql` call may run, well under
201// the ten minutes `$.process.run` allows. The URLs' calls run side by side,
202// and a call still running then rejects: a `gh pr view` reads as `unknown`
203// (M224), and a count call leaves the counts null (M225). An open PR's two
204// calls run one after the other, so its read can take twice this.
205const GH_TIMEOUT_MS = 15_000
206
207export const register: Register = on => {
208  on('session.start', async ($, e, next) => {
209    const result = await next(e)
210    await refresh($)
211    // The band does not depend on the command: a refused registration
212    // leaves the band as the refresh drew it.
213    try {
214      await $.command.register({ name: PANE_COMMAND, description: 'Open or close the cairn pane' })
215    } catch {
216      // No `/cairn-pane` in this session. The band's open button still opens the pane.
217    }
218    // After the registration, so a slow open does not hold `/cairn-pane`
219    // back (M222 review).
220    await reopen($)
221    return result
222  })
223
224  // A `/clear` that stays in the same process raises no `session.start`, and
225  // the host's state starts empty under the new session id, so the band and
226  // an open pane read the files again here (M222).
227  on('classic.SessionStart', async ($, e, next) => {
228    const result = await next(e)
229    if (e.source === 'clear') await refresh($)
230    return result
231  })
232
233  // `/cairn-pane` closes an open pane, and otherwise reads the files and
234  // opens it, or says why it did not (M205 AC1). After the open, it reads
235  // the pull request states, so its line comes once they are read (M224).
236  // Only a pane the person can see is closed: one that waits undrawn, or
237  // sits behind another pane's tab, is opened again instead (M205 review).
238  // A hook that refuses the open or the close gives a line, not an error.
239  on('command.run', { command: PANE_COMMAND }, async $ => {
240    try {
241      const mine = (await $.ui.panes()).find(open => open.id === PANE)
242      if (mine !== undefined && mine.isPlaced && mine.isShown) {
243        await $.ui.close({ id: PANE })
244        return { text: 'cairn pane closed' }
245      }
246      await refresh($)
247      if (!(await read($, pane)).found) return { text: NO_ROADMAP }
248      const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
249      await readPrs($)
250      return { text: opened.isPlaced ? 'cairn pane opened' : notPlaced(opened.reason) }
251    } catch (error) {
252      return { text: `cairn pane: ${error instanceof Error ? error.message : String(error)}` }
253    }
254  })
255
256  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257    const { Box, Button, Text } = $.ui.resolve(e)
258    // The Next line's Button shows while no cairn skill's step is set
259    // (M218). A pane gets no `isWorking` prop, so it also shows during a
260    // turn outside a cairn skill, and a press then waits for the session to
261    // be idle, as `$.command.run` queues.
262    // After that Button, the Next line shows Status at the same times, and
263    // Clear before Status while `ended` is true (M219).
264    const acts = knownStep(await read($, step)) === null
265    const shown = await read($, band)
266    const held = await read($, hotfixes)
267    const listed = held.root !== null && held.root === shown.root ? held.prs : []
268    const lines = paneLines(await read($, pane), shown.rows, acts, await read($, ended), await read($, prs), listed)
269    // A line's lead and tail keep their width, and its text takes the room
270    // left between them: cut to one line with an ellipsis, or wrapped for
271    // the goal's lines. The line Box may shrink below its content's width,
272    // so a long text is cut rather than pushing the tail past the edge
273    // (M208, LESSONS M194). The action Button sits after them in a Box that
274    // does not shrink, so the pill is cut first (M218), and so do Clear and
275    // Status after it, each in its own such Box (M219). A Text drops its
276    // `key`, so each line's key sits on a Box.
277    return (
278      <Box key="cairn-pane" flexDirection="column">
279        {lines.map(line => (
280          <Box key={line.key} paddingLeft={line.indent} minWidth={0}>
281            {line.lead.length === 0 ? null : (
282              <Box key={`${line.key}-lead`} flexShrink={0}>
283                {line.lead.map(span => (
284                  <Text wrap="truncate-end" {...style(span)}>
285                    {span.text}
286                  </Text>
287                ))}
288              </Box>
289            )}
290            {line.text === null ? null : (
291              <Box key={`${line.key}-text`} flexShrink={1} minWidth={0}>
292                <Text wrap={line.wraps ? 'wrap' : 'truncate-end'} {...style(line.text)}>
293                  {line.text.text}
294                </Text>
295              </Box>
296            )}
297            {line.tail === undefined ? null : (
298              <Box key={`${line.key}-tail`} flexShrink={0}>
299                {line.tail.map(span => (
300                  <Text wrap="truncate-end" {...style(span)}>
301                    {span.text}
302                  </Text>
303                ))}
304              </Box>
305            )}
306            {line.action === undefined ? null : (
307              <Box key={`${line.key}-action`} flexShrink={0} marginLeft={1}>
308                <Button
309                  key={PANE_NEXT}
310                  variant="secondary"
311                  label={line.action}
312                  onPress={() => pressNext($, line.action)}
313                />
314              </Box>
315            )}
316            {(line.buttons ?? []).map(kind => {
317              const button = PANE_BUTTONS[kind]
318              const target = line.target
319              return (
320                <Box key={`${line.key}-${kind}`} flexShrink={0} marginLeft={1}>
321                  <Button
322                    key={target === undefined ? button.key : `${button.key}-${target}`}
323                    variant="secondary"
324                    label={button.label}
325                    onPress={() => pressPane($, kind, target)}
326                  />
327                </Box>
328              )
329            })}
330          </Box>
331        ))}
332      </Box>
333    )
334  })
335
336  // A turn end reads the tracking files again and ends no step (M201). A
337  // skill that waits on background work ends its turn, and the work's notice
338  // starts the next one, so a turn end is not the skill's end.
339  on('turn.complete', async ($, e, next) => {
340    const result = await next(e)
341    await update($, expanded, () => false)
342    await refresh($)
343    return result
344  })
345
346  // The main loop's Stop ends the running cairn skill's step when nothing is
347  // in flight: `background_tasks` is empty or absent, and no hook beneath
348  // blocked the stop (M201). A Stop with work in flight keeps the step
349  // through the wait, and so does a blocked Stop, after which the turn goes
350  // on. A Stop inside a subagent carries an `agent_id` and keeps the step.
351  // A one-shot entry in `session_crons`, as ScheduleWakeup makes, also keeps
352  // the step, so a skill that waits on a wakeup keeps it (M210). A recurring
353  // cron, as `/loop` makes, does not, since it would keep the step for good.
354  on('classic.Stop', async ($, e, next) => {
355    const result = await next(e)
356    await update($, expanded, () => false)
357    if (e.agent_id !== undefined || result?.block !== undefined) return result
358    const waking = (e.session_crons ?? []).some(cron => cron.recurring === false)
359    if ((e.background_tasks ?? []).length === 0 && !waking) {
360      if (knownStep(await read($, step)) !== null) await update($, ended, () => true)
361      await update($, step, () => null)
362      await refresh($)
363    }
364    return result
365  })
366
367  // A prompt the operator typed, at the terminal or the desktop (`composer`)
368  // or through Remote Control (`bridge`), while the session was idle (no
369  // `turnId`) ends a step that a Stop kept (M201). A background task's
370  // notice, a peer's message, or a prompt typed over a running turn keeps
371  // it. A typed cairn slash command keeps the step its own skill prompt set:
372  // the engine expands the command first, so the prompt finds `expanded`
373  // set. Were the skill prompt to run beneath instead, the step would end
374  // before `next` and the skill prompt would set the new one. So the step
375  // ends before `next`, and a prompt that a hook beneath drops puts it back,
376  // unless something set a new step meanwhile (M210).
377  on('prompt.submit', async ($, e, next) => {
378    const typed = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
379    const fresh = await read($, expanded)
380    await update($, expanded, () => false)
381    if (!typed || e.turnId !== undefined || fresh) return next(e)
382    const before = await read($, step)
383    const hiddenBefore = await read($, dismissed)
384    const endedBefore = await read($, ended)
385    await update($, step, () => null)
386    await update($, ended, () => false)
387    await refresh($)
388    const result = await next(e)
389    // The refresh above can clear a hide made against the old step, so a
390    // drop puts back the close state as well (M210 review).
391    if (result?.drop !== undefined && before !== null) {
392      await update($, step, current => current ?? before)
393      if (hiddenBefore !== null) await update($, dismissed, current => current ?? hiddenBefore)
394      await reconcile($)
395    }
396    // A drop also puts back the Clear Button that a skill's end left, unless
397    // a skill started meanwhile (M216).
398    if (result?.drop !== undefined && endedBefore && (await read($, step)) === null) {
399      await update($, ended, () => true)
400    }
401    return result
402  })
403
404  // A cairn skill's prompt starts its step, the same skill run again
405  // included. The event carries no agent id, so a subagent that loads a
406  // cairn skill starts a step too. The text passes through as is.
407  on('skill.prompt', async ($, e, next) => {
408    const result = await next(e)
409    const skill = cairnSkill(e.skill)
410    if (skill !== null) {
411      await update($, step, () => ({ skill }))
412      await update($, ended, () => false)
413      await update($, expanded, () => true)
414      await refresh($)
415    }
416    return result
417  })
418
419  // A file the session writes under a `cairn/` directory, such as a
420  // milestone file whose box was just checked, reads the files again, so the
421  // track moves inside a long turn (M206 review: the chapter hook, which
422  // M206 removed, had done this at each chapter). The tool's own call goes
423  // through first, and a refused or failed call reads nothing.
424  on('tool.call', { tool: 'Edit' }, afterWrite)
425  on('tool.call', { tool: 'Write' }, afterWrite)
426  on('tool.call', { tool: 'MultiEdit' }, afterWrite)
427
428  // Every session end, a `/clear` or a resume among them, ends the step and
429  // shows a band that a press hid, whatever its reason (M200).
430  on('session.end', async ($, e, next) => {
431    // A `Plan` or `Implement` press's held command is taken first, so a
432    // rejecting `next(e)` below cannot leave it set (M221 review).
433    const queued = heldRun
434    heldRun = null
435    // An `other` end marks this folder for a reopen at the next start
436    // (M222). A `clear` end needs nothing, as no pane closes when the clear
437    // stays in the same process.
438    if (e.reason === 'other') await markReopen($)
439    const result = await next(e)
440    await update($, step, () => null)
441    await update($, expanded, () => false)
442    await update($, dismissed, () => null)
443    await update($, ended, () => false)
444    // A `/clear` from the Clear Button ends the session, and its run may
445    // never settle, so the end frees the action Buttons (M216).
446    running = false
447    // The held command runs now when this end is its `/clear` (M221). The
448    // run is not awaited, so the session end does not wait on the command.
449    // It takes the next run number, so the `/clear` run that settles after
450    // this end does not free a press made while the command runs. Any other
451    // end drops the command, and the prompt box and a toast say so, as for
452    // a refused run (M221 review).
453    if (queued !== null) {
454      if (e.reason === 'clear') void run($, queued.command, queued.args)
455      else void fallBack($, lineOf(queued.command, queued.args), 'the session ended before /clear')
456    }
457    return result
458  })
459
460  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
461    if (e.props.hasSurvey) return next(e)
462    const state = await read($, band)
463    const { rows, workable } = state
464    const current = knownStep(await read($, step))
465    // A row drawn from a found ROADMAP carries the pane's open button
466    // (M205), and with no active or workable row it is the empty row (M213).
467    const { found: canOpen, next: nextStep } = await read($, pane)
468    if (rows.length === 0 && workable.length === 0 && !canOpen) return next(e)
469    const hidden = await read($, dismissed)
470    if (hidden !== null && same(hidden, mark(state, current))) return next(e)
471    const beneath = await next(e)
472    const { Box, Button, Text } = $.ui.resolve(e)
473    // The track draws as the `Svg` element on every surface whose table has
474    // one, the desktop app's among them (M204), and as braille cells in the
475    // terminal (M206). With the image, the row centers its parts, since the
476    // track is taller than text.
477    const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
478    const center = Svg === undefined ? {} : { alignItems: 'center' as const }
479    const close = CLOSE_COLUMNS + (canOpen ? OPEN_COLUMNS : 0)
480    // The next-step and status Buttons (M212) show while no cairn skill's
481    // step is set and no turn is working, on a row that has room for them
482    // (band.ts `actionsFit`). The track gives up columns to them; without
483    // them the row draws its track or label alone, and the idle row draws
484    // no command in their place (M220). The next step is the
485    // pane's: a milestone's step on a milestone or idle row, and planning
486    // on the empty row (M213).
487    const nextLabel = nextStep == null ? undefined : labelOf(nextStep.action)
488    const canAct = canOpen && nextLabel !== undefined && current === null && e.props.isWorking !== true
489    // Each action Button draws with its chrome, so the terminal draws it as
490    // `[ label ]`: its label and 4 columns. One space follows each. Both take
491    // the `secondary` look: an accent color beside the track's phase colors
492    // clashed at a live look.
493    // The Clear Button (M216) shows with them after a cairn skill ends, and
494    // the row drops it first when the three do not fit.
495    const actionColumns = nextLabel === undefined ? 0 : width(nextLabel) + 4 + 1 + width(STATUS_LABEL) + 4 + 1
496    const clearColumns = width(CLEAR_LABEL) + 4 + 1
497    const tries = !canAct ? [] : (await read($, ended)) ? [actionColumns + clearColumns, actionColumns] : [actionColumns]
498    let acts = false
499    let clears = false
500    let lines: BandLine[] = []
501    for (const taken of tries) {
502      const reserved = close + taken
503      const withActs = stepLines(rows, current, e.props.bodyColumns, reserved, workable, canOpen)
504      if (withActs.length > 0 && actionsFit(withActs[0], e.props.bodyColumns, reserved)) {
505        acts = true
506        clears = taken > actionColumns
507        lines = withActs
508        break
509      }
510    }
511    if (!acts) lines = stepLines(rows, current, e.props.bodyColumns, close, workable, canOpen)
512    const spans = (list: Span[]) =>
513      list.map(span => (
514        <Text wrap="truncate-end" {...style(span)}>
515          {span.text}
516        </Text>
517      ))
518    const track = (line: BandLine) => {
519      if (line.track === undefined) return null
520      if (Svg === undefined) return spans(brailleSpans(line.track.flow, line.track.columns))
521      const px = Math.min(TRACK_PX, line.track.columns * PX_PER_COLUMN)
522      return <Svg source={trackSvg(line.track.flow, px)} alt={line.track.flow.alt} width={px} height={TRACK_H} />
523    }
524
525    // Two groups: the bold id and the title on the left, where the title
526    // gives way first, and the track and the tail on the right, which keep
527    // their width. The engine cuts the title to the room that is left.
528    // A shrinking Box also takes `minWidth: 0`, and the id sits in a Box
529    // that never shrinks. Without both, the desktop app drew a long text at
530    // full width past the edge with no `…`, and the short parts beside it
531    // shrank to nothing (M194). The empty row has no id, so no head (M213).
532    const row = (line: BandLine, isFirst: boolean) => (
533      <Box key={line.key} justifyContent="space-between" {...center}>
534        <Box key={`${line.key}-left`} flexShrink={1} minWidth={0}>
535          {line.id === '' ? null : (
536            <Box key={`${line.key}-head`} flexShrink={0}>
537              <Text wrap="truncate-end" color={GRAY} bold>
538                {line.id}
539              </Text>
540              <Text wrap="truncate-end">{' '}</Text>
541            </Box>
542          )}
543          <Box key={`${line.key}-text`} flexShrink={1} minWidth={0}>
544            <Text wrap="truncate-end" color={GRAY}>
545              {line.title}
546            </Text>
547          </Box>
548        </Box>
549        <Box key={`${line.key}-right`} flexShrink={0} marginLeft={GAP} {...center}>
550          {track(line)}
551          {spans(line.tail)}
552          {isFirst ? (
553            <Text wrap="truncate-end">{' '.repeat(GAP)}</Text>
554          ) : null}
555          {isFirst && clears ? (
556            <Button key="cairn-clear" variant="secondary" label={CLEAR_LABEL} onPress={() => pressClear($)} />
557          ) : null}
558          {isFirst && clears ? <Text wrap="truncate-end">{' '}</Text> : null}
559          {isFirst && acts ? (
560            <Button
561              key="cairn-next"
562              variant="secondary"
563              label={nextLabel}
564              onPress={() => pressNext($, nextLabel)}
565            />
566          ) : null}
567          {isFirst && acts ? <Text wrap="truncate-end">{' '}</Text> : null}
568          {isFirst && acts ? (
569            <Button key="cairn-status" variant="secondary" label={STATUS_LABEL} onPress={() => pressStatus($)} />
570          ) : null}
571          {isFirst && acts ? <Text wrap="truncate-end">{' '}</Text> : null}
572          {isFirst && canOpen ? (
573            <Button
574              key="cairn-open"
575              plain
576              {...(e.surface === 'terminal' ? {} : { dimColor: true })}
577              label={OPEN_GLYPH}
578              onPress={() => openPane($)}
579            />
580          ) : null}
581          {isFirst && canOpen ? <Text wrap="truncate-end">{' '.repeat(OPEN_COLUMNS - width(OPEN_GLYPH))}</Text> : null}
582          {isFirst ? (
583            <Button
584              key="cairn-close"
585              role="dismiss"
586              plain
587              {...(e.surface === 'terminal' ? {} : { dimColor: true })}
588              label={e.surface === 'terminal' ? CLOSE_GLYPH : DESKTOP_CLOSE_GLYPH}
589              onPress={() => dismiss($)}
590            />
591          ) : null}
592        </Box>
593      </Box>
594    )
595
596    return (
597      <Box key="cairn-stack" flexDirection="column">
598        <Box key="cairn-band" flexDirection="column">
599          {/* A Text drops its `key`, so each row's key sits on a Box. */}
600          {lines.map((line, i) => row(line, i === 0))}
601        </Box>
602        {beneath ?? null}
603      </Box>
604    )
605  })
606}
607
608// After an Edit, Write, or MultiEdit call that went through, a path under a
609// `cairn/` directory reads the files again.
610async function afterWrite($, e, next) {
611  const result = await next(e)
612  if (result?.deny !== undefined || result?.isError === true) return result
613  if (typeof e.file_path === 'string' && /(^|[\\/])cairn[\\/]/.test(e.file_path)) await refresh($)
614  return result
615}
616
617// The press reads the rows and the step as they are now, not as they were
618// drawn. It writes only while the close state stands at the version it had
619// before those reads, so a session end that lands between them wins and the
620// band stays shown (M210). Any other write in between drops the press too,
621// and a second press hides the band.
622async function dismiss($) {
623  const held = await $.state.get(DISMISSED_REF)
624  const state = await read($, band)
625  const current = await read($, step)
626  await $.state.set(DISMISSED_REF, { shape: DISMISSED_SHAPE, value: mark(state, current) }, { ifVersion: held.version })
627}
628
629// A press of the band's open button opens the pane. The press is the
630// person's own act, so the surface places the pane at any width.
631// A press has no output line, so an open the surface does not place, or one
632// a hook refuses, says why in a toast. An open that does not throw reads
633// the pull request states (M224).
634async function openPane($) {
635  try {
636    const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
637    if (!opened.isPlaced) $.ui.toast(notPlaced(opened.reason))
638  } catch (error) {
639    $.ui.toast(`cairn pane: ${error instanceof Error ? error.message : String(error)}`)
640    return
641  }
642  await readPrs($)
643}
644
645// Each read of the pull request states takes the next number, and only the
646// newest read started writes its words, so an older read that settles
647// later does not put back older words (M224 review). A reload starts it
648// over at 0.
649let prReads = 0
650
651// Reads each blocked row's pull request with one `gh pr view` call per URL,
652// side by side, and writes each state word by its URL (M224). The URL
653// names the repo, so the call needs no `--repo`. A call that rejects, as
654// when `gh` cannot start or outruns its timeout, reads as `unknown`, as a
655// bad result does (pane.ts `prWord`), and the read never throws. It runs
656// only at a pane open and a Refresh press: no timer and no turn end starts
657// one, since the operator does not want repeating tasks. A read that
658// starts while another runs still runs, with the newest URLs.
659// A pull request whose word says it is open then gets one `gh api graphql`
660// call for its counts (counts.ts), with the same timeout. A count call that
661// fails leaves the counts null, so its line draws no counts, and the state
662// word stands (M225). Words and counts are written together when every call
663// has settled, so a Refresh keeps the earlier counts drawn until then.
664// Before them, `readHotfixes` lists the open hotfix pull requests, whose
665// URLs join the blocked rows' (M226). The list is written with the words.
666async function readPrs($) {
667  prReads += 1
668  const mine = prReads
669  try {
670    const urls: string[] = []
671    for (const row of (await read($, pane)).blocked) {
672      if (row.url !== null && !urls.includes(row.url)) urls.push(row.url)
673    }
674    const listed = await readHotfixes($)
675    for (const pr of listed.prs) {
676      if (!urls.includes(pr.url)) urls.push(pr.url)
677    }
678    if (urls.length === 0) {
679      if (mine === prReads) await update($, hotfixes, () => listed)
680      return
681    }
682    const reads = await Promise.all(
683      urls.map(async (url): Promise<PrRead> => {
684        let word: PrRead['word']
685        try {
686          const argv = ['gh', 'pr', 'view', url, '--json', 'state,reviewDecision']
687          word = prWord(await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS }))
688        } catch {
689          word = prWord(null)
690        }
691        const argv = countsArgv(url)
692        if (!OPEN_WORDS.includes(word) || argv === null) return { word, counts: null }
693        try {
694          return { word, counts: prCounts(await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })) }
695        } catch {
696          return { word, counts: null }
697        }
698      }),
699    )
700    const out: Record<string, PrRead> = {}
701    urls.forEach((url, i) => {
702      out[url] = reads[i]
703    })
704    // Each write checks again that no newer read started, so a newer read
705    // that settles between the two writes keeps its list (M226 review).
706    if (mine === prReads) await update($, prs, () => out)
707    if (mine === prReads) await update($, hotfixes, () => listed)
708  } catch {
709    // No write: the lines keep the words they had.
710  }
711}
712
713// The hotfix list to write, read with one `gh pr list` call on the base
714// remote of the band's repo root (M226). The base remote is the one
715// `base_remote` in hooks/cairn_common.py picks: `upstream` in guest mode
716// when `git remote` lists it, else `origin`. The call names the remote's
717// URL, which `gh` takes as `--repo` (M226 T1), and runs in the root. With
718// no root or no URL for the base remote, it runs no call, and the list is
719// empty. A call that fails keeps the last good list when it was read for
720// the same root, and is empty otherwise. A good read replaces it, an empty
721// one included.
722async function readHotfixes($): Promise<CairnHotfixRead> {
723  const root = (await read($, band)).root
724  if (root === null) return { root: null, prs: [] }
725  const url = await baseRemoteUrl($, root)
726  if (url === null) return { root, prs: [] }
727  let got: HotfixPr[] | null
728  try {
729    got = hotfixPrs(await $.process.run(listArgv(url), { cwd: root, timeoutMs: GH_TIMEOUT_MS }))
730  } catch {
731    got = null
732  }
733  if (got !== null) return { root, prs: got }
734  const held = await read($, hotfixes)
735  return held.root === root ? held : { root, prs: [] }
736}
737
738// The `gh pr list` call for the operator's open pull requests on a remote
739// (M226). A list longer than the limit is cut at it.
740const LIST_LIMIT = 100
741function listArgv(url: string): string[] {
742  return [
743    'gh',
744    'pr',
745    'list',
746    '--repo',
747    url,
748    '--state',
749    'open',
750    '--author',
751    '@me',
752    '--limit',
753    `${LIST_LIMIT}`,
754    '--json',
755    'number,title,url,headRefName',
756  ]
757}
758
759// The base remote's URL for a root, or null when `git remote get-url`
760// fails or prints nothing (M226).
761async function baseRemoteUrl($, root: string): Promise<string | null> {
762  let base = 'origin'
763  const profile = await readText($, join(root, 'cairn/PROFILE.md'))
764  if (profile !== null && collaborationMode(profile) === 'guest') {
765    const names = await git($, root, ['remote'])
766    if (names !== null && names.split(/\s+/).includes('upstream')) base = 'upstream'
767  }
768  const url = (await git($, root, ['remote', 'get-url', base]))?.trim() ?? ''
769  return url === '' ? null : url
770}
771
772// A git call's stdout in a root, or null when it rejects or exits non-zero.
773async function git($, root: string, args: string[]): Promise<string | null> {
774  try {
775    const got = await $.process.run(['git', ...args], { cwd: root, timeoutMs: GH_TIMEOUT_MS })
776    return got.exitCode === 0 ? got.stdout : null
777  } catch {
778    return null
779  }
780}
781
782// A press of a pane Button (M219, M224). Either heading's Refresh goes to
783// `pressRefresh`, whatever its target (M226).
784async function pressPane($, kind: PaneButton, target: string | undefined) {
785  if (kind === 'clear') return pressClear($)
786  if (kind === 'status') return pressStatus($)
787  if (kind === 'refresh') return pressRefresh($)
788  if (target !== undefined) return pressBlocked($, kind, target)
789}
790
791// A press of the Blocked or Hotfixes heading's Refresh reads the files,
792// then the hotfix list and the pull request states again (M224, M226).
793async function pressRefresh($) {
794  await refresh($)
795  await readPrs($)
796}
797
798// A press of a blocked line's Button runs its command as a typed command
799// would, through `run` with no `/clear` first, as the Status press does
800// (M224). It reads the row's state word as it is now, not as it was drawn,
801// and does nothing when that word no longer carries this Button, while
802// another Button's run is in flight, or while a cairn skill's step runs,
803// as the Status press does (M224 review).
804async function pressBlocked($, kind: PaneButton, id: string) {
805  if (busy() || knownStep(await read($, step)) !== null) return
806  const row = (await read($, pane)).blocked.find(each => each.id === id)
807  if (row === undefined || row.url === null) return
808  const words = await read($, prs)
809  const word = Object.prototype.hasOwnProperty.call(words, row.url) ? words[row.url].word : undefined
810  const route = BLOCKED_COMMANDS[kind]
811  if (word === undefined || PR_BUTTON[word] !== kind || route === undefined) return
812  await run($, route.command, route.withId ? id : '')
813}
814
815// True while an action Button's run is in flight, so a second press, such
816// as a double click, does not queue the command again (M212 review). A
817// reload starts it over as false. Each run takes the next number in
818// `runs`, and only the latest run clears `running` as it settles, so a run
819// from before a session end that settles late does not free a newer run's
820// press (M216 review).
821let running = false
822let runs = 0
823
824// The command a `Plan` or `Implement` press holds from the press until the
825// next session end, which runs it when the end's reason is `clear` (M221).
826// While it is set, a press does nothing, as while a run is in flight. A
827// refused `/clear` drops it. A reload starts it over as null.
828let heldRun: { command: string; args: string } | null = null
829
830// A press of the next-step Button reads the next step and the step as they
831// are now, not as they were drawn, as the close press does (M212 review). A
832// cairn skill that started since the drawing, or no next step, makes the
833// press do nothing. A Button drawn for a milestone (`Implement`, `Resume`,
834// `Review`) also does nothing when the next step no longer names one, as
835// in M212 (M213 review). `Plan` runs the next step as it is now, which is
836// planning with no arguments while nothing is workable (M213).
837// `Plan` and `Implement` run `/clear` first and hold the command for the
838// session end it brings (M221). The press is the person's consent to drop
839// the conversation, as a press of Clear is (M216), so it clears only when
840// the drawn label is the next step's label as it is now: a `Resume` or
841// `Review` drawing whose next step has since become planning or implement
842// runs the command and keeps the conversation (M221 review).
843async function pressNext($, drawn: string | undefined) {
844  if (busy() || knownStep(await read($, step)) !== null) return
845  const next = (await read($, pane)).next
846  if (next === null) return
847  if (next.id === null && drawn !== PLAN_LABEL) return
848  const command = `cairn:${next.command.slice(1)}`
849  const args = next.id ?? ''
850  if (!CLEARS_FIRST.includes(next.action) || drawn !== labelOf(next.action)) {
851    await run($, command, args)
852    return
853  }
854  const mine = { command, args }
855  heldRun = mine
856  if (!(await run($, CLEAR_COMMAND, '')) && heldRun === mine) heldRun = null
857}
858
859// True while a run is in flight or a command waits for its `/clear`.
860function busy(): boolean {
861  return running || heldRun !== null
862}
863
864async function pressStatus($) {
865  if (busy() || knownStep(await read($, step)) !== null) return
866  await run($, STATUS_COMMAND, '')
867}
868
869// A press of Clear runs the built-in `/clear` (M216), under the same checks
870// as the other action Buttons. It also reads `ended` as it is now, so a
871// press of a drawing made before a typed prompt, a skill, or a session end
872// cleared it does nothing (M216 review).
873async function pressClear($) {
874  if (busy() || knownStep(await read($, step)) !== null || !(await read($, ended))) return
875  await run($, CLEAR_COMMAND, '')
876}
877
878// An action Button's command runs as if the person typed it (M212). A run
879// that rejects puts the command line after the prompt box's draft, so the
880// person can send it, and a toast says why and names the command line, for
881// a box that could not take it. It answers false for a run that rejects.
882async function run($, command: string, args: string): Promise<boolean> {
883  running = true
884  runs += 1
885  const mine = runs
886  try {
887    await $.command.run({ command, args })
888    return true
889  } catch (error) {
890    await fallBack($, lineOf(command, args), error instanceof Error ? error.message : String(error))
891    return false
892  } finally {
893    if (runs === mine) running = false
894  }
895}
896
897// A command's line as the person would type it.
898function lineOf(command: string, args: string): string {
899  return args === '' ? `/${command}` : `/${command} ${args}`
900}
901
902// Puts a command line that did not run after the prompt box's draft, and
903// toasts why (M212). A held command that a session end dropped goes the
904// same way (M221 review).
905async function fallBack($, text: string, why: string) {
906  try {
907    await $.prompt.fill({ text, mode: 'append' })
908  } catch {
909    // A fill that rejects leaves the draft as it was; the toast still
910    // names the refusal.
911  }
912  try {
913    await $.ui.toast(`cairn: ${why} (${text})`)
914  } catch {
915    // No toast shows; the press has nothing else to say.
916  }
917}
918
919// Marks the session's folder for a reopen when the pane is open and shown
920// (M222). A store or pane call that fails marks nothing.
921async function markReopen($) {
922  try {
923    const mine = (await $.ui.panes()).find(open => open.id === PANE)
924    if (mine === undefined || !mine.isShown || !mine.isPlaced) return
925    const cwd = await $.session.root()
926    const held = await reopenList($)
927    if (!held.includes(cwd)) await $.store.set(REOPEN_KEY, [...held, cwd])
928  } catch {
929    // No mark: the next start opens no pane.
930  }
931}
932
933// At the first start in a marked folder, clears the mark and, when the
934// refresh found a ROADMAP, opens the pane (M222). A reopen that is not placed waits
935// with no toast, and a refused one gives nothing. An open reads the pull
936// request states (M224).
937async function reopen($) {
938  try {
939    const cwd = await $.session.root()
940    const held = await reopenList($)
941    if (!held.includes(cwd)) return
942    await $.store.set(REOPEN_KEY, held.filter(each => each !== cwd))
943    if (!(await read($, pane)).found) return
944    await $.ui.open({ id: PANE, title: PANE_TITLE })
945  } catch {
946    // No reopen.
947    return
948  }
949  // The reopen is an open, so it reads the pull request states (M224).
950  await readPrs($)
951}
952
953async function reopenList($): Promise<string[]> {
954  const held = await $.store.get(REOPEN_KEY)
955  return Array.isArray(held) ? held.filter((each): each is string => typeof each === 'string') : []
956}
957
958function notPlaced(reason: string): string {
959  return `cairn pane not placed: ${reason}`
960}
961
962// A change to the active ids, statuses, or order, to the running skill, or
963// to the idle row's id brings the band back, and it stays until the next
964// press. The end of a skill's step is a change to the running skill. The
965// decision reads the close state at a version and clears it only at that
966// version, so a press made while `reconcile` reads the `band` and `step`
967// values is compared, not lost, and it is kept when those reads match it
968// (M200, M210 review). A hook that changes the rows or the step before the
969// clear can still clear a press made against the new state, because the
970// comparison uses the old reads. With nothing to clear, it writes nothing
971// (M210).
972async function reconcile($) {
973  const now = mark(await read($, band), await read($, step))
974  for (let tries = 0; tries < 3; tries += 1) {
975    const held = await $.state.get(DISMISSED_REF)
976    const hidden = held.value !== undefined && held.value.shape === DISMISSED_SHAPE ? held.value.value : null
977    if (hidden === null || same(hidden, now)) return
978    const done = await $.state.set(DISMISSED_REF, { shape: DISMISSED_SHAPE, value: null }, { ifVersion: held.version })
979    if (done.isSet) return
980  }
981}
982
983// A span's style props, leaving out the ones it does not set.
984function style(span: Span) {
985  const props: { color?: string; backgroundColor?: string; bold?: boolean } = {}
986  if (span.color !== undefined) props.color = span.color
987  if (span.backgroundColor !== undefined) props.backgroundColor = span.backgroundColor
988  if (span.bold) props.bold = true
989  return props
990}
991
992// No ROADMAP found empties the band and the pane. A found ROADMAP that
993// cannot be read, or whose text is empty or only whitespace, keeps the rows
994// and the pane as they were (M200), and so does a throw while parsing it,
995// but only when the kept rows came from the same root (M210). A failed read
996// in another root, a throw from `$.session.cwd()`, and kept rows with no
997// root all empty the band and the pane, so no row from another repo shows.
998// The close state is then compared against the rows and the current step.
999async function refresh($) {
1000  let got: { root: string | null; state: { band: BandState; pane: PaneState } | null } | null
1001  try {
1002    got = await readCairn(fsSource($))
1003  } catch {
1004    got = null
1005  }
1006  const root = got === null ? null : got.root
1007  if (got !== null && got.state !== null) {
1008    const next = got.state
1009    await update($, band, () => ({ ...next.band, root }))
1010    await update($, pane, () => next.pane)
1011  } else {
1012    // The keep-or-empty decision and the write stand at one version, so a
1013    // good read that lands in between is decided again, not overwritten
1014    // (M210 review). Keeping writes nothing.
1015    for (let tries = 0; tries < 3; tries += 1) {
1016      const held = await $.state.get(BAND_REF)
1017      const kept = held.value !== undefined && held.value.shape === BAND_SHAPE ? held.value.value.root : null
1018      if (kept !== null && root !== null && kept === root) break
1019      const empty = { rows: [], workable: [], root }
1020      const done = await $.state.set(BAND_REF, { shape: BAND_SHAPE, value: empty }, { ifVersion: held.version })
1021      if (done.isSet) {
1022        await update($, pane, () => NO_PANE)
1023        break
1024      }
1025    }
1026  }
1027  await reconcile($)
1028}
1029
1030function fsSource($): FileSource {
1031  return {
1032    cwd: () => $.session.cwd(),
1033    isFile: path => isFile($, path),
1034    read: path => readText($, path),
1035    list: path => listNames($, path),
1036  }
1037}
1038
1039async function listNames($, path: string): Promise<string[] | null> {
1040  try {
1041    return (await $.fs.list(path)).map(entry => entry.name)
1042  } catch {
1043    return null
1044  }
1045}
1046
1047async function isFile($, path: string): Promise<boolean> {
1048  try {
1049    return (await $.fs.stat(path)).kind === 'file'
1050  } catch {
1051    return false
1052  }
1053}
1054
1055async function readText($, path: string): Promise<string | null> {
1056  try {
1057    return await $.fs.read(path)
1058  } catch {
1059    return null
1060  }
1061}
1062
hooks/status/band.ts 332 lines
1import type { CairnBandHidden, CairnBandMark, CairnStep } from '../../types'
2import type { BandRow, BandState, WorkableRow } from './reader'
3
4// The band's one row, as a plain description that register.tsx draws
5// (M206). It shows one active milestone: the first `review` row while
6// /milestone-review runs and one exists, else the first `in-progress` row,
7// else the first `review` row. With no active row, it shows the idle row
8// for the first workable planned milestone, whether or not a cairn skill
9// runs, and with none workable, the empty row where a ROADMAP is found
10// (M213). A milestone or idle row's left side is the bold id and the
11// title, and the empty row's is its text alone. Its right side is the
12// flow track and its percent, the idle row's track alone (M220), or a
13// warning label for a row whose counts cannot be read, and on the empty
14// row nothing but the Buttons register.tsx adds. The track draws as
15// an image on the desktop and as braille cells in the terminal (track.ts),
16// at the width the row leaves it.
17
18// One run of text and its style. The row draws in `inactive`, the theme's
19// gray, but for the warning labels and the terminal track's cells.
20export type Span = { text: string; color?: string; backgroundColor?: string; bold?: boolean }
21
22export const GRAY = 'inactive'
23
24// The track and the columns it takes: an image of about PX_PER_COLUMN
25// pixels a column on the desktop, one braille cell a column in the
26// terminal.
27export type TrackPart = { flow: Flow; columns: number }
28
29// `track`, when set, draws before the tail.
30export type BandLine = { key: string; id: string; title: string; tail: Span[]; track?: TrackPart }
31
32type Phase = { noun: string }
33
34const PHASES: Record<string, Phase> = {
35  'in-progress': { noun: 'tasks' },
36  review: { noun: 'criteria' },
37}
38
39// The columns between the left and the right group.
40export const GAP = 2
41// The most columns a row keeps for its title when it picks a form: a
42// shorter title needs only its own width.
43export const TEXT_ROOM = 10
44// The most and least columns the track takes. The desktop counts the full
45// TRACK_PX (360) image as TRACK_COLUMNS, at about PX_PER_COLUMN pixels a
46// column (M204). On a milestone row, below MIN_TRACK_COLUMNS the track
47// keeps that width and the title takes less room. The idle row drops its
48// track instead.
49export const TRACK_COLUMNS = 52
50export const MIN_TRACK_COLUMNS = 12
51export const PX_PER_COLUMN = 7
52
53// The cairn skills, by their directories under `skills/`. fixtures.gen.ts
54// lists those directories, and a test holds this list to that one. A
55// running skill shows nothing of its own (M206): /milestone-review picks the
56// row, and a skill's start or end can show a band hidden over a milestone
57// row.
58export const CAIRN_SKILLS: readonly string[] = [
59  'milestone-plan',
60  'milestone-implement',
61  'milestone-review',
62  'hotfix',
63  'cairn-triage',
64  'cairn-release',
65  'milestone',
66  'milestone-brief',
67  'design-interview',
68  'cairn-init',
69]
70
71// A skill's bare name when it is a cairn skill, from its bare name or its
72// `cairn:` name; null for any other skill.
73export function cairnSkill(name: string): string | null {
74  const bare = name.startsWith('cairn:') ? name.slice('cairn:'.length) : name
75  return CAIRN_SKILLS.includes(bare) ? bare : null
76}
77
78// The step, or null when its skill is not a cairn skill, as a step stored
79// before a reload can name a skill the list has since dropped.
80export function knownStep(step: CairnStep | null): CairnStep | null {
81  return step !== null && CAIRN_SKILLS.includes(step.skill) ? step : null
82}
83
84// What the close button stores at a press, and what a refresh and the
85// drawing compare it with: the ids and statuses of the active rows, in
86// ROADMAP order, `milestone-review` while that skill runs and moves the band
87// off the row it shows with no skill, and the idle row's id while no row is
88// active (M206). The step is read through knownStep, as the drawing reads it
89// (M200). The workable list is left out while a row is active, so a planned
90// row added then keeps the band hidden too. Any other skill draws nothing,
91// so its start or end keeps a hidden band hidden (M206 review).
92export function mark(state: BandState, step: CairnStep | null): CairnBandHidden {
93  const current = knownStep(step)
94  const marks: CairnBandMark[] = state.rows.map(row => ({ id: row.id, status: row.status }))
95  const active = state.rows.length > 0
96  const first = (status: string) => state.rows.find(row => row.status === status)
97  const plain = first('in-progress') ?? first('review')
98  const reviewed = current?.skill === 'milestone-review' ? first('review') : undefined
99  return {
100    marks,
101    skill: reviewed !== undefined && reviewed !== plain ? 'milestone-review' : null,
102    idle: active ? null : (state.workable[0]?.id ?? null),
103  }
104}
105
106export function same(a: CairnBandHidden, b: CairnBandHidden): boolean {
107  return (
108    a.skill === b.skill &&
109    a.idle === b.idle &&
110    a.marks.length === b.marks.length &&
111    a.marks.every((m, i) => m.id === b.marks[i].id && m.status === b.marks[i].status)
112  )
113}
114
115export function phaseOf(row: BandRow): Phase {
116  return PHASES[row.status]
117}
118
119// The flow track (M204): one flow of three equal segments, plan, then
120// implement, then review. track.ts draws it; the model is here.
121export type FlowPhase = 'plan' | 'implement' | 'review'
122export const FLOW_PHASES: readonly FlowPhase[] = ['plan', 'implement', 'review']
123// Each phase's color as a Claude Code theme key (M217), so it changes with
124// the theme: `planMode` for plan, `claude` for implement, and `success` for
125// review. The light theme changes plan and review, and the colorblind and
126// ANSI themes change all three. The desktop track is an image and
127// cannot read theme keys, so it draws from its own palette (track.ts).
128export const FLOW_COLORS: Record<FlowPhase, string> = { plan: 'planMode', implement: 'claude', review: 'success' }
129
130// A segment's fill as a whole-number fraction, so the percent needs no
131// floating point.
132export type Fill = { num: number; den: number }
133
134export type Flow = {
135  // The phase the pill sits on and takes its color from.
136  phase: FlowPhase
137  // Plan, implement, review.
138  fills: [Fill, Fill, Fill]
139  // The items in the implement and review segments. The active segment's
140  // count sets its ticks; 0 draws none.
141  items: [number, number]
142  pill: string
143  // The pill's text on a track too short for `pill`: the count alone,
144  // `none` for a section with no items, or `Planned` on the idle flow
145  // (M206).
146  short: string
147  // The flow's percent, or null on a row that shows none.
148  percent: number | null
149  // What the drawing says, for a reader that cannot see it.
150  alt: string
151}
152
153const FULL: Fill = { num: 1, den: 1 }
154const NONE: Fill = { num: 0, den: 1 }
155
156// A section of zero items is empty.
157function share(checked: number, total: number): Fill {
158  return total === 0 ? NONE : { num: checked, den: total }
159}
160
161// floor(100 × (sum of the fills) / 3), in whole numbers.
162export function percentOf(fills: readonly Fill[]): number {
163  const den = fills.reduce((d, f) => d * f.den, 1)
164  const num = fills.reduce((n, f) => n + f.num * (den / f.den), 0)
165  const top = 100 * num
166  return (top - (top % (3 * den))) / (3 * den)
167}
168
169// A milestone row's flow, or null when its counts cannot be read. Plan is
170// full. An `in-progress` row fills implement by its tasks and leaves review
171// empty; a `review` row fills implement and fills review by its criteria.
172export function flowOf(row: BandRow): Flow | null {
173  const { tasksChecked, tasksTotal, criteriaChecked, criteriaTotal } = row
174  if (tasksChecked === null || tasksTotal === null || criteriaChecked === null || criteriaTotal === null) return null
175  const isTasks = phaseOf(row).noun === 'tasks'
176  const fills: [Fill, Fill, Fill] = isTasks
177    ? [FULL, share(tasksChecked, tasksTotal), NONE]
178    : [FULL, FULL, share(criteriaChecked, criteriaTotal)]
179  const [checked, total, noun] = isTasks ? [tasksChecked, tasksTotal, 'tasks'] : [criteriaChecked, criteriaTotal, 'criteria']
180  const phase: FlowPhase = isTasks ? 'implement' : 'review'
181  const name = isTasks ? 'Implement' : 'Review'
182  const percent = percentOf(fills)
183  const counts = total === 0 ? `no ${noun}` : `${checked}/${total} ${noun}`
184  return {
185    phase,
186    fills,
187    items: [tasksTotal, criteriaTotal],
188    pill: total === 0 ? `no ${noun}` : `${name} ${checked}/${total}`,
189    short: total === 0 ? 'none' : `${checked}/${total}`,
190    percent,
191    alt: `${phase} ${counts}, ${percent}% through plan, implement, review`,
192  }
193}
194
195// The idle row's flow: plan full, the rest empty, no percent.
196export function idleFlow(): Flow {
197  return {
198    phase: 'plan',
199    fills: [FULL, NONE, NONE],
200    items: [0, 0],
201    pill: 'Planned',
202    short: 'Planned',
203    percent: null,
204    alt: 'plan done, implement next',
205  }
206}
207
208// Columns at one per code point: true for ASCII and the band's own glyphs.
209// A wide character in a title, such as a CJK character or an emoji, is
210// undercounted.
211export function width(text: string): number {
212  return [...text].length
213}
214
215const spansWidth = (spans: Span[]) => spans.reduce((n, span) => n + width(span.text), 0)
216
217// The columns of the left group's part that does not shrink: the id and
218// the space after it, or none on the empty row, which has no id.
219function headWidth(id: string): number {
220  return id === '' ? 0 : width(id) + 1
221}
222
223// The columns the title needs to pick a form: its full width, or TEXT_ROOM
224// when that is less.
225function textNeed(title: string): number {
226  return Math.min(width(title), TEXT_ROOM)
227}
228
229// The columns a row leaves the right group, less `tail` and the title's
230// need. `close` is the columns the row's close gap and buttons take.
231function room(columns: number, close: number, line: { id: string; title: string }, tail: Span[]): number {
232  return columns - headWidth(line.id) - textNeed(line.title) - GAP - spansWidth(tail) - close
233}
234
235// The track's columns in that room: at most TRACK_COLUMNS, and at least
236// MIN_TRACK_COLUMNS.
237function trackColumns(left: number): number {
238  return Math.max(MIN_TRACK_COLUMNS, Math.min(TRACK_COLUMNS, left))
239}
240
241// The space between the track and what follows it.
242const SPACE: Span = { text: ' ' }
243
244// The idle row: the bold id and the title, and on the right the track
245// (plan full). Where the row leaves the track too little room, the track
246// goes. The row draws no command: the `Implement` Button register.tsx adds
247// starts the milestone (M220).
248export function idleLines(next: WorkableRow, columns: number, close = 0): BandLine[] {
249  const line = { key: 'idle-row', id: next.id, title: next.title }
250  const left = room(columns, close, line, [])
251  if (left >= MIN_TRACK_COLUMNS) {
252    return [{ ...line, tail: [], track: { flow: idleFlow(), columns: trackColumns(left) } }]
253  }
254  return [{ ...line, tail: [] }]
255}
256
257// The empty row's key and text (M213).
258export const EMPTY_KEY = 'plan-row'
259export const EMPTY_TEXT = 'No milestone ready'
260
261// The empty row (M213), for a found ROADMAP with no active and no workable
262// row: no id, the text, and no track or tail. Its right side is the
263// Buttons register.tsx adds.
264export function emptyLines(): BandLine[] {
265  return [{ key: EMPTY_KEY, id: '', title: EMPTY_TEXT, tail: [] }]
266}
267
268// The band's one row, or none: the first `review` row under
269// /milestone-review, else the first `in-progress` row, else the first
270// `review` row; with no active row, an idle row for the first workable
271// planned row, else the empty row where `found` says a ROADMAP was found,
272// else nothing. `rows` are the active rows in ROADMAP order, and `workable`
273// the workable planned rows in their order. `close` is the columns the
274// close gap and buttons take.
275export function stepLines(
276  rows: BandRow[],
277  step: CairnStep | null,
278  columns: number,
279  close = 0,
280  workable: WorkableRow[] = [],
281  found = false,
282): BandLine[] {
283  const first = (status: string) => rows.find(row => row.status === status)
284  const reviewed = step?.skill === 'milestone-review' ? first('review') : undefined
285  const row = reviewed ?? first('in-progress') ?? first('review')
286  if (row !== undefined) return bandLines(row, columns, close)
287  if (workable.length > 0) return idleLines(workable[0], columns, close)
288  return found ? emptyLines() : []
289}
290
291// One milestone's row: the track and its percent, or a warning label when
292// its counts cannot be read. The track keeps its least width even where
293// that leaves the title less than its room.
294export function bandLines(row: BandRow, columns: number, close = 0): BandLine[] {
295  const line = { key: `${row.id}-row`, id: row.id, title: row.title }
296  const flow = flowOf(row)
297  if (flow === null) {
298    const long: Span[] = [{ text: LONG_WARNING, color: 'warning' }]
299    const short: Span[] = [{ text: 'no file', color: 'warning' }]
300    return [{ ...line, tail: room(columns, close, line, long) >= 0 ? long : short }]
301  }
302  const tail: Span[] = [SPACE, { text: `${flow.percent}%`, color: GRAY }]
303  return [{ ...line, tail, track: { flow, columns: trackColumns(room(columns, close, line, tail)) } }]
304}
305
306// The warning label of a row whose counts cannot be read, at its full width.
307const LONG_WARNING = 'no milestone file'
308
309// Whether a line drawn with `reserved` columns taken for the close gap and
310// all the Buttons has room for the action Buttons (M212): the line has its
311// track, or its long warning label, or is the empty row (M213), and the
312// title keeps its room. The track gives up its columns to the Buttons down
313// to MIN_TRACK_COLUMNS. A line that has its track or its long label keeps it
314// at every wider band, and the title's room then stays at its need or grows,
315// so the widths that show the Buttons are one run up to any wider band.
316export function actionsFit(line: BandLine, columns: number, reserved: number): boolean {
317  const full = line.track !== undefined || line.tail[0]?.text === LONG_WARNING || line.key === EMPTY_KEY
318  if (!full) return false
319  const taken = headWidth(line.id) + GAP + spansWidth(line.tail) + (line.track?.columns ?? 0) + reserved
320  return columns - taken >= textNeed(line.title)
321}
322
323// A line's text with the two groups GAP spaces apart (the least gap a row
324// draws; a wide row spreads them further), for the tests. The track draws
325// as `[track N]`, N its columns.
326export function lineText(line: BandLine): string {
327  const text = (spans: Span[]) => spans.map(s => s.text).join('')
328  const track = line.track === undefined ? '' : `[track ${line.track.columns}]`
329  const head = line.id === '' ? '' : `${line.id} `
330  return `${head}${line.title}${' '.repeat(GAP)}${track}${text(line.tail)}`.trimEnd()
331}
332
hooks/status/counts.ts 151 lines
1// The two counts under an open handed-off pull request's blocked line
2// (M225): its unresolved review threads, and the reviews and conversation
3// comments from others that are newer than the anchor. The anchor is the
4// latest of the author's newest submitted review, the author's newest
5// conversation comment, and the committed date of the pull request's newest
6// commit, whoever made it. GitHub links no reply to a review or a
7// conversation comment, so the anchor stands in for "answered": the
8// author's comment or review, or a newer commit, clears every earlier item.
9// Bots are others, so a Copilot review counts, and its threads count too. Each kind is read `last: 100`, so the
10// counts cover the newest 100 of each.
11
12import type { CairnPrRead } from '../../types'
13
14// The counts as the state contract holds them.
15export type PrCounts = NonNullable<CairnPrRead['counts']>
16
17// One review, conversation comment, and thread as the query returns them.
18// A null author is a deleted account, read as another person.
19type Author = { login: string } | null
20type Review = { author: Author; state: string; submittedAt: string | null }
21type Comment = { author: Author; createdAt: string }
22export type PrNodes = {
23  author: string
24  threads: { isResolved: boolean }[]
25  reviews: Review[]
26  comments: Comment[]
27  commit: string | null
28}
29
30// The query `gh api graphql` runs for one pull request.
31export const COUNTS_QUERY =
32  'query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { ' +
33  'author { login } reviewThreads(last: 100) { nodes { isResolved } } ' +
34  'reviews(last: 100) { nodes { author { login } state submittedAt } } ' +
35  'comments(last: 100) { nodes { author { login } createdAt } } ' +
36  'commits(last: 1) { nodes { commit { committedDate } } } } } }'
37
38// The URL `prUrl` in reader.ts keeps: owner, repo, and number.
39const URL = /^https:\/\/github\.com\/([^/ \t]+)\/([^/ \t]+)\/pull\/([0-9]{1,15})$/
40
41// The `gh api graphql` argv for a pull request URL, null for a URL of
42// another form. The owner and repo go as strings (`-f`) and the number as
43// an Int (`-F`), so an owner named like a number stays a string.
44export function countsArgv(url: string): string[] | null {
45  const match = URL.exec(url)
46  if (match === null) return null
47  const [, owner, repo, number] = match
48  return ['gh', 'api', 'graphql', '-f', `query=${COUNTS_QUERY}`, '-f', `owner=${owner}`, '-f', `repo=${repo}`, '-F', `number=${number}`]
49}
50
51// The review states that count and that set the anchor. APPROVED,
52// DISMISSED, and PENDING do neither.
53const COUNTED = ['COMMENTED', 'CHANGES_REQUESTED']
54
55// Both counts from the returned nodes. Times are GitHub's ISO-8601 UTC
56// strings, compared as text, and an item counts only when strictly later
57// than the anchor. A review with no `submittedAt` neither counts nor sets
58// the anchor.
59export function countPr(pr: PrNodes): PrCounts {
60  const mine = (author: Author) => author !== null && author.login === pr.author
61  const reviews = pr.reviews.filter(r => COUNTED.includes(r.state) && r.submittedAt !== null)
62  let anchor = pr.commit ?? ''
63  for (const r of reviews) if (mine(r.author) && (r.submittedAt as string) > anchor) anchor = r.submittedAt as string
64  for (const c of pr.comments) if (mine(c.author) && c.createdAt > anchor) anchor = c.createdAt
65  const unanswered =
66    reviews.filter(r => !mine(r.author) && (r.submittedAt as string) > anchor).length +
67    pr.comments.filter(c => !mine(c.author) && c.createdAt > anchor).length
68  return { unresolved: pr.threads.filter(t => !t.isResolved).length, unanswered }
69}
70
71const isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
72
73// A node's author: null, or an object with a string login. Anything else
74// fails the shape check (undefined).
75function authorOf(value: unknown): Author | undefined {
76  if (value === null) return null
77  if (isObject(value) && typeof value.login === 'string') return { login: value.login }
78  return undefined
79}
80
81// A connection's `nodes` list, or undefined when it is not a list of objects.
82function nodesOf(value: unknown): Record<string, unknown>[] | undefined {
83  if (!isObject(value) || !Array.isArray(value.nodes) || !value.nodes.every(isObject)) return undefined
84  return value.nodes as Record<string, unknown>[]
85}
86
87// The nodes of a `gh api graphql` reply, or null when the reply fails the
88// shape check: a null `pullRequest` or PR author, a connection that is not
89// a list, or a node with a field of the wrong type.
90export function prNodes(reply: unknown): PrNodes | null {
91  if (!isObject(reply) || !isObject(reply.data) || !isObject(reply.data.repository)) return null
92  const pr = reply.data.repository.pullRequest
93  if (!isObject(pr)) return null
94  const author = authorOf(pr.author)
95  if (author === null || author === undefined) return null
96  const threads = nodesOf(pr.reviewThreads)
97  const reviews = nodesOf(pr.reviews)
98  const comments = nodesOf(pr.comments)
99  const commits = nodesOf(pr.commits)
100  if (threads === undefined || reviews === undefined || comments === undefined || commits === undefined) return null
101  const out: PrNodes = { author: author.login, threads: [], reviews: [], comments: [], commit: null }
102  for (const t of threads) {
103    if (typeof t.isResolved !== 'boolean') return null
104    out.threads.push({ isResolved: t.isResolved })
105  }
106  for (const r of reviews) {
107    const by = authorOf(r.author)
108    if (by === undefined || typeof r.state !== 'string') return null
109    if (r.submittedAt !== null && typeof r.submittedAt !== 'string') return null
110    out.reviews.push({ author: by, state: r.state, submittedAt: r.submittedAt as string | null })
111  }
112  for (const c of comments) {
113    const by = authorOf(c.author)
114    if (by === undefined || typeof c.createdAt !== 'string') return null
115    out.comments.push({ author: by, createdAt: c.createdAt })
116  }
117  for (const c of commits) {
118    const at = isObject(c.commit) ? utc(c.commit.committedDate) : null
119    if (at === null) return null
120    if (out.commit === null || at > out.commit) out.commit = at
121  }
122  return out
123}
124
125// A commit time as `YYYY-MM-DDTHH:MM:SSZ`, null when it is not a time.
126// GitHub types `committedDate` as a GitTimestamp, which its schema says is
127// not converted to UTC, so an offset form is converted here to compare as
128// text with the reviews' and comments' UTC times. The replies seen on
129// 2026-10-09 all ended in `Z`.
130function utc(value: unknown): string | null {
131  if (typeof value !== 'string') return null
132  if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) return value
133  const ms = Date.parse(value)
134  return Number.isNaN(ms) ? null : new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
135}
136
137// The counts from one `gh api graphql` call, null for a read that failed:
138// a call that rejected, a non-zero exit, text that is not JSON, or a reply
139// that fails the shape check.
140export function prCounts(result: { exitCode: number; stdout: string } | null): PrCounts | null {
141  if (result === null || result.exitCode !== 0) return null
142  let reply: unknown
143  try {
144    reply = JSON.parse(result.stdout)
145  } catch {
146    return null
147  }
148  const nodes = prNodes(reply)
149  return nodes === null ? null : countPr(nodes)
150}
151
hooks/status/pane.ts 457 lines
1import type { CairnHotfixPr, CairnPrRead, CairnPrWord } from '../../types'
2import type { PrCounts } from './counts'
3import type { Span } from './band'
4import type { FlowPhase } from './band'
5import { FLOW_COLORS, flowOf, GRAY } from './band'
6import type { BandRow, CandidateRow, PaneItem, PaneMilestone, PaneState } from './reader'
7import { PILL_TEXT } from './track'
8
9// The cairn pane's lines (M205), as a plain description that register.tsx
10// draws. For each active milestone: its phase, id, and title, with the
11// band's percent for the row at the end of the line when its file reads
12// (M208), its goal, its tasks and criteria with their boxes, and its
13// newest work-log lines.
14// Below them: the command that scripts/cairn_next.py recommends, the
15// workable planned milestones, and the planned ones that wait on
16// dependencies. Then the blocked milestones, each with the number of the
17// pull request its header names (M223), and after a read, that pull
18// request's state word and a Button for three of the words (M224), and for
19// an open one with counts above zero, a line under it with its unresolved
20// threads and unanswered reviews and comments (M225). After a read, the
21// open pull requests that the operator opened from `hotfix-*` branches,
22// each with the same word and counts and no Button (M226). With no
23// active milestone, the ROADMAP's candidate rows follow, each a priority
24// mark and its short title (M207).
25// A line's `lead` and `tail` keep their width. Its `text` is cut to one
26// line with an ellipsis, but for the goal's lines, which wrap: the operator
27// picked one line per item at the M205 live look, so a long task list fits
28// on one screen.
29// The operator picked the look at the M208 prototypes: a blank row, a
30// `▎`, and a gray uppercase label for each section heading, eight squares
31// beside the Tasks and Criteria counts, the Next command in a pill, and the
32// percent alone on the head line, with no track. At the live look the
33// operator asked for the phase colors: a milestone's marks and meters take
34// its phase's color, the queue's take plan's, and the pill takes the color
35// of the phase its command runs. The colors are theme keys (M217).
36// While no cairn skill runs, the Next line also carries the band's
37// next-step label after the pill, which register.tsx draws as a Button
38// (M218). After that Button the line carries Status, and Clear before it
39// while `ended` is true, which register.tsx draws as Buttons (M219).
40
41// The Next line's `action`, when set, is the label of the next-step Button
42// drawn after its tail. No other line carries one: register.tsx draws it
43// with the one key `cairn-pane-next` and the next-step press.
44// A line's `buttons` name the Buttons that register.tsx draws after its
45// tail and any action Button, in order: Clear and Status on the Next line
46// (M219), Refresh on the Blocked heading, and one of Finish, Revise, or
47// Check on a blocked line (M224). A blocked line's `target` is its
48// milestone id, which its Button's key and press carry. The Hotfixes
49// heading's Refresh has the target `hotfixes`, so its key differs from the
50// Blocked heading's (M226).
51export type PaneButton = 'clear' | 'status' | 'refresh' | 'finish' | 'revise' | 'check'
52export type PaneLine = {
53  key: string
54  indent: number
55  lead: Span[]
56  text: Span | null
57  tail?: Span[]
58  wraps?: true
59  action?: string
60  buttons?: PaneButton[]
61  target?: string
62}
63
64// A blocked milestone's pull request state, as `prWord` reads it from a
65// `gh pr view <url> --json state,reviewDecision` call (M224). The state
66// contract holds the one list of words.
67export type PrWord = CairnPrWord
68
69// One read of a pull request: its word, and its counts while it is open
70// and its count read did not fail (M225).
71export type PrRead = CairnPrRead
72
73// The words `prWord` gives an OPEN pull request, whose counts are read
74// (M225).
75export const OPEN_WORDS: readonly PrWord[] = ['changes requested', 'approved', 'in review']
76
77// The count line's text, or null when both counts are zero. A zero part is
78// left out, and one thread reads in the singular (M225).
79export function countsText(counts: PrCounts): string | null {
80  const parts: string[] = []
81  if (counts.unresolved > 0) parts.push(`${counts.unresolved} unresolved ${counts.unresolved === 1 ? 'thread' : 'threads'}`)
82  if (counts.unanswered > 0) parts.push(`${counts.unanswered} unanswered`)
83  return parts.length === 0 ? null : parts.join(' · ')
84}
85
86// The state word of one `gh pr view` result, null for a call that
87// rejected. A non-zero exit, text that is not a JSON object, or a `state`
88// other than MERGED, CLOSED, or OPEN reads as `unknown`. An OPEN pull
89// request reads by its `reviewDecision`, and any decision but
90// CHANGES_REQUESTED or APPROVED, an empty or absent one included, reads as
91// `in review`.
92export function prWord(result: { exitCode: number; stdout: string } | null): PrWord {
93  if (result === null || result.exitCode !== 0) return 'unknown'
94  let view: unknown
95  try {
96    view = JSON.parse(result.stdout)
97  } catch {
98    return 'unknown'
99  }
100  if (view === null || typeof view !== 'object') return 'unknown'
101  const { state, reviewDecision } = view as { state?: unknown; reviewDecision?: unknown }
102  if (state === 'MERGED') return 'merged'
103  if (state === 'CLOSED') return 'closed'
104  if (state !== 'OPEN') return 'unknown'
105  if (reviewDecision === 'CHANGES_REQUESTED') return 'changes requested'
106  if (reviewDecision === 'APPROVED') return 'approved'
107  return 'in review'
108}
109
110// An open pull request from a `hotfix-*` branch (M226).
111export type HotfixPr = CairnHotfixPr
112
113// The head branch prefix of a hotfix (tracking-rules git model).
114export const HOTFIX_PREFIX = 'hotfix-'
115
116// The hotfix pull requests of one `gh pr list --json
117// number,title,url,headRefName` result, in the order it lists them, or
118// null for a call that rejected, exited non-zero, or printed something
119// other than a JSON array (M226). An entry that is not an object, or has no
120// string `headRefName`, no string `url`, or no integer `number`, is
121// skipped, and so is one whose branch does not start with `hotfix-`, and a
122// second entry with a URL or number already kept, so no two lines share a
123// key (M226 review). A title that is not a string reads as empty.
124export function hotfixPrs(result: { exitCode: number; stdout: string } | null): HotfixPr[] | null {
125  if (result === null || result.exitCode !== 0) return null
126  let list: unknown
127  try {
128    list = JSON.parse(result.stdout)
129  } catch {
130    return null
131  }
132  if (!Array.isArray(list)) return null
133  const out: HotfixPr[] = []
134  for (const entry of list) {
135    if (entry === null || typeof entry !== 'object') continue
136    const { number, title, url, headRefName } = entry as Record<string, unknown>
137    if (typeof headRefName !== 'string' || !headRefName.startsWith(HOTFIX_PREFIX)) continue
138    if (typeof url !== 'string' || typeof number !== 'number' || !Number.isInteger(number)) continue
139    if (out.some(pr => pr.url === url || pr.number === number)) continue
140    out.push({ number, title: typeof title === 'string' ? title : '', url })
141  }
142  return out
143}
144
145// The Button a blocked line carries for its state word (M224), from the
146// routes that /milestone gives a handed-off PR: a merged one goes to
147// review's hygiene, one with changes requested back to implement, and a
148// closed one to /milestone. The other words carry none.
149export const PR_BUTTON: Partial<Record<PrWord, PaneButton>> = {
150  merged: 'finish',
151  'changes requested': 'revise',
152  closed: 'check',
153}
154
155// A state word's color: the review phase's for a merged or approved pull
156// request, the warning key for one with changes requested, gray otherwise.
157function prColor(word: PrWord): string {
158  if (word === 'merged' || word === 'approved') return FLOW_COLORS.review
159  if (word === 'changes requested') return WARNING
160  return GRAY
161}
162
163// The next-step Button's label by the action that scripts/cairn_next.py
164// names (M212), planning among them (M213). The band and the pane read
165// this one map (M218).
166export const PLAN_LABEL = 'Plan'
167export const NEXT_LABELS: Record<string, string> = {
168  review: 'Review',
169  resume: 'Resume',
170  implement: 'Implement',
171  'plan the next milestone': PLAN_LABEL,
172}
173
174// The actions whose next-step Button runs `/clear` first and its command in
175// the cleared conversation (M221): `Implement` and `Plan`. A resumed run
176// and a review keep their conversation. Each is a key of `NEXT_LABELS`.
177export const CLEARS_FIRST: readonly string[] = ['implement', 'plan the next milestone']
178
179// An action's label, read from the map's own keys only, so an action named
180// like an object property, such as `toString`, has none (M218 review).
181export function nextLabel(action: string): string | undefined {
182  return Object.prototype.hasOwnProperty.call(NEXT_LABELS, action) ? NEXT_LABELS[action] : undefined
183}
184
185// The Status and Clear Buttons' labels (M212, M216). The band and the
186// pane's Next line read these (M219).
187export const STATUS_LABEL = 'Status'
188export const CLEAR_LABEL = 'Clear'
189// The Blocked heading's Button and a blocked line's Buttons (M224).
190export const REFRESH_LABEL = 'Refresh'
191export const FINISH_LABEL = 'Finish'
192export const REVISE_LABEL = 'Revise'
193export const CHECK_LABEL = 'Check'
194
195export const NO_ROADMAP = 'no cairn ROADMAP found'
196export const NO_FILE = 'no milestone file'
197export const NO_ACTIVE = 'no active milestone'
198export const CHECKED_MARK = '✓'
199export const OPEN_MARK = '○'
200// A candidate row's mark for each priority (M207).
201export const PRIORITY_MARK: Record<CandidateRow['priority'], Span> = {
202  high: { text: '↑', color: FLOW_COLORS.implement, bold: true },
203  normal: { text: '·' },
204  low: { text: '↓', color: GRAY },
205}
206// A section heading's accent mark, in the phase's color (M208).
207export const ACCENT_MARK = '▎'
208// The meter's squares, filled and empty, and how many it draws (M208).
209export const METER_FULL = '■'
210export const METER_EMPTY = '□'
211export const METER_CELLS = 8
212// The empty squares' theme key, as the band's terminal track marks use (M208 review).
213export const METER_EMPTY_KEY = 'subtle'
214// The warning text's theme key, as the band's warning labels use.
215const WARNING = 'warning'
216
217const PHASE: Record<string, { label: string; color: string }> = {
218  'in-progress': { label: 'implement', color: FLOW_COLORS.implement },
219  review: { label: 'review', color: FLOW_COLORS.review },
220}
221// The phase each recommended command runs, for the Next pill's color.
222export const COMMAND_PHASE: Record<string, FlowPhase> = {
223  '/milestone-plan': 'plan',
224  '/milestone-implement': 'implement',
225  '/milestone-review': 'review',
226}
227// The queue's headings are about planned, blocked, and candidate work.
228const QUEUE_COLOR = FLOW_COLORS.plan
229
230const line = (key: string, indent: number, lead: Span[], text: Span | null = null): PaneLine => ({
231  key,
232  indent,
233  lead,
234  text,
235})
236
237// One blank row, before each section heading, each milestone after the
238// first, and Next.
239const gap = (key: string) => line(key, 0, [], { text: ' ' })
240
241// The filled share of the meter: none only with no box checked, and all
242// only with every box checked.
243export function meterFill(checked: number, total: number): number {
244  if (total === 0 || checked === 0) return 0
245  if (checked >= total) return METER_CELLS
246  return Math.min(METER_CELLS - 1, Math.max(1, Math.round((METER_CELLS * checked) / total)))
247}
248
249export function meter(checked: number, total: number, color: string): Span[] {
250  const fill = meterFill(checked, total)
251  return [
252    { text: METER_FULL.repeat(fill), color },
253    { text: METER_EMPTY.repeat(METER_CELLS - fill), color: METER_EMPTY_KEY },
254  ].filter(span => span.text !== '')
255}
256
257// A section heading and the blank row before it: the accent in `color`,
258// the label in gray uppercase, and its count and meter where it has them.
259function heading(key: string, label: string, color: string, after: Span[] = []): PaneLine[] {
260  return [
261    gap(`${key}-gap`),
262    line(key, 0, [
263      { text: ACCENT_MARK, color },
264      { text: ' ' },
265      { text: label.toUpperCase(), color: GRAY, bold: true },
266      ...after,
267    ]),
268  ]
269}
270
271function counted(key: string, label: string, list: PaneItem[], color: string): PaneLine[] {
272  const checked = list.filter(item => item.checked).length
273  return heading(key, label, color, [
274    { text: ' ' },
275    { text: `${checked}/${list.length}`, color: GRAY },
276    { text: ' ' },
277    ...meter(checked, list.length, color),
278  ])
279}
280
281function items(prefix: string, list: PaneItem[]): PaneLine[] {
282  return list.map((item, i) =>
283    line(
284      `${prefix}-${i}`,
285      2,
286      [item.checked ? { text: CHECKED_MARK, color: GRAY } : { text: OPEN_MARK }, { text: ' ' }],
287      item.checked ? { text: item.text, color: GRAY } : { text: item.text },
288    ),
289  )
290}
291
292function milestoneLines(row: PaneMilestone, band: BandRow | undefined): PaneLine[] {
293  const phase = PHASE[row.status] ?? { label: row.status, color: GRAY }
294  const head = line(
295    `${row.id}-head`,
296    0,
297    [{ text: phase.label, color: phase.color, bold: true }, { text: ' ' }, { text: row.id, bold: true }, { text: '  ' }],
298    { text: row.title },
299  )
300  const file = row.file
301  // The band's percent for the row, from its own counts, which count a box
302  // inside an HTML comment where the pane's items do not.
303  const flow = file === null || band === undefined ? null : flowOf(band)
304  if (flow !== null && flow.percent !== null) head.tail = [{ text: '  ' }, { text: `${flow.percent}%`, color: GRAY }]
305  const out: PaneLine[] = [head]
306  if (file === null) return [...out, line(`${row.id}-nofile`, 2, [], { text: NO_FILE, color: WARNING })]
307  if (file.goal !== '') {
308    out.push(...heading(`${row.id}-goal-head`, 'Goal', phase.color))
309    // A blank line between paragraphs draws as one space, so it keeps its row.
310    file.goal.split('\n').forEach((text, i) =>
311      out.push({ ...line(`${row.id}-goal-${i}`, 2, [], { text: text === '' ? ' ' : text }), wraps: true }),
312    )
313  }
314  if (file.tasks.length > 0) {
315    out.push(...counted(`${row.id}-tasks-head`, 'Tasks', file.tasks, phase.color))
316    out.push(...items(`${row.id}-task`, file.tasks))
317  }
318  if (file.criteria.length > 0) {
319    out.push(...counted(`${row.id}-criteria-head`, 'Criteria', file.criteria, phase.color))
320    out.push(...items(`${row.id}-criterion`, file.criteria))
321  }
322  if (file.log.length > 0) {
323    out.push(...heading(`${row.id}-log-head`, 'Work log', phase.color))
324    file.log.forEach((text, i) => out.push(line(`${row.id}-log-${i}`, 2, [], { text, color: GRAY })))
325  }
326  return out
327}
328
329// The pane's lines from its state and the band's rows, whose counts give
330// each head line its percent. `acts` is true while no cairn skill's step is
331// set, and then the Next line carries its action label (M218) and the
332// Status Button after it (M219). `ended` is true from a
333// Stop that ends a cairn skill's step until the next idle typed prompt,
334// cairn skill prompt, or session end, and then the Clear Button comes
335// before Status (M219). `prs` holds each pull request's state word by its
336// URL, from the last read (M224), with its counts (M225). `hotfixes` holds
337// the open hotfix pull requests of the last read for this root (M226).
338export function paneLines(
339  state: PaneState,
340  band: BandRow[] = [],
341  acts = false,
342  ended = false,
343  prs: Record<string, PrRead> = {},
344  hotfixes: HotfixPr[] = [],
345): PaneLine[] {
346  if (!state.found) return [line('no-roadmap', 0, [], { text: NO_ROADMAP, color: GRAY })]
347  const out: PaneLine[] = []
348  if (state.milestones.length === 0) out.push(line('no-active', 0, [], { text: NO_ACTIVE, color: GRAY }))
349  state.milestones.forEach((row, i) => {
350    if (i > 0) out.push(gap(`${row.id}-gap`))
351    out.push(...milestoneLines(row, band.find(b => b.id === row.id && b.status === row.status)))
352  })
353  if (state.next !== null) {
354    const target = state.next.id === null ? state.next.command : `${state.next.command} ${state.next.id}`
355    out.push(gap('next-gap'))
356    const next = line('next', 0, [{ text: 'Next', bold: true }, { text: '  ' }], {
357      text: ` ${target} `,
358      color: PILL_TEXT,
359      backgroundColor: FLOW_COLORS[COMMAND_PHASE[state.next.command] ?? 'implement'],
360      // Bold, as the band's pill is. The text and fill are the theme's own
361      // keys (M217), so their contrast is the theme's.
362      bold: true,
363    })
364    const label = nextLabel(state.next.action)
365    // Status and Clear follow the next-step Button and show only with it, as
366    // the band's show only with a next-step label (M219 review).
367    if (acts && label !== undefined) {
368      next.action = label
369      next.buttons = ended ? ['clear', 'status'] : ['status']
370    }
371    out.push(next)
372  }
373  if (state.workable.length > 0) {
374    out.push(...heading('workable-head', 'Workable', QUEUE_COLOR))
375    for (const row of state.workable) {
376      out.push(line(`workable-${row.id}`, 2, [{ text: row.id, bold: true }, { text: '  ' }], { text: row.title }))
377    }
378  }
379  if (state.waiting.length > 0) {
380    out.push(...heading('waiting-head', 'Waiting', QUEUE_COLOR))
381    for (const row of state.waiting) {
382      out.push(
383        line(`waiting-${row.id}`, 2, [{ text: row.id, bold: true }, { text: '  ' }], {
384          text: `${row.title} · on ${row.unmet.join(', ')}`,
385        }),
386      )
387    }
388  }
389  // The blocked rows, each with its pull request's number when its
390  // milestone file's header names one, whether or not a milestone is
391  // active (M223). After the number, the state word that the last read
392  // found, and the Button that word carries (M224). The heading carries
393  // the Refresh Button while a row has a pull request to read.
394  if (state.blocked.length > 0) {
395    const head = heading('blocked-head', 'Blocked', QUEUE_COLOR, [
396      { text: ' ' },
397      { text: `${state.blocked.length}`, color: GRAY },
398    ])
399    if (state.blocked.some(row => row.url !== null)) head[1].buttons = ['refresh']
400    out.push(...head)
401    for (const row of state.blocked) {
402      const held = line(`blocked-${row.id}`, 2, [{ text: row.id, bold: true }, { text: '  ' }], { text: row.title })
403      if (row.pr !== null) held.tail = [{ text: '  ' }, { text: `#${row.pr}`, color: GRAY }]
404      const got = row.url !== null && Object.prototype.hasOwnProperty.call(prs, row.url) ? prs[row.url] : undefined
405      const word = got?.word
406      if (word !== undefined) {
407        held.tail = [...(held.tail ?? []), { text: '  ' }, { text: word, color: prColor(word) }]
408        const button = PR_BUTTON[word]
409        if (button !== undefined) {
410          held.buttons = [button]
411          held.target = row.id
412        }
413      }
414      out.push(held)
415      // The counts sit in the line's text, which is cut at the pane's edge,
416      // since a 44-column dock leaves no room for them in the tail (M225).
417      const counts = got?.counts == null ? null : countsText(got.counts)
418      if (counts !== null) out.push(line(`blocked-${row.id}-counts`, 4, [], { text: counts, color: GRAY }))
419    }
420  }
421  // The open hotfix pull requests, after the Blocked rows and before the
422  // candidates, each its number and title, and after a read its state word
423  // and counts as a blocked line shows them, with no Button (M226). No
424  // command resumes an open hotfix pull request, so no word routes one.
425  if (hotfixes.length > 0) {
426    const head = heading('hotfixes-head', 'Hotfixes', QUEUE_COLOR, [{ text: ' ' }, { text: `${hotfixes.length}`, color: GRAY }])
427    head[1].buttons = ['refresh']
428    head[1].target = 'hotfixes'
429    out.push(...head)
430    for (const pr of hotfixes) {
431      const held = line(`hotfix-${pr.number}`, 2, [{ text: `#${pr.number}`, bold: true }, { text: '  ' }], { text: pr.title })
432      const got = Object.prototype.hasOwnProperty.call(prs, pr.url) ? prs[pr.url] : undefined
433      if (got !== undefined) held.tail = [{ text: '  ' }, { text: got.word, color: prColor(got.word) }]
434      out.push(held)
435      const counts = got?.counts == null ? null : countsText(got.counts)
436      if (counts !== null) out.push(line(`hotfix-${pr.number}-counts`, 4, [], { text: counts, color: GRAY }))
437    }
438  }
439  // With no active milestone, the ROADMAP's candidate rows (M207).
440  if (state.milestones.length === 0 && state.candidates.length > 0) {
441    out.push(
442      ...heading('candidates-head', 'Candidates', QUEUE_COLOR, [{ text: ' ' }, { text: `${state.candidates.length}`, color: GRAY }]),
443    )
444    state.candidates.forEach((row, i) =>
445      out.push(
446        line(
447          `candidate-${i}`,
448          2,
449          [PRIORITY_MARK[row.priority], { text: ' ' }],
450          row.priority === 'low' ? { text: row.title, color: GRAY } : { text: row.title },
451        ),
452      ),
453    )
454  }
455  return out
456}
457
hooks/status/reader.ts 568 lines
1// The status band's reader: which milestones are active, how many of their
2// tasks and criteria are checked, and the first open one of each, and which
3// planned milestones are workable. It mirrors the Python helpers the
4// validator uses (`parse_roadmap_rows_full` in hooks/cairn_common.py, `_section_body` and
5// `_AC_ITEM` in scripts/cairn_validate.py, `find_cairn_root` for the walk),
6// and `workable` in scripts/cairn_next.py over the rows `cairn_scripts.rows`
7// parses (its Depends-on cells through `parse_depends`), with what
8// `workable` reaches through `done_ids` and `_workable` (`canon_id`,
9// `archive_files`, and `sort_by_priority` with its `id_num`). For the cairn
10// pane it also mirrors `recommend` and `waiting` in scripts/cairn_next.py
11// (M205), and reads the candidate rows in the sections that
12// `candidate_count` in scripts/cairn_scripts.py walks (M207), and mirrors
13// `blocked` and `pr_number` in scripts/cairn_next.py (M223), and `pr_url`
14// (M224). The mirror
15// reads ASCII digits only, where Python's `isdigit`, `isdecimal`, and `\d`
16// also take other Unicode digits, so an id such as `M0057` reads
17// differently in the two.
18// hooks/status/reader.test.ts holds this reader, and
19// scripts/tests/test_status_fixtures.py the Python helpers, to the same
20// fixtures.
21
22// Where the reader gets files. The shipped mod reads through `$.fs` and
23// `$.session`; the tests read an in-memory copy of a fixture.
24export type FileSource = {
25  cwd: () => Promise<string>
26  isFile: (path: string) => Promise<boolean>
27  // The file's text, or null when it cannot be read.
28  read: (path: string) => Promise<string | null>
29  // The names of a directory's entries, or null when it cannot be listed.
30  list: (path: string) => Promise<string[] | null>
31}
32
33// A planned milestone whose dependencies are all done.
34export type WorkableRow = { id: string; title: string }
35
36// What the band reads: the active rows in ROADMAP order, and the workable
37// planned rows by priority and then id, whether or not a row is active.
38export type BandState = { rows: BandRow[]; workable: WorkableRow[] }
39
40// The counts and next items are all null when the row's milestone path is
41// not a readable regular file. A next item is also null when its section
42// holds no open box.
43export type BandRow = {
44  id: string
45  title: string
46  status: string
47  tasksChecked: number | null
48  tasksTotal: number | null
49  criteriaChecked: number | null
50  criteriaTotal: number | null
51  // The first open task or criterion line, less its `- [ ]` prefix.
52  nextTask: string | null
53  nextCriterion: string | null
54}
55
56export type Fixture = {
57  cwd: string
58  files: Record<string, string>
59  rows: BandRow[]
60  // The ordered ids of the workable planned rows, as cairn_next.py lists them.
61  workable: string[]
62  // The pane's milestones, and `recommend` with `waiting` beside it, as
63  // scripts/tests/test_status_fixtures.py computes them; `next` is null
64  // with no ROADMAP (M205).
65  pane: PaneMilestone[]
66  next: (NextStep & { waiting: WaitingRow[] }) | null
67  // The pane's candidate rows, as test_status_fixtures.py reads them (M207).
68  candidates: CandidateRow[]
69  // The pane's blocked rows, as `blocked` in scripts/cairn_next.py reads
70  // them (M223).
71  blocked: BlockedRow[]
72  // Paths that stat as files but whose read fails in the tests.
73  unreadable: string[]
74}
75
76export const ACTIVE: readonly string[] = ['in-progress', 'review']
77
78const AC_ITEM = /^\s*-\s*\[[ xX]\]/
79const CHECKED = /^\s*-\s*\[[xX]\]/
80const OPEN_BOX = /^\s*-\s*\[ \]\s*/
81
82// The line breaks Python's str.splitlines() splits on.
83const LINE_BREAK = new RegExp(
84  '\\r\\n|[\\n\\r\\v\\f\\x1c\\x1d\\x1e\\x85' + String.fromCharCode(0x2028, 0x2029) + ']',
85)
86
87export function splitLines(text: string): string[] {
88  return text.split(LINE_BREAK)
89}
90
91export type RoadmapRow = {
92  id: string
93  title: string
94  status: string
95  depends: string
96  priority: string
97  relpath: string
98}
99
100export function parseRoadmapRows(text: string): RoadmapRow[] {
101  const rows: RoadmapRow[] = []
102  for (const line of splitLines(text)) {
103    if (!line.trimStart().startsWith('|')) continue
104    const cells = line.split('|').slice(1, -1).map(c => c.trim())
105    if (cells.length < 6 || !cells[0].startsWith('M')) continue
106    rows.push({
107      id: cells[0],
108      title: cells[1],
109      status: cells[2].toLowerCase(),
110      depends: cells[3],
111      priority: cells[4],
112      relpath: cells[5],
113    })
114  }
115  return rows
116}
117
118const DIGITS = /^\d+$/
119
120// The milestone ids in a Depends-on cell, as `parse_depends` for ASCII
121// digits: a token is an `M` and digits, and every other token, `—`
122// included, is dropped.
123export function parseDepends(cell: string): string[] {
124  return cell
125    .trim()
126    .split(/[,\s]+/)
127    .filter(token => token.startsWith('M') && DIGITS.test(token.slice(1)))
128}
129
130// An id at three-digit padding, as `canon_id` for ASCII digits: `M57`,
131// `M057`, and `M0057` are `M057`, and a non-numeric id stays as it is.
132export function canonId(id: string): string {
133  const rest = id.slice(1)
134  return DIGITS.test(rest) ? `M${BigInt(rest).toString().padStart(3, '0')}` : id
135}
136
137// The sort key of an id: its number, or after every numeric id.
138function idNumber(id: string): number {
139  const rest = id.slice(1)
140  return DIGITS.test(rest) ? Number(rest) : 1e9
141}
142
143// A Map, so a priority word such as `constructor` reads as normal.
144const PRIORITY_RANK = new Map([
145  ['high', 0],
146  ['normal', 1],
147  ['low', 2],
148])
149
150function priorityRank(priority: string): number {
151  return PRIORITY_RANK.get(priority.toLowerCase()) ?? 1
152}
153
154// The planned rows whose Depends-on ids are each a done row or in
155// `archived`, by priority and then id; the sort keeps ROADMAP order on a
156// tie. `archived` holds ids at three-digit padding.
157export function workableRows(rows: RoadmapRow[], archived: string[]): WorkableRow[] {
158  const done = new Set([...rows.filter(row => row.status === 'done').map(row => canonId(row.id)), ...archived])
159  return rows
160    .filter(row => row.status === 'planned' && parseDepends(row.depends).every(dep => done.has(canonId(dep))))
161    .sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || idNumber(a.id) - idNumber(b.id))
162    .map(row => ({ id: row.id, title: row.title }))
163}
164
165// The glob `M*.md` and the id at the start of each name, as `archive_files`.
166const ARCHIVE_NAME = /^M[\s\S]*\.md$/
167const ARCHIVE_ID = /^M\d+/
168
169// The ids, at three-digit padding, of the archive files directly under
170// cairn/milestones/archive/.
171async function archivedIds(source: FileSource, root: string): Promise<string[]> {
172  const names = (await source.list(join(root, 'cairn/milestones/archive'))) ?? []
173  const ids: string[] = []
174  for (const name of names) {
175    const id = ARCHIVE_NAME.test(name) ? ARCHIVE_ID.exec(name)?.[0] : undefined
176    if (id !== undefined) ids.push(canonId(id))
177  }
178  return ids
179}
180
181// Lines under the first `## <heading>` H2, up to the next H2 or the end.
182export function sectionBody(text: string, heading: string): string[] {
183  const out: string[] = []
184  let inSection = false
185  for (const line of splitLines(text)) {
186    if (line.startsWith('## ')) {
187      if (inSection) break
188      inSection = line.slice(3).trim().toLowerCase().startsWith(heading.toLowerCase())
189      continue
190    }
191    if (inSection) out.push(line)
192  }
193  return out
194}
195
196export type SectionFields = { checked: number; total: number; next: string | null }
197
198// The checkbox counts of one section and its first open line, less the box.
199export function sectionFields(text: string, heading: string): SectionFields {
200  const items = sectionBody(text, heading).filter(line => AC_ITEM.test(line))
201  const open = items.find(line => OPEN_BOX.test(line))
202  return {
203    checked: items.filter(line => CHECKED.test(line)).length,
204    total: items.length,
205    next: open === undefined ? null : open.replace(OPEN_BOX, ''),
206  }
207}
208
209const NO_FILE = {
210  tasksChecked: null,
211  tasksTotal: null,
212  criteriaChecked: null,
213  criteriaTotal: null,
214  nextTask: null,
215  nextCriterion: null,
216}
217
218function fileFields(text: string) {
219  const tasks = sectionFields(text, 'Tasks')
220  const criteria = sectionFields(text, 'Acceptance criteria')
221  return {
222    tasksChecked: tasks.checked,
223    tasksTotal: tasks.total,
224    criteriaChecked: criteria.checked,
225    criteriaTotal: criteria.total,
226    nextTask: tasks.next,
227    nextCriterion: criteria.next,
228  }
229}
230
231export function dirname(path: string): string {
232  const trimmed = path.length > 1 ? path.replace(/[\\/]+$/, '') : path
233  // A UNC share root (`\\host\share`) is a root, as a drive is (M210).
234  if (/^[\\/]{2}[^\\/]+[\\/][^\\/]+$/.test(trimmed)) return trimmed
235  const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
236  if (cut < 0) return trimmed
237  if (cut === 0) return trimmed[0]
238  const head = trimmed.slice(0, cut)
239  return /^[A-Za-z]:$/.test(head) ? head + trimmed[cut] : head
240}
241
242export function join(dir: string, rel: string): string {
243  return `${dir.replace(/[\\/]+$/, '')}/${rel}`
244}
245
246// The nearest directory at or above `cwd` that holds cairn/ROADMAP.md.
247export async function findRoot(source: FileSource, cwd: string): Promise<string | null> {
248  let path = cwd
249  for (;;) {
250    if (await source.isFile(join(path, 'cairn/ROADMAP.md'))) return path
251    const parent = dirname(path)
252    if (parent === path) return null
253    path = parent
254  }
255}
256
257// One row per `in-progress` or `review` milestone, in ROADMAP order, and
258// the workable list. Empty when no ROADMAP is found, and null when one is
259// found but cannot be read (M200) or is empty (M210). `read_roadmap` in
260// scripts/cairn_scripts.py reads an unreadable ROADMAP as empty instead.
261export async function loadBand(source: FileSource): Promise<BandState | null> {
262  const loaded = await loadCairn(source)
263  return loaded === null ? null : loaded.band
264}
265
266// The band's state and the pane's from one read of the files (M205). Null
267// when a ROADMAP is found but cannot be read, or its text is empty or only
268// whitespace, as a write half done can leave it (M210).
269export async function loadCairn(source: FileSource): Promise<{ band: BandState; pane: PaneState } | null> {
270  const root = await findRoot(source, await source.cwd())
271  return root === null ? emptyCairn() : loadAt(source, root)
272}
273
274// The same read with the root it found (M210): `root` is null when no
275// ROADMAP is found, and `state` is null when a found one cannot be read,
276// its text is empty or only whitespace, or a throw ends the read. A throw
277// from `source.cwd()` is not caught, since it leaves the root unknown.
278export async function readCairn(
279  source: FileSource,
280): Promise<{ root: string | null; state: { band: BandState; pane: PaneState } | null }> {
281  const root = await findRoot(source, await source.cwd())
282  if (root === null) return { root, state: emptyCairn() }
283  try {
284    return { root, state: await loadAt(source, root) }
285  } catch {
286    return { root, state: null }
287  }
288}
289
290function emptyCairn(): { band: BandState; pane: PaneState } {
291  return { band: { rows: [], workable: [] }, pane: NO_PANE }
292}
293
294async function loadAt(source: FileSource, root: string): Promise<{ band: BandState; pane: PaneState } | null> {
295  const roadmap = await source.read(join(root, 'cairn/ROADMAP.md'))
296  if (roadmap === null || roadmap.trim() === '') return null
297  const parsed = parseRoadmapRows(roadmap)
298  const out: BandRow[] = []
299  const milestones: PaneMilestone[] = []
300  const blocked: BlockedRow[] = []
301  for (const row of parsed) {
302    const active = ACTIVE.includes(row.status)
303    if (!active && row.status !== 'blocked') continue
304    // A regular file only, as Python's os.path.isfile: a path to a pipe or
305    // a device would otherwise stall the read at every turn end.
306    const path = join(root, `cairn/${row.relpath}`)
307    const text = (await source.isFile(path)) ? await source.read(path) : null
308    if (!active) {
309      blocked.push({
310        id: row.id,
311        title: row.title,
312        pr: text === null ? null : prNumber(text),
313        url: text === null ? null : prUrl(text),
314      })
315      continue
316    }
317    const fields = text === null ? NO_FILE : fileFields(text)
318    out.push({ id: row.id, title: row.title, status: row.status, ...fields })
319    milestones.push({ id: row.id, title: row.title, status: row.status, file: text === null ? null : paneFile(text) })
320  }
321  const archived = await archivedIds(source, root)
322  const workable = workableRows(parsed, archived)
323  return {
324    band: { rows: out, workable },
325    pane: {
326      found: true,
327      milestones,
328      next: recommend(parsed, workable),
329      workable,
330      waiting: waitingRows(parsed, archived),
331      candidates: candidateRows(roadmap),
332      blocked,
333    },
334  }
335}
336
337// A milestone header's `Branch/PR` line, and a pull request URL in it, as
338// `BRANCH_PR` and `PR_URL` in scripts/cairn_next.py (M223), with spaces and
339// tabs spelled out, since `\s` takes different characters in the two. A
340// number has at most 15 digits, so `Number` reads it exactly.
341const BRANCH_PR = /^[ \t]*-[ \t]*\*\*Branch\/PR:\*\*(.*)$/
342const PR_URL = /https:\/\/github\.com\/[^/ \t]+\/[^/ \t]+\/pull\/([0-9]{1,15})(?![0-9])/
343
344// `_pr_match` in scripts/cairn_next.py: the match of the pull request URL
345// that the first `Branch/PR` header line names, the first URL before any
346// `companion:` entry, or null. The header is the text before the first
347// `## ` heading.
348function prMatch(text: string): RegExpExecArray | null {
349  for (const line of splitLines(text)) {
350    if (line.startsWith('## ')) return null
351    const match = BRANCH_PR.exec(line)
352    if (match === null) continue
353    return PR_URL.exec(match[1].split('companion:')[0])
354  }
355  return null
356}
357
358// The collaboration mode that a `cairn/PROFILE.md` text declares, as
359// `collaboration_mode` in hooks/cairn_common.py reads it (M226): the value
360// of the first `# Collaboration mode:` line before the first `## ` slot
361// heading, lowercased, with the key matched in any case and a leading BOM
362// skipped. A text with no such line reads as `owner`.
363export function collaborationMode(text: string): string {
364  for (const each of splitLines(text.replace(/^/, ''))) {
365    if (each.startsWith('## ')) break
366    const found = /^#\s*Collaboration mode:\s*(\S+)/i.exec(each)
367    if (found !== null) return found[1].toLowerCase()
368  }
369  return 'owner'
370}
371
372// `pr_number` in scripts/cairn_next.py: the number of that pull request, or
373// null.
374export function prNumber(text: string): number | null {
375  const url = prMatch(text)
376  return url === null ? null : Number(url[1])
377}
378
379// `pr_url` in scripts/cairn_next.py (M224): its URL up to the number, so
380// with no trailing path or anchor, or null.
381export function prUrl(text: string): string | null {
382  const url = prMatch(text)
383  return url === null ? null : url[0]
384}
385
386// The cairn pane's state (M205): the active milestones in full, the
387// queue that scripts/cairn_next.py prints, the candidate rows (M207), and
388// the blocked rows with their pull request numbers (M223). `found` is
389// false when no ROADMAP is found.
390export type PaneState = {
391  found: boolean
392  milestones: PaneMilestone[]
393  next: NextStep | null
394  workable: WorkableRow[]
395  waiting: WaitingRow[]
396  candidates: CandidateRow[]
397  blocked: BlockedRow[]
398}
399
400// One candidate row of the ROADMAP: its priority token's level, `normal`
401// with no token, and its short title.
402export type CandidateRow = { priority: 'high' | 'normal' | 'low'; title: string }
403
404export type PaneItem = { text: string; checked: boolean }
405
406// `file` is null when the milestone file is missing or its read fails.
407export type PaneMilestone = {
408  id: string
409  title: string
410  status: string
411  file: { goal: string; tasks: PaneItem[]; criteria: PaneItem[]; log: string[] } | null
412}
413
414// `recommend` in scripts/cairn_next.py: `id` is null for planning.
415export type NextStep = { action: string; command: string; id: string | null }
416
417// `waiting` in scripts/cairn_next.py: `unmet` holds each undone dependency
418// as written, with its row's status or `unknown`.
419export type WaitingRow = { id: string; title: string; unmet: string[] }
420
421// `blocked` in scripts/cairn_next.py (M223): a `blocked` row, and the
422// number of the pull request its milestone file's `Branch/PR` header names,
423// or null, with that pull request's URL beside it (M224).
424export type BlockedRow = { id: string; title: string; pr: number | null; url: string | null }
425
426export const NO_PANE: PaneState = {
427  found: false,
428  milestones: [],
429  next: null,
430  workable: [],
431  waiting: [],
432  candidates: [],
433  blocked: [],
434}
435
436// How many work-log lines the pane shows, newest last.
437export const LOG_LINES = 5
438
439const COMMENT = /<!--[\s\S]*?-->/g
440const ITEM_BOX = /^\s*-\s*\[[ xX]\]\s*/
441const LOG_ITEM = /^- /
442
443// A section's lines with HTML comments removed, as the template's owner
444// notes are.
445function sectionText(text: string, heading: string): string[] {
446  return splitLines(sectionBody(text, heading).join('\n').replace(COMMENT, ''))
447}
448
449// A section's checkbox items. A non-blank indented line that is not itself
450// an item continues the item above it, so a wrapped task reads whole.
451export function sectionItems(text: string, heading: string): PaneItem[] {
452  const items: PaneItem[] = []
453  let current: PaneItem | null = null
454  for (const line of sectionText(text, heading)) {
455    if (AC_ITEM.test(line)) {
456      current = { text: line.replace(ITEM_BOX, ''), checked: CHECKED.test(line) }
457      items.push(current)
458    } else if (current !== null && /^\s/.test(line) && line.trim() !== '') {
459      current.text = `${current.text} ${line.trim()}`
460    } else {
461      current = null
462    }
463  }
464  return items
465}
466
467function paneFile(text: string) {
468  return {
469    goal: sectionText(text, 'Goal').join('\n').trim(),
470    tasks: sectionItems(text, 'Tasks'),
471    criteria: sectionItems(text, 'Acceptance criteria'),
472    log: sectionText(text, 'Work log')
473      .filter(line => LOG_ITEM.test(line))
474      .slice(-LOG_LINES)
475      .map(line => line.replace(LOG_ITEM, '')),
476  }
477}
478
479const PRIORITY_TOKENS: readonly [string, 'high' | 'low'][] = [
480  ['[high] ', 'high'],
481  ['[low] ', 'low'],
482]
483
484// The candidate rows (M207): each flush-left line that opens with `- `
485// under a `## Candidates` heading. HTML comments are removed from each such
486// section's body before it is read, so the `/cairn-init` skeleton's
487// placeholder rows are not read, and a `<!--` in another section hides
488// nothing here. Every `## ` line starts or ends a section, as in
489// `candidate_count` in scripts/cairn_scripts.py, which also counts indented
490// lines and lines inside comments. A row's priority is `high` or `low` when
491// its text opens with exactly `[high] ` or `[low] `, else `normal`. Its
492// short title is the text after that token up to the first `: `, or all of it.
493export function candidateRows(roadmap: string): CandidateRow[] {
494  const bodies: string[][] = []
495  let body: string[] | null = null
496  for (const line of splitLines(roadmap)) {
497    if (line.startsWith('## ')) {
498      body = line.trim().toLowerCase().startsWith('## candidates') ? [] : null
499      if (body !== null) bodies.push(body)
500      continue
501    }
502    if (body !== null) body.push(line)
503  }
504  const rows: CandidateRow[] = []
505  for (const line of bodies.flatMap(lines => splitLines(lines.join('\n').replace(COMMENT, '')))) {
506    if (!line.startsWith('- ')) continue
507    let text = line.slice(2)
508    let priority: CandidateRow['priority'] = 'normal'
509    const match = PRIORITY_TOKENS.find(([token]) => text.startsWith(token))
510    if (match !== undefined) {
511      priority = match[1]
512      text = text.slice(match[0].length)
513    }
514    const cut = text.indexOf(': ')
515    rows.push({ priority, title: (cut < 0 ? text : text.slice(0, cut)).trim() })
516  }
517  return rows
518}
519
520// `recommend` in scripts/cairn_next.py, over the rows and the workable list.
521export function recommend(rows: RoadmapRow[], workable: WorkableRow[]): NextStep {
522  const review = rows.find(row => row.status === 'review')
523  if (review !== undefined) return { action: 'review', command: '/milestone-review', id: review.id }
524  const active = rows.find(row => row.status === 'in-progress')
525  if (active !== undefined) return { action: 'resume', command: '/milestone-implement', id: active.id }
526  if (workable.length > 0) return { action: 'implement', command: '/milestone-implement', id: workable[0].id }
527  return { action: 'plan the next milestone', command: '/milestone-plan', id: null }
528}
529
530// `waiting` in scripts/cairn_next.py: the planned rows with a dependency
531// not yet done, in ROADMAP order. A later row with the same id wins the
532// status lookup, as the Python dict does.
533export function waitingRows(rows: RoadmapRow[], archived: string[]): WaitingRow[] {
534  const done = new Set([...rows.filter(row => row.status === 'done').map(row => canonId(row.id)), ...archived])
535  const byId = new Map<string, RoadmapRow>()
536  for (const row of rows) byId.set(canonId(row.id), row)
537  const out: WaitingRow[] = []
538  for (const row of rows) {
539    if (row.status !== 'planned') continue
540    const unmet = parseDepends(row.depends)
541      .filter(dep => !done.has(canonId(dep)))
542      .map(dep => `${dep} (${byId.get(canonId(dep))?.status ?? 'unknown'})`)
543    if (unmet.length > 0) out.push({ id: row.id, title: row.title, unmet })
544  }
545  return out
546}
547
548// The names directly under `dir` in a set of absolute file paths: a file's
549// name, or the first part of a deeper path. Null when nothing is under it.
550export function listNames(paths: string[], dir: string): string[] | null {
551  const prefix = `${dir.replace(/[\\/]+$/, '')}/`
552  const names = new Set<string>()
553  for (const path of paths) if (path.startsWith(prefix)) names.add(path.slice(prefix.length).split('/')[0])
554  return names.size === 0 ? null : [...names]
555}
556
557// An in-memory file source over absolute paths, for the tests. A path in
558// `unreadable` stats as a file, and its read fails.
559export function memorySource(files: Record<string, string>, cwd: string, unreadable: string[] = []): FileSource {
560  return {
561    cwd: async () => cwd,
562    isFile: async path => Object.prototype.hasOwnProperty.call(files, path),
563    read: async path =>
564      Object.prototype.hasOwnProperty.call(files, path) && !unreadable.includes(path) ? files[path] : null,
565    list: async dir => listNames(Object.keys(files), dir),
566  }
567}
568
hooks/status/track.ts 266 lines
1import type { Flow, Span } from './band'
2import type { FlowPhase } from './band'
3import { FLOW_COLORS, FLOW_PHASES } from './band'
4
5// The flow track (M204, M206), the band's one progress form. On the
6// desktop it is an SVG of one rounded track whose three equal segments run
7// plan, implement, and review. register.tsx draws it as the desktop's `Svg`
8// element, an image, so it cannot read the app's theme keys. In a
9// browser-pane preview, a `prefers-color-scheme` rule inside such an image
10// followed the browser's setting, not the page's (M204). The track draws
11// from one palette meant for a light and a dark ground instead (M217).
12//
13// Back to front: the ground; a field of 2-pixel specks from the left edge
14// to the head (the active phase's fill edge), sparse at the left and dense
15// near the head, each gray or the active phase's color, the colored share
16// growing toward the head; the item ticks; the two phase-edge marks; and
17// the pill on the head, in the active phase's color, its label bold and its
18// count dimmer. Item ticks fall in the active phase's segment, past the
19// head, and only when the items are SPACING pixels apart or more.
20// The operator picked this look at a live look over a solid dither per
21// segment and two other speck designs (M204).
22//
23// The track is TRACK_PX wide where the row has room, and shorter where it
24// does not. A track too short for the whole pill shows the pill's short
25// text: its count alone, `none` for a section with no boxes, or `Planned`
26// on the idle row (M206, the operator's pick from three narrow looks).
27//
28// In the terminal the track is a run of braille cells on the theme's
29// `userMessageBackground` ground, with the same specks as dots and the pill
30// as `inverseText` text on the phase's theme key (`brailleSpans`, M206, the
31// operator's pick from five terminal looks, in theme keys since M217).
32
33export const TRACK_PX = 360
34export const TRACK_H = 18
35
36const CELL = 2
37const ROWS = TRACK_H / CELL
38// The least spacing of item ticks, in pixels.
39export const SPACING = 6
40// palette
41// The desktop track's colors, the only raw colors the mod draws (M217). The
42// image cannot read theme keys, so the ground, the gray specks, and the
43// marks are translucent grays for a light or a dark ground. The phase fills
44// keep the M204 hues, darker, so the pill's white label and its count at
45// COUNT_OPACITY reach the WCAG 2.2 text contrast of 4.5:1 on the fill
46// whatever the app's ground (the band tests compute it).
47const GROUND = 'rgba(128,128,128,0.16)'
48const GRAY = 'rgb(160,160,160)'
49const MARK = 'rgb(200,200,200)'
50const PHASE_FILLS: Record<FlowPhase, string> = {
51  plan: 'rgb(71,103,158)',
52  implement: 'rgb(152,85,57)',
53  review: 'rgb(68,113,81)',
54}
55const PILL_LABEL = 'rgb(255,255,255)'
56// palette end
57const COUNT_OPACITY = 0.85
58// A speck's strength, by one of three levels.
59const LEVELS = [0.4, 0.65, 0.95]
60const PILL_H = TRACK_H - 2
61// The pill's width per character of its label and its count, and its side
62// padding, an estimate for the system font at the pill's size.
63const LABEL_CHAR = 6.3
64const COUNT_CHAR = 5.6
65const COUNT_GAP = 5
66const PILL_PAD = 16
67
68// A fixed hash of a cell, in [0, 1), so every drawing of a state is the
69// same.
70function hash(x: number, y: number, salt: number): number {
71  let h = (x * 374761393 + y * 668265263 + salt * 2147483647) | 0
72  h = Math.imul(h ^ (h >>> 13), 1274126177)
73  h ^= h >>> 16
74  return ((h >>> 0) % 100000) / 100000
75}
76
77// A speck's chance at a share `t` of the way from the left edge to the
78// head, and its chance of the phase's color rather than gray.
79const density = (t: number) => 0.22 + 0.7 * Math.pow(t, 1.4)
80const colored = (t: number) => 0.95 * Math.pow(t, 2.2)
81
82// Two decimals at most, no trailing zeros.
83function n(value: number): string {
84  return String(Math.round(value * 100) / 100)
85}
86
87function escape(text: string): string {
88  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
89}
90
91// The active phase's fill edge, as a share of the track from the left.
92function headShare(flow: Flow): number {
93  const index = FLOW_PHASES.indexOf(flow.phase)
94  const fill = flow.fills[index]
95  return (index + fill.num / fill.den) / 3
96}
97
98// The active phase's fill edge, in pixels from the left of a track `width`
99// pixels wide.
100export function headOf(flow: Flow, width = TRACK_PX): number {
101  return width * headShare(flow)
102}
103
104// The x of each item tick: the active segment's inner item edges k with
105// checked < k < n, none when the items are closer than SPACING. The test
106// compares whole numbers, k × den > num × n, so an edge at the head never
107// draws through rounding.
108export function ticksOf(flow: Flow, width = TRACK_PX): number[] {
109  const segment = width / 3
110  const index = FLOW_PHASES.indexOf(flow.phase)
111  const items = index === 0 ? 0 : flow.items[index - 1]
112  const step = segment / Math.max(items, 1)
113  if (items < 2 || step < SPACING) return []
114  const fill = flow.fills[index]
115  const out: number[] = []
116  for (let k = 1; k < items; k++) {
117    if (k * fill.den > fill.num * items) out.push(segment * index + step * k)
118  }
119  return out
120}
121
122// A pill text splits into a bold label and a dimmer count: `Implement 3/7`.
123function pillParts(pill: string): [string, string] {
124  const match = /^(.*) (\d+\/\d+)$/.exec(pill)
125  return match === null ? [pill, ''] : [match[1], match[2]]
126}
127
128function pillWidth(pill: string): number {
129  const [label, count] = pillParts(pill)
130  return label.length * LABEL_CHAR + (count === '' ? 0 : count.length * COUNT_CHAR + COUNT_GAP) + PILL_PAD
131}
132
133// The pill's text on a track `width` pixels wide: the whole pill when it
134// takes a third of the track or less, else its short text.
135export function pillText(flow: Flow, width = TRACK_PX): string {
136  return pillWidth(flow.pill) * 3 <= width ? flow.pill : flow.short
137}
138
139// The pill's left edge and width: its right edge just past the head, kept
140// inside the track.
141export function pillBox(flow: Flow, width = TRACK_PX): { x: number; width: number } {
142  const w = pillWidth(pillText(flow, width))
143  const x = Math.min(Math.max(headOf(flow, width) - w + 6, 1), width - w - 1)
144  return { x, width: w }
145}
146
147// The specks, one path per color and level, in the desktop palette.
148function specks(flow: Flow, width: number): string {
149  const head = headOf(flow, width)
150  const paths = new Map<string, string[]>()
151  for (let cx = 0; cx * CELL < head; cx++) {
152    const x = cx * CELL
153    // The last column is cut at the head, so no speck runs past it.
154    const w = n(Math.min(CELL, head - x))
155    const t = x / head
156    for (let cy = 0; cy < ROWS; cy++) {
157      if (hash(cx, cy, 1) > density(t)) continue
158      const level = Math.floor(hash(cx, cy, 2) * LEVELS.length)
159      const color = hash(cx, cy, 3) < colored(t) ? flow.phase : 'gray'
160      const key = `${color}:${level}`
161      const cells = paths.get(key) ?? []
162      cells.push(`M${x} ${cy * CELL}h${w}v${CELL}h-${w}z`)
163      paths.set(key, cells)
164    }
165  }
166  return [...paths.entries()]
167    .map(([key, cells]) => {
168      const [color, level] = key.split(':')
169      const fill = color === 'gray' ? GRAY : PHASE_FILLS[flow.phase]
170      return `<path class="specks" data-color="${color}" d="${cells.join('')}" fill="${fill}" fill-opacity="${LEVELS[Number(level)]}"/>`
171    })
172    .join('')
173}
174
175export function trackSvg(flow: Flow, width = TRACK_PX): string {
176  const segment = width / 3
177  const head = headOf(flow, width)
178  const marks = [1, 2]
179    .map(i => {
180      const x = segment * i
181      return `<rect class="edge" x="${n(x - 0.5)}" y="2" width="1" height="${TRACK_H - 4}" fill="${MARK}" fill-opacity="${x < head ? 0.7 : 0.25}"/>`
182    })
183    .join('')
184  const ticks = ticksOf(flow, width)
185    .map(x => `<rect class="tick" x="${n(x - 0.5)}" y="${TRACK_H / 2 - 3}" width="1" height="6" fill="${GRAY}" fill-opacity="0.4"/>`)
186    .join('')
187  const [label, count] = pillParts(pillText(flow, width))
188  const pill = pillBox(flow, width)
189  const countSpan = count === '' ? '' : `<tspan dx="${COUNT_GAP}" fill-opacity="${COUNT_OPACITY}" font-weight="500">${escape(count)}</tspan>`
190  return (
191    `<svg xmlns="http://www.w3.org/2000/svg" width="${n(width)}" height="${TRACK_H}" viewBox="0 0 ${n(width)} ${TRACK_H}">` +
192    `<defs><clipPath id="track"><rect width="${n(width)}" height="${TRACK_H}" rx="${TRACK_H / 2}"/></clipPath></defs>` +
193    `<g clip-path="url(#track)"><rect class="ground" width="${n(width)}" height="${TRACK_H}" fill="${GROUND}"/>` +
194    `${specks(flow, width)}${ticks}${marks}</g>` +
195    `<rect class="pill" x="${n(pill.x)}" y="1" width="${n(pill.width)}" height="${PILL_H}" rx="${PILL_H / 2}" fill="${PHASE_FILLS[flow.phase]}"/>` +
196    // `textLength` holds the text to the estimated width, so a wider
197    // fallback font squeezes the text rather than running past the pill.
198    `<text class="pill-text" x="${n(pill.x + PILL_PAD / 2)}" y="${TRACK_H / 2}" textLength="${n(pill.width - PILL_PAD)}" lengthAdjust="spacingAndGlyphs" dominant-baseline="central" font-family="-apple-system,system-ui,sans-serif" font-size="10.5" fill="${PILL_LABEL}">` +
199    `<tspan font-weight="650">${escape(label)}</tspan>${countSpan}</text>` +
200    `</svg>`
201  )
202}
203
204// The terminal track's ground and the pill's text color as theme keys, and
205// its blank cell and its mark at each third as braille characters, the
206// mark drawn in `MARK_KEY` and the specks in the phase's key or
207// `SPECK_KEY`. The pill's text is `inverseText` on the phase's key (M217):
208// white in the light themes, black in the dark.
209export const BRAILLE_GROUND = 'userMessageBackground'
210export const BRAILLE_BLANK = '⠀'
211export const BRAILLE_MARK = '⡇'
212const MARK_KEY = 'subtle'
213const SPECK_KEY = 'inactive'
214export const PILL_TEXT = 'inverseText'
215// The eight dots of a braille cell, as bits of its code point.
216const DOTS = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80]
217
218// The terminal track `cells` columns wide: the specks as braille dots, the
219// pill (one space each side of its text), and a mark at each third past the
220// head. The pill ends at the head's cell, or starts there when that would
221// leave no speck before it. Either way it is then kept inside the track, so
222// on a short track the head can fall inside the pill. The last speck
223// before the pill or the head always draws in the phase's color, so a short
224// track still shows its fill. A track too short for the whole pill in a
225// third of it shows the short text. Runs of one style merge into one span.
226export function brailleSpans(flow: Flow, cells: number): Span[] {
227  const color = FLOW_COLORS[flow.phase]
228  const head = Math.round(cells * headShare(flow))
229  const full = ` ${flow.pill} `
230  const pill = full.length * 3 <= cells ? full : ` ${flow.short} `
231  const before = head - pill.length
232  const start = Math.max(0, Math.min(before >= 1 ? before : head, cells - pill.length))
233  const lastSpeck = Math.min(start, head) - 1
234  const thirds = [Math.round(cells / 3), Math.round((2 * cells) / 3)]
235  const out: Span[] = []
236  const push = (text: string, style: Omit<Span, 'text'>) => {
237    const last = out[out.length - 1]
238    if (last !== undefined && last.color === style.color && last.backgroundColor === style.backgroundColor && last.bold === style.bold) {
239      last.text += text
240    } else {
241      out.push({ text, ...style })
242    }
243  }
244  for (let i = 0; i < cells; i++) {
245    if (i >= start && i < start + pill.length) {
246      push(pill[i - start], { color: PILL_TEXT, backgroundColor: color, bold: true })
247      continue
248    }
249    if (i >= head) {
250      const mark = thirds.includes(i)
251      push(mark ? BRAILLE_MARK : BRAILLE_BLANK, { color: MARK_KEY, backgroundColor: BRAILLE_GROUND })
252      continue
253    }
254    const t = i / head
255    let bits = 0
256    DOTS.forEach((bit, k) => {
257      if (hash(i, k, 1) < density(t)) bits |= bit
258    })
259    const last = i === lastSpeck
260    const hue = last || hash(i, 0, 3) < colored(t) ? color : SPECK_KEY
261    if (last && bits === 0) bits = 0xff
262    push(String.fromCharCode(0x2800 + bits), { color: hue, backgroundColor: BRAILLE_GROUND })
263  }
264  return out
265}
266
types/index.d.ts 127 lines
1// The cairn plugin's `$.state` contract: the values its hooks module keeps
2// for the session. `claude plugin validate` holds the module to it.
3
4// One active milestone. The counts and next items are null when its
5// milestone path is not a readable regular file; a next item is also null
6// when its section holds no open box.
7export type CairnBandRow = {
8  id: string
9  title: string
10  status: string
11  tasksChecked: number | null
12  tasksTotal: number | null
13  criteriaChecked: number | null
14  criteriaTotal: number | null
15  nextTask: string | null
16  nextCriterion: string | null
17}
18
19// A planned milestone whose dependencies are all done.
20export type CairnWorkableRow = { id: string; title: string }
21
22// The active milestones in ROADMAP order, the workable planned milestones
23// by priority and then id, and the repo root of the last read, null when no
24// ROADMAP was found or the working directory could not be read (M210).
25export type CairnBandState = { rows: CairnBandRow[]; workable: CairnWorkableRow[]; root: string | null }
26
27// One active milestone's id and status, as the close button stores them.
28export type CairnBandMark = { id: string; status: string }
29
30// What the close button stores at a press: the active ids and statuses in
31// ROADMAP order, `milestone-review` while that skill runs and moves the band
32// to another row, else null, and the idle row's id while no row is active
33// (M206). A step stored before a reload that names a skill the cairn list
34// has since dropped reads as no step (M200).
35export type CairnBandHidden = { marks: CairnBandMark[]; skill: string | null; idle: string | null }
36
37// The running cairn skill's bare name (`milestone-plan`). A main-loop Stop
38// with no background work in flight, or a prompt the operator types while
39// the session is idle, ends it (M201).
40export type CairnStep = { skill: string }
41
42// One checkbox item of a milestone file's Tasks or Acceptance criteria,
43// less its box, with a wrapped item's lines joined (M205).
44export type CairnPaneItem = { text: string; checked: boolean }
45
46// One active milestone as the cairn pane shows it. `file` is null when the
47// milestone file is missing or its read fails; `log` holds the newest five
48// work-log lines, less their `- `.
49export type CairnPaneMilestone = {
50  id: string
51  title: string
52  status: string
53  file: { goal: string; tasks: CairnPaneItem[]; criteria: CairnPaneItem[]; log: string[] } | null
54}
55
56// What the cairn pane shows (M205): `found` is false when no ROADMAP is
57// found. `next` is `recommend` in scripts/cairn_next.py, its `id` null for
58// planning, and `waiting` is its `waiting`, each row's undone dependencies
59// as written with their status. `candidates` holds the ROADMAP's candidate
60// rows, each its priority and the text before its first `: ` (M207).
61// `blocked` holds the `blocked` rows in ROADMAP order, each with the number
62// of the pull request its milestone file's `Branch/PR` header names, or
63// null (M223), and that pull request's URL up to its number, or null (M224).
64export type CairnPaneState = {
65  found: boolean
66  milestones: CairnPaneMilestone[]
67  next: { action: string; command: string; id: string | null } | null
68  workable: CairnWorkableRow[]
69  waiting: { id: string; title: string; unmet: string[] }[]
70  candidates: { priority: 'high' | 'normal' | 'low'; title: string }[]
71  blocked: { id: string; title: string; pr: number | null; url: string | null }[]
72}
73
74// The state word of a blocked milestone's pull request, as the pane last
75// read it from `gh pr view` (M224).
76export type CairnPrWord = 'merged' | 'closed' | 'changes requested' | 'approved' | 'in review' | 'unknown'
77
78// One read of a blocked milestone's pull request: its state word (M224),
79// and for an open one, its unresolved review threads and the reviews and
80// comments from others newer than its author's last comment or review and
81// its newest commit (M225). `counts` is null for a pull request that is not open, or
82// whose count read failed.
83export type CairnPrRead = { word: CairnPrWord; counts: { unresolved: number; unanswered: number } | null }
84
85// One open pull request that the operator opened from a `hotfix-*` branch,
86// as `gh pr list` gave it (M226).
87export type CairnHotfixPr = { number: number; title: string; url: string }
88
89// The hotfix list that the last read wrote, and the repo root it is for
90// (M226): a good `gh pr list` read's result, the list kept after a failed
91// call in the same root, or empty when no call ran or the kept list was
92// for another root. `root` is null before any read and when no ROADMAP
93// root was known.
94export type CairnHotfixRead = { root: string | null; prs: CairnHotfixPr[] }
95
96declare module 'claude-code' {
97  interface PluginState {
98    // Each value is kept under a shape tag (register.tsx), so a value an
99    // older layout wrote reads as absent after a reload. `dismissed` is
100    // null while the band shows, and `step` is null while no cairn skill
101    // runs.
102    cairn: {
103      band: Shaped<CairnBandState>
104      dismissed: Shaped<CairnBandHidden | null>
105      step: Shaped<CairnStep | null>
106      // True from a cairn skill's prompt until the next prompt, Stop, turn
107      // end, or session end (M201).
108      expanded: Shaped<boolean>
109      // True from a Stop that ends a cairn skill's step until the next idle
110      // typed prompt that enters, cairn skill, or session end. A row that
111      // draws the action Buttons then carries the Clear Button too, when it
112      // has room (M216).
113      ended: Shaped<boolean>
114      // What the cairn pane shows, written at each refresh (M205).
115      pane: Shaped<CairnPaneState>
116      // Each blocked milestone's pull request state word, and its counts,
117      // by its URL, as the last read at a pane open or a Refresh press found
118      // them (M224, M225). A URL with no entry has not been read.
119      prs: Shaped<Record<string, CairnPrRead>>
120      // The open hotfix pull requests and the repo root they are for, as
121      // the last read at a pane open or a Refresh press wrote them (M226).
122      // Their words and counts sit in `prs` by URL.
123      hotfixes: Shaped<CairnHotfixRead>
124    }
125  }
126}
127