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

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.
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 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 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:
| Word | GitHub state | Button |
|---|---|---|
merged | MERGED | Finish runs /cairn:milestone-review <id> |
changes requested | OPEN, review decision CHANGES_REQUESTED | Revise runs /cairn:milestone-implement <id> |
closed | CLOSED | Check runs /cairn:milestone |
approved | OPEN, review decision APPROVED | none |
in review | OPEN, any other review decision, or none | none |
unknown | the call failed, exited non-zero, or gave no known state | none |
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.
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 -->|/clhooks/status/register.tsx 1062 lines1import { 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}
1062hooks/status/band.ts 332 lines1import 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}
332hooks/status/counts.ts 151 lines1// 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}
151hooks/status/pane.ts 457 lines1import 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}
457hooks/status/reader.ts 568 lines1// 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}
568hooks/status/track.ts 266 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
266types/index.d.ts 127 lines1// 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