Plan pane: the steps the session is working through, and the PRs/MRs that need attention.

A Claude Code mod that draws the plan your session is working through as a live pane, with the PRs/MRs it touches and what each needs from you.
gh and/or glab installed and logged in, for PR/MR tracking. Plan progress works without them./plugin marketplace add phatjam98/claude-mods
/plugin install waypoint@claude-mods
The validator's form:
/plugin install waypoint --marketplace phatjam98/claude-mods
From a clone, for a single session:
claude --plugin-dir mods/waypoint
Docked and wide (64 columns or more):
⚑ claude-mods repo + waypoint, a plan-progress pane mod WP5 · 6 of 7 · 85%
██████████████████████████████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░
✓ WP0 Repo bootstrap and dev loop
! WP5 Docs, CI, PR, release 3/6 waiting on you
✓ README, CHANGELOG, marketplace entry
✓ CI workflow green on a clean runner
· mark PR #1 ready and merge (after WP6)
▶ WP6 Visual redesign and "Waiting on you" handoff
── Pull requests · 1 open · none need you · polled 0s ago ────────────────────────────────────
○ #1 feat(waypoint): plan pane with PR/MR attention and handoff note WP6 draft copy · untrack
── Waiting on you · 12m ago ──────────────────────────────────────────────────────────────────
waypoint 0.2.0 is pushed to PR #1 as commit f7a4a05 …
! action Look at the redesigned pane and this note on screen
! decision Mark PR #1 ready and merge? #1
──────────────────────────────────────────────────────────────────────────────────────────────
~/.claude/plans/we-are-going-to-purring-origami.md · changed 12m ago r refresh · p path · c close
Narrow dock. The docked pane asks the engine for 48 columns (paneColumns), and from 44 to 63 columns it keeps the sectioned layout, adapted: one-space indents, WP6 · 5/7 · 71% in the header, needs you on a blocked step, PR rows without the step or the copy · untrack buttons (the link opens the PR; /waypoint untrack <ref> still works), a PR header without none need you or n other, and the footer on two rows, the path and then the keys:
⚑ claude-mods repo + waypo… WP6 · 5/7 · 71%
████████████████████████████████▓▓▓▓▓▓░░░░░░░░
✓ WP0 Repo bootstrap and dev loop
✓ WP1 Mod skeleton, contract, plan parser
! WP5 Docs, CI, PR, release 3/6 needs you
✓ README, CHANGELOG, marketplace entry
· mark PR #1 ready and merge (after WP6)
▶ WP6 Visual redesign and "Waiting on you"…
── Pull requests · 1 open · polled 3s ago ─────
○ #1 feat(waypoint): plan pane with P… draft
── Waiting on you · 12m ago ───────────────────
waypoint 0.2.0 is pushed to PR #1 as commit
f7a4a05: the pane redesign and this very
note. After your sign-off I mark #1 ready…
! action Look at the redesigned pane and…
! decision Mark PR #1 ready and merge? #1
───────────────────────────────────────────────
~/.claude/plans/we-are-going-to-purring-o…
r refresh · p path · c close
Inline, or docked narrower than 44 columns (8 rows at most, no footer):
⚑ ship the widget 3/9 33%
████████████████▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░
✓ 3 done · WP0 WP1 WP2
▶ WP3 Draw the pane 1/2
· WP4 Step number 4
· WP5 Step number 5 +3 more
! waiting on you · Mark PR #13 ready a… (+1)
✗ 1 PR needs you · #13 ci failed · polled 0s…
The pane shows:
done of total and the percentage (3/9 33% in compact);█ (green), the current step's own share ▓ (plan-mode colour, or warning when it is blocked) and what remains ░. It shows how far along the plan is and where the session is in it;✓ done, ▶ in progress (bold), · pending (dim), ! blocked on you (warning), – skipped (struck through); the current step shows its child-checkbox progress (2/4) and, in the wide layout, its checkboxes below it: one per line under the title (8 at most, then +n more) when the rows allow, else all in one row. When the steps do not fit, leading finished steps fold into one ✓ n done row and a +n more row ends the list; the current step is always shown;copy · untrack buttons (the narrow dock drops the step and the buttons). A gh/glab error (not logged in, rate-limited, not installed) shows as a note under the header. Merged and closed PRs are not listed. Compact draws one line for the PR most in need;! for those blocking the plan, with a link when the item names a tracked PR/MR or a URL. Its header says from an earlier session when the note was left by another session. Compact draws one line, ! waiting on you · <first item>, and so does every other pane state (no plan tracked, planning, file missing, no steps), on its first row. This section and the PR section keep their rows before the step list does; in the wide layout each section opens with a rule carrying its header (── Pull requests ───, ── Waiting on you ───) and a full-width rule sits above the footer. When rows run short, the current step's checkboxes go back to one row first; then the sections give up the blank lines between them (and the footer rule), then the +n more item row, then fold to one line each (the handoff first, then the PRs);~ for your home folder), when the plan file last changed, and refresh / path / close buttons.With no plan tracked, /waypoint lists up to 5 recent plans from planDir, newest first, each with a hotkey 1–5 to track it. Each row reads <title> · 71% · 36m ago: the plan's title (its first # heading, without a Plan: prefix; the file slug when it has none or cannot be read), its progress when a session has tracked it before, and when the file last changed. The title is cut to fit the pane. In the wide layout a dim line under each row names the folder it was last tracked from (in ~/src/claude-mods). The same numbered list comes back in the command's reply, so /waypoint use 2 tracks the second plan without the pane. /waypoint use with a name that matches no file replies with that list too.
When the pane is not placed (narrow terminal, or closed), a one-line band above the prompt carries the summary (for example the current step, steps done, PRs needing you) with an open button. While a handoff note is up it reads ⚑ waiting on you (2) · WP5 of 6 · 5 done, in warning colour, and with no plan tracked it still shows as ⚑ waiting on you (2).
The status line carries a short segment: ⚑ 3/9 WP2, with ! after the step when blocked, ✓ when complete, and · needs you while a handoff note is up (⚑ 5/6 WP5 · needs you; ⚑ needs you with no plan tracked, ⚑ no steps · needs you for a plan with no steps).
A step is a heading whose text starts with a marker:
| Marker | Meaning |
|---|---|
[ ] | pending |
[~] | in progress |
[x] | done |
[!] | blocked on you |
[-] | skipped |
Example: ### [~] WP2 — Remove the old field. A step-like heading with no marker counts as pending. Plans with no markers at all are still recognised by their ### WP1 — / ## Step 1 shapes, and prose sections (Context, Decisions, Verification and the like) are not steps. Child items written as - [ ] under a step show as its checkboxes.
While a plan is tracked, the mod adds a short section to the system prompt asking the model to keep the markers current and to add a - PR: <url> line under a step when it opens a PR. In plan mode it asks the model to write markers on the step headings.
PR/MR links are read from a step in two ways: any full PR/MR URL anywhere in the step, and short refs (#13, !6677, group/project!6677, against this repo) only on a - PR: <url or #n> / - MR: … line under the step, or as a markdown link target (fix). A bare #123 in prose is ignored.
Of 21 plans on one machine, 19 had no status markers, so the parser had to guess. waypoint ships a one-page Plan Style Guide (a skeleton, four rules, the lint codes) and applies it in four places: appended to the engine's full plan-mode reminder (not the sparse repeats), as a lint note after a plan file is written, in /waypoint lint, and in /waypoint conform. The styleGuide option gates the first two; the commands always work.
When the model stops because only you can move the plan (a decision, a merge, a credential, an answer), the system-prompt section asks it to call the tool mcp__waypoint__handoff first, with a 2–4 sentence summary of where things stand and one item per thing you must do, decide or answer (decision, action or answer; blocking when the plan cannot move without it; an optional PR/MR ref). The note shows in the pane as "Waiting on you", in the band and on the status line, and raises a toast (and a native notification with notify on). When work resumes the model calls the tool with clear: true, or replaces the note. An input with neither a summary nor an item comes back to the model as an error. /waypoint handoff prints it and /waypoint handoff clear clears it yourself.
The note is kept per plan: it comes back when a later session resumes the same plan, as a recap marked from an earlier session, and is dropped after 30 days untouched. While such a note is showing, a second short system-prompt section tells the model it is there (with the summary's first 120 characters), so it can clear or replace it once you have acted.
| Command | Does | ||
|---|---|---|---|
/waypoint | Re-read the plan and open the pane (at any width) | ||
/waypoint close | Close the pane | ||
/waypoint refresh | Re-read the plan and poll tracked PRs now | ||
| `/waypoint use <path\ | slug\ | n>` | Track a plan by path, by slug in planDir, or by its number 1–5 in the recent-plans list; a name that matches no file replies with that list |
/waypoint track <ref> | Track a PR/MR: a URL, #n / !n (against this repo), group/project!n, or host/path#n / host/path!n | ||
/waypoint untrack <ref> | Stop tracking a PR/MR: any form track takes, its label, or its full id (github.com/o/r#5) | ||
/waypoint handoff | Print the model's current "waiting on you" note | ||
/waypoint handoff clear | Clear that note | ||
| `/waypoint lint [path\ | slug\ | n]` | Print the style report for the tracked plan, or the named one: what the parser saw and what to fix |
| `/waypoint conform [rewrite] [path\ | slug\ | n]` | Submit an instruction as a prompt from the waypoint plugin (shown in the transcript as its message); once the session is idle the model applies the style guide to the plan with the Edit tool and shows the diff. If the submit fails, the instruction rides along with your next prompt. Additive by default; rewrite may also reshape headings and move checkbox lines under steps |
/waypoint reset | Forget the tracked plan and its handoff note | ||
| `/waypoint layout auto\ | compact\ | wide` | Choose the pane layout; this choice wins over the layout option |
/waypoint layout | Forget that choice, so the layout option applies again | ||
/waypoint status | One-line summary, then a pane: line (placement, drawn width, width asked) |
The pane hotkeys r (refresh), p (copy path) and c (close) sit in the footer of the wide layout only (the per-PR copy and untrack buttons have no hotkey), and 1–5 (pick a recent plan) exist only when no plan is tracked. All work only while the pane is focused (ctrl+x, then tab). The pane opens automatically only at 144 columns or wider; /waypoint opens it at any width.
Set these in /config, or under pluginConfigs["waypoint"].options in settings.json.
| Option | Default | Purpose |
|---|---|---|
openOnStart | true | Open the pane when a plan is found at session start (placed only in wide terminals; the band shows otherwise) |
band | true | Draw a one-line plan summary above the prompt while the pane is not placed |
statusLine | true | Pin the current step under the prompt |
layout | auto | auto picks compact inline and wide when docked; or compact / wide |
paneColumns | 48 | Body columns the docked pane asks for; 0 leaves the engine's share. Ignored inline and once you drag the dock. |
planDir | ~/.claude/plans | Where /waypoint use <slug> looks for plan files |
planPollSeconds | 5 | How often the plan file is stat-ed for outside edits; 0 disables |
resumeDays | 14 | A plan last touched within this many days comes back in a new session in the same directory |
pollSeconds | 120 | How often tracked PRs/MRs are polled with gh/glab; minimum 30 |
notify | false | Also raise a system notification when a PR/MR starts needing attention or the model leaves a handoff note (toasts always show) |
styleGuide | true | Append the plan style guide to the plan-mode reminder and leave a lint note after a plan file is written (/waypoint lint and conform work either way) |
planDir (default ~/.claude/plans) for the recent-plans picker and /waypoint use <slug>. For the picker it also reads the 5 newest plan files there (for their titles) and its own last:<cwd> records (for their progress and folder), only when no plan is tracked or on /waypoint use. It also reads any .md under planDir right after a main-session Write or Edit of it (to lint it), and the plan file named to /waypoint lint or conform.HOME environment variable, to expand ~ in paths.origin remote through the engine ($.session.repo), only to know which host and repo a short #123 / !123 (on a - PR: line, in /waypoint track, or in a gh pr / glab mr command) belongs to.gh or glab, from the main session and from subagents alike: the command text and its output, to find PR/MR references. It reads nothing from other Bash calls. From the output it takes only the first PR/MR URL, and only when the command named no PR (gh pr create, a bare gh pr view). A --repo / -R value or PR number carrying quotes, commas, $ or braces (a gh call quoted inside a script) is ignored.Write and Edit calls (main session only), to re-read the plan when its file is the one written. A write or edit of a file under planDir is also linted and may leave a hidden note.ExitPlanMode calls (main session only), to pick up the plan file the call names.plan_mode, plan_mode_reentry, plan_mode_exit; main session only), for the plan file path and whether plan mode is on./waypoint conform instruction as hidden context once, when the plugin's own submit of it failed. It never reads or changes what you typed.gh pr view --json and glab mr view -F json, using your existing logins: every pollSeconds, 750 ms after a new PR/MR is discovered, and on /waypoint refresh.mcp__waypoint__handoff, which the model calls to leave (or clear) the "waiting on you" note. The tool only records what the model passes it (a summary, up to 10 items, control characters stripped, at most 1200 + 10 × (200 + 500) characters: a 200-character title and a 500-character ref per item); it runs nothing and reads nothing.### [ ] WP1 — Title pending, [~] in progress, [x] done, [!] blocked on the user", to edit a heading to [~] when starting and [x] when finished, to add - PR: <url> under a step when it opens a PR, and to call mcp__waypoint__handoff when it stops waiting on you (and with clear: true when it resumes). While a handoff note from an earlier session is showing, a second section (waypoint:handoff) quotes the first 120 characters of its summary and asks the model to clear or replace it once you have acted.Write or Edit to a file under planDir (a lint report: what the parser saw and what to fix), and, separately, the instruction /waypoint conform submits as a prompt from the plugin (visible in the transcript, not hidden context; if the submit fails it rides along with your next prompt). After an edit outside plan mode the lint note is a pointer, not an order: it says the shape may be intentional and names /waypoint conform. The first two are gated by styleGuide. Linting reads the plan file just written; any edit comes from the model's own Edit tool.p (plan path) or copy (PR URL).notify option is on (a PR/MR starting to need attention, a new handoff note); toasts show otherwise.layout (your /waypoint layout choice); last:<cwd> = { path, title, at, percent, sessionId } (to resume the plan in that directory); waypoint:prs:plan/<plan path> and waypoint:prs:session/<session id> = the tracked PR/MR entries (id, URL, title, branch, state, CI/review/merge status, when seen and polled). Closed PRs older than 7 days, and PRs/MRs the CLI reported not found three polls running, are pruned on every write and at session start; also at session start other sessions' session keys with no activity in the last day, and plan keys with no open PR and nothing newer than 7 days, are deleted. waypoint:handoff:plan/<plan path> and waypoint:handoff:session/<session id> = the handoff note ({ summary, items, at, sessionId }), the session key folded into the plan key when a plan becomes tracked; notes older than 30 days, and other sessions' session-key notes older than a day, are deleted at session start (never the current session's), and /waypoint reset deletes the current one./waypoint to open it at any width; below that width the band above the prompt shows the summary.paneColumns (48 by default), but a width you have ever set by hand wins over it: the engine keeps one dock width for every pane, in ~/.claude.json under pluginPanes.dockColumns, and applies it in every session. To resize by hand, focus the pane (Ctrl+X then Tab) and press Ctrl+X then Right or Down to shrink, Ctrl+X then Left or Up to grow; the new width is kept. To let paneColumns apply again, quit every Claude Code session and remove dockColumns from ~/.claude.json. /waypoint status ends with a pane: line giving the placement (dock or inline), the width the engine drew and the width asked.gh auth status / glab auth status. The pane shows the error class and retries with backoff.#123 on a - PR: line or a PR URL in a step is always tracked. Run /waypoint untrack <ref>; one that does not exist is dropped after three not-found polls.claude plugin validate . in mods/waypoint.claude --plugin-dir . # load it in a session
claude plugin validate --strict .
claude plugin test .
npx -p typescript tsc -p . # after the first interactive load lays .claude-plugin/types/
See the repo-level CLAUDE.md for code conventions.
MIT.
hooks/register.tsx 1461 lines1// waypoint: the only `on()` site. Every function that touches `$` is declared
2// top level here (the validator credits `$` calls only to those); the logic
3// they call lives in the pure modules beside this file.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, RenderElement, RenderInput, RenderSurface } from 'claude-code'
7
8import type { Handoff, Layout, PlanSnapshot, PlanSource, PrEntry, PrsStore, PrsView, RepoRef, StoredPlan, Tracked, WaypointUi } from '../types'
9import { drawBand, drawError, drawPane } from './pane'
10import {
11 CLEARED_RESULT,
12 HANDOFF_DESCRIPTION,
13 HANDOFF_SCHEMA,
14 HANDOFF_TOOL,
15 handoffText,
16 handoffToast,
17 NOT_NOTED,
18 noteResult,
19 parseHandoffInput,
20 sanitizeHandoff,
21 sweepDrops,
22} from './handoff'
23import type { Kit, PaneActions } from './pane'
24import { parsePlan, planTitle } from './plan'
25import { earlierHandoffSection, HANDOFF_SECTION_ID, planSection, SECTION_ID } from './prompt'
26import { conformInstruction, findingsKey, lintPlan, lintText, PLAN_STYLE_GUIDE, writeNote } from './style'
27import {
28 applyPoll,
29 dropPlanRefsNotIn,
30 emptyStore,
31 errorText,
32 extractPlanRefs,
33 glabEnv,
34 KICKOFF_MS,
35 newToasts,
36 parseGhPr,
37 parseGlabMr,
38 parseRemote,
39 pickDue,
40 pollIntervalMs,
41 prId,
42 prLabel,
43 prune,
44 repoNameOf,
45 resolveManualRef,
46 rowOf,
47 STAGGER_MS,
48 idleSessionStore,
49 sanitizeStore,
50 staleStore,
51 toastsFor,
52 toView,
53 upsert,
54 viewArgs,
55 discoverFromBash,
56 classifyError,
57} from './prs/index'
58import type { PollOutcome, PrErrorClass, Toast } from './prs/index'
59import { expandHome, isLastKey, nextSource, normalizePath, recordFor, resolveUseArg, samePath, shouldResume, storeKey } from './track'
60import { bandQuiet, buildBandModel, buildPaneModel, orderRecent, paneStatusLine, pickerText, rowBudget, statusText } from './view'
61import type { PaneDraw, RecentPlan } from './view'
62
63// --- state -------------------------------------------------------------------
64
65const PANE_ID = 'waypoint'
66const PANE_TITLE = 'Waypoint'
67const LAYOUT_KEY = 'layout'
68const MAX_PLAN_BYTES = 4 * 1024 * 1024
69const CLI_TIMEOUT_MS = 60_000
70const REPO_TTL_MS = 60_000
71const PANE_SYNC_MS = 5_000
72const SUBCOMMANDS =
73 '[close|refresh|use <path|slug|n>|track <ref>|untrack <ref>|handoff [clear]|lint [path|slug|n]|conform [rewrite] [path|slug|n]|reset|layout [auto|compact|wide]|status]'
74const USAGE = `Usage: /waypoint ${SUBCOMMANDS}`
75/** The style guide's own heading: an attachment already carrying it is not given it twice. */
76const STYLE_MARK = '# Waypoint: plan style'
77
78const EMPTY_UI: WaypointUi = { paneOpen: false, isPlaced: false, layout: null, inPlanMode: false, lastReadAt: 0, lastError: null }
79const EMPTY_PRS: PrsView = { entries: [], lastPolledAt: null, note: '' }
80
81const tracked = atom({ plugin: 'waypoint', key: 'tracked' } as const, null as Tracked | null)
82const plan = atom({ plugin: 'waypoint', key: 'plan' } as const, null as PlanSnapshot | null)
83const ui = atom({ plugin: 'waypoint', key: 'ui' } as const, EMPTY_UI)
84const prs = atom({ plugin: 'waypoint', key: 'prs' } as const, EMPTY_PRS)
85const prsToasted = atom({ plugin: 'waypoint', key: 'prsToasted' } as const, [] as string[])
86const handoff = atom({ plugin: 'waypoint', key: 'handoff' } as const, null as Handoff | null)
87/** Plan file (absolute path) → findingsKey of the last lint note left after a write, so an identical note is not repeated. */
88const lintNoted = atom({ plugin: 'waypoint', key: 'lintNoted' } as const, {} as Record<string, string>)
89
90type Cfg = {
91 openOnStart: boolean
92 band: boolean
93 statusLine: boolean
94 layout: Layout
95 /** Docked width asked of the engine; undefined leaves it the engine's. */
96 paneColumns: number | undefined
97 planDir: string
98 planPollMs: number
99 resumeDays: number
100 pollMs: number
101 notify: boolean
102 /** Append the plan style guide to the plan-mode reminder and lint plan files after a write. */
103 styleGuide: boolean
104}
105
106const bool = (v: unknown, d: boolean): boolean => (typeof v === 'boolean' ? v : v === 'true' ? true : v === 'false' ? false : d)
107const num = (v: unknown, d: number): number => {
108 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
109 return Number.isFinite(n) ? n : d
110}
111
112/** 0 (or less) hands the width to the engine; otherwise 40..200 whole columns. */
113const paneColumns = (v: unknown): number | undefined => {
114 const n = num(v, 48)
115 return n <= 0 ? undefined : Math.min(200, Math.max(40, Math.floor(n)))
116}
117
118export function parseOptions(o: Record<string, unknown>): Cfg {
119 const layout = o.layout === 'compact' || o.layout === 'wide' ? o.layout : 'auto'
120 const pp = num(o.planPollSeconds, 5)
121 return {
122 openOnStart: bool(o.openOnStart, true),
123 band: bool(o.band, true),
124 statusLine: bool(o.statusLine, true),
125 layout,
126 paneColumns: paneColumns(o.paneColumns),
127 planDir: typeof o.planDir === 'string' && o.planDir.trim() !== '' ? o.planDir.trim() : '~/.claude/plans',
128 planPollMs: pp <= 0 ? 0 : Math.max(2, pp) * 1000,
129 resumeDays: Math.max(0, num(o.resumeDays, 14)),
130 pollMs: pollIntervalMs(o.pollSeconds),
131 notify: bool(o.notify, false),
132 styleGuide: bool(o.styleGuide, true),
133 }
134}
135
136// Module state. A hot reload runs `register` (and `session.start`) again with
137// fresh module variables; the engine cancels the old environment's waits, and
138// handles kept here are cancelled before re-arming. Atoms and `$.store` persist.
139let cfg: Cfg = parseOptions({})
140let interactive = false
141let generation = 0
142let home: string | null = null
143let planTimer: { cancel: () => void } | undefined
144let prTimer: { cancel: () => void } | undefined
145let kickTimer: { cancel: () => void } | undefined
146let paneTimer: { cancel: () => void } | undefined
147let inflight: Promise<void> | null = null
148let queued: Promise<void> | null = null
149let seen: { path: string; mtimeMs: number } | null = null
150let lastStatus: string | undefined | null = null // null: never sent
151let repoCache: { at: number; repo: RepoRef | null } | null = null
152let ticking = false
153let recentPlans: RecentPlan[] = []
154/** The last Pane render's placement and body width; module memory, since render hooks never write state. */
155let lastPaneDraw: PaneDraw | null = null
156/** A `/waypoint conform` instruction not yet handed to the session; the next prompt carries it if the submit failed. */
157let pendingConform: { text: string; at: number } | null = null
158/** A pending conform instruction older than this is dropped, not attached to a prompt. */
159const PENDING_CONFORM_MS = 10 * 60_000
160
161const bg = (p: Promise<unknown>): void => {
162 p.catch(() => {})
163}
164const basename = (p: string): string => p.split('/').pop() ?? p
165/** Every step finished (done or skipped); a plan with no steps is never complete. */
166const isComplete = (s: PlanSnapshot): boolean =>
167 s.counts.total > 0 && s.counts.pending + s.counts.active + s.counts.blocked === 0
168const doneOf = (s: PlanSnapshot): string => `${s.counts.done + s.counts.skipped}/${s.counts.total} done`
169
170/** A command's answer: shown text, plus hidden notes the model reads after it. */
171type CommandReply = { text: string; context?: string[] }
172
173/** The full plan-mode reminder with the plan style guide appended; any other text as it came. Never null. */
174function withStyleGuide<R extends { text: string | null }>(r: R): R {
175 if (typeof r.text !== 'string' || r.text.trim() === '' || r.text.includes(STYLE_MARK)) return r
176 return { ...r, text: `${r.text}\n\n${PLAN_STYLE_GUIDE}` }
177}
178
179/** `planDir` as an absolute path: `~` expanded, a relative one taken from the home dir. */
180const planDirAbs = (planDir: string, home: string): string => {
181 const d = expandHome(planDir, home)
182 return normalizePath(d.startsWith('/') || home === '' ? d : `${home}/${d}`)
183}
184
185/** `abs` is a `.md` file inside `dir` (both normalized absolute paths). */
186const isPlanDirPath = (abs: string, dir: string): boolean =>
187 abs.startsWith('/') && /\.md$/i.test(abs) && abs.startsWith(dir === '/' ? '/' : `${dir}/`)
188
189// --- helpers that touch `$` ----------------------------------------------------
190
191async function homeDir($: EngineInterface): Promise<string> {
192 if (home !== null) return home
193 try {
194 home = (await $.env.get('HOME')) ?? ''
195 } catch {
196 home = ''
197 }
198 return home
199}
200
201async function setUi($: EngineInterface, patch: Partial<WaypointUi>): Promise<void> {
202 await update($, ui, prev => {
203 const keys = Object.keys(patch) as (keyof WaypointUi)[]
204 return keys.every(k => prev[k] === patch[k]) ? prev : { ...prev, ...patch }
205 })
206}
207
208async function setStatus($: EngineInterface): Promise<void> {
209 if (!cfg.statusLine) return
210 try {
211 const t = await read($, tracked)
212 const s = await read($, plan)
213 const u = await read($, ui)
214 const h = await read($, handoff)
215 const text = statusText(t && s && samePath(s.path, t.path) ? s : null, u.inPlanMode, h)
216 if (text === lastStatus) return
217 lastStatus = text
218 $.ui.status(text)
219 } catch {
220 // the status line is cosmetic
221 }
222}
223
224/**
225 * Track `path` from `source` under the precedence rules. Returns what happened;
226 * the caller decides whether to refresh (a plan file in plan mode may not exist yet).
227 */
228async function track($: EngineInterface, path: string, source: PlanSource): Promise<'replace' | 'same' | 'keep'> {
229 const h = await homeDir($)
230 const p = normalizePath(expandHome(path, h))
231 const cur = await read($, tracked)
232 const d = nextSource(cur, { path: p, source }, h)
233 if (d === 'keep') return d
234 if (d === 'same') {
235 if (cur && source === 'manual' && cur.source !== 'manual') await update($, tracked, t => (t ? { ...t, source } : t))
236 return d
237 }
238 const now = await $.clock.now()
239 const cwd = await $.session.cwd()
240 await update($, tracked, () => ({ path: p, source, since: now, cwd, missing: false }))
241 await update($, plan, s => (s && samePath(s.path, p) ? s : null))
242 seen = null
243 await prsSetPlanPath($, p)
244 await setStatus($)
245 return d
246}
247
248/** Re-read the tracked plan: coalesced, stat-gated (unless forced), hash-gated. Never throws. */
249function refresh($: EngineInterface, reason: 'event' | 'poll' | 'force'): Promise<void> {
250 if (!inflight) {
251 inflight = refreshOnce($, reason).finally(() => {
252 inflight = null
253 })
254 return inflight
255 }
256 if (reason !== 'force') return inflight
257 if (!queued) {
258 queued = inflight.then(() => {
259 queued = null
260 return refresh($, 'force')
261 })
262 }
263 return queued
264}
265
266async function refreshOnce($: EngineInterface, reason: 'event' | 'poll' | 'force'): Promise<void> {
267 try {
268 const t = await read($, tracked)
269 if (!t) return
270 let st: { mtimeMs: number; size: number }
271 try {
272 st = await $.fs.stat(t.path)
273 } catch {
274 // In plan mode the file may not be written yet: that is planning, not missing.
275 const u = await read($, ui)
276 if (!t.missing && !u.inPlanMode) {
277 await update($, tracked, cur => (cur && samePath(cur.path, t.path) ? { ...cur, missing: true } : cur))
278 await setStatus($)
279 }
280 return
281 }
282 const prev = await read($, plan)
283 const prevSame = prev !== null && samePath(prev.path, t.path) ? prev : null
284 if (seen === null && prevSame) seen = { path: t.path, mtimeMs: prevSame.mtimeMs }
285 if (reason !== 'force' && !t.missing && seen?.path === t.path && seen.mtimeMs === st.mtimeMs) return
286 if (st.size > MAX_PLAN_BYTES) {
287 await setUi($, { lastError: `plan file over 4 MiB: ${basename(t.path)}` })
288 seen = { path: t.path, mtimeMs: st.mtimeMs }
289 return
290 }
291 let raw: string
292 try {
293 raw = await $.fs.read(t.path)
294 } catch (err) {
295 await setUi($, { lastError: `could not read ${basename(t.path)}: ${String((err as Error)?.message ?? err)}` })
296 return
297 }
298 const now = await $.clock.now()
299 seen = { path: t.path, mtimeMs: st.mtimeMs }
300 if (t.missing) await update($, tracked, cur => (cur && samePath(cur.path, t.path) ? { ...cur, missing: false } : cur))
301 await setUi($, { lastReadAt: now, lastError: null })
302 const snap = parsePlan(raw, { path: t.path, mtimeMs: st.mtimeMs, now })
303 // A forced read always lands: the parser may have changed under a reload
304 // or `/waypoint refresh`, and the file's hash cannot know that.
305 if (reason !== 'force' && prevSame && prevSame.hash === snap.hash && !t.missing) return
306 await update($, plan, () => snap)
307 try {
308 await onPlanParsed($, snap)
309 } catch {
310 // PR extraction never breaks the plan refresh
311 }
312 try {
313 const rec: StoredPlan = { path: t.path, title: snap.displayTitle, at: now, percent: snap.percent, sessionId: await $.session.id() }
314 await $.store.set(storeKey(t.cwd), rec)
315 } catch {
316 // resume is best effort
317 }
318 await setStatus($)
319 if (prevSame && !isComplete(prevSame) && isComplete(snap)) $.ui.toast('⚑ plan complete')
320 } catch {
321 // a refresh never breaks the hook that asked for it
322 }
323}
324
325async function planTick($: EngineInterface, gen: number): Promise<void> {
326 if (gen !== generation || inflight) return
327 if (await read($, tracked)) await refresh($, 'poll')
328}
329
330/** The pane may be placed or unplaced later (a resize); the band follows. Runs whatever `planPollSeconds` is. */
331async function paneTick($: EngineInterface, gen: number): Promise<void> {
332 if (gen !== generation) return
333 try {
334 const u = await read($, ui)
335 if (!u.paneOpen) return
336 const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
337 if (!pane) await setUi($, { paneOpen: false, isPlaced: false })
338 else if (pane.isPlaced !== u.isPlaced) await setUi($, { isPlaced: pane.isPlaced })
339 } catch {
340 // cosmetic
341 }
342}
343
344/**
345 * Re-seats an already open pane so a `columns` request is applied anew: the
346 * engine's open of an open id only retitles. No-op when nothing is asked.
347 */
348async function reseatPane($: EngineInterface): Promise<void> {
349 if (cfg.paneColumns === undefined) return
350 try {
351 const open = (await $.ui.panes()).some(p => p.id === PANE_ID)
352 if (open) await $.ui.close({ id: PANE_ID })
353 } catch {
354 // the kit or surface has no panes/close: open as usual
355 }
356}
357
358async function openPane($: EngineInterface): Promise<boolean> {
359 await reseatPane($)
360 try {
361 const r = await $.ui.open({
362 id: PANE_ID,
363 title: PANE_TITLE,
364 rows: 10,
365 ...(cfg.paneColumns === undefined ? {} : { columns: cfg.paneColumns }),
366 })
367 await setUi($, { paneOpen: true, isPlaced: r.isPlaced })
368 return r.isPlaced
369 } catch {
370 return false
371 }
372}
373
374/** An open the person did not ask for: placed only in wide terminals; the band covers the rest. */
375async function maybeAutoOpen($: EngineInterface): Promise<void> {
376 if (!cfg.openOnStart || !interactive) return
377 if (!(await read($, tracked))) return
378 await openPane($)
379}
380
381/** The picker's plans: newest `*.md` in `planDir`, each with its title (read from the file) and its stored progress. */
382async function listRecent($: EngineInterface): Promise<RecentPlan[]> {
383 let files: { name: string; path: string; mtimeMs: number; size: number }[]
384 try {
385 const dir = normalizePath(expandHome(cfg.planDir, await homeDir($)))
386 const entries = await $.fs.list(dir)
387 files = orderRecent(entries.filter(f => f.kind === 'file' && /\.md$/i.test(f.name))).map(f => ({
388 name: f.name.replace(/\.md$/i, ''),
389 path: `${dir}/${f.name}`,
390 mtimeMs: f.mtimeMs,
391 size: f.size,
392 }))
393 } catch {
394 return []
395 }
396 const records = await lastRecords($)
397 const out: RecentPlan[] = []
398 for (const f of files) {
399 const rec = recordFor(f.path, records)
400 out.push({ name: f.name, path: f.path, mtimeMs: f.mtimeMs, title: await recentTitle($, f.path, f.size), percent: rec?.percent ?? null, cwd: rec?.cwd ?? null })
401 }
402 return out
403}
404
405/** A plan file's display title for the picker; null (the slug shows) when it is too big, unreadable or has none. */
406async function recentTitle($: EngineInterface, path: string, size: number): Promise<string | null> {
407 if (size > MAX_PLAN_BYTES) return null
408 try {
409 return planTitle(await $.fs.read(path))?.displayTitle ?? null
410 } catch {
411 return null
412 }
413}
414
415/** Every stored `last:<cwd>` record, as key and value. */
416async function lastRecords($: EngineInterface): Promise<{ key: string; value: unknown }[]> {
417 const out: { key: string; value: unknown }[] = []
418 try {
419 for (const key of await $.store.keys()) {
420 if (!isLastKey(key)) continue
421 try {
422 out.push({ key, value: await $.store.get(key) })
423 } catch {
424 // skip an unreadable record
425 }
426 }
427 } catch {
428 // no records: the picker shows no progress
429 }
430 return out
431}
432
433/** The picker as text, from a fresh listing; also what the pane draws next. */
434async function pickerReply($: EngineInterface): Promise<string> {
435 recentPlans = await listRecent($)
436 return pickerText(recentPlans, await $.clock.now(), cfg.planDir)
437}
438
439async function resetTracking($: EngineInterface): Promise<void> {
440 const t = await read($, tracked)
441 const cwd = t?.cwd ?? (await $.session.cwd())
442 await handoffForget($)
443 await update($, tracked, () => null)
444 await update($, plan, () => null)
445 await setUi($, { lastError: null })
446 seen = null
447 try {
448 await $.store.delete(storeKey(cwd))
449 } catch {
450 // nothing stored
451 }
452 await prsSetPlanPath($, null)
453 lastStatus = null
454 if (cfg.statusLine) {
455 lastStatus = undefined
456 $.ui.status(undefined)
457 }
458}
459
460async function startSession($: EngineInterface, cwd: string, isInteractive: boolean): Promise<void> {
461 generation++
462 const gen = generation
463 interactive = isInteractive
464 await $.command.register({
465 name: 'waypoint',
466 description: 'Plan progress pane',
467 argumentHint: SUBCOMMANDS,
468 })
469 await registerHandoffTool($)
470 try {
471 const l = await $.store.get(LAYOUT_KEY)
472 if (l === 'auto' || l === 'compact' || l === 'wide') await setUi($, { layout: l })
473 } catch {
474 // default layout
475 }
476 await prsSweep($)
477 await handoffSweep($)
478 // Resume only on a fresh start: after a hot reload the atoms still hold what was tracked.
479 let resumed = false
480 if (!(await read($, tracked))) {
481 try {
482 const rec = (await $.store.get(storeKey(cwd))) as StoredPlan | undefined
483 if (rec && typeof rec.path === 'string') {
484 let st: { mtimeMs: number } | null = null
485 try {
486 st = await $.fs.stat(rec.path)
487 } catch {
488 st = null
489 }
490 if (shouldResume(rec, st, await $.clock.now(), cfg.resumeDays)) {
491 resumed = (await track($, rec.path, 'resume')) === 'replace'
492 await refresh($, 'force')
493 } else {
494 await $.store.delete(storeKey(cwd))
495 }
496 }
497 } catch {
498 // resume is best effort
499 }
500 } else {
501 // A reload may carry a changed parser: re-read the plan even though its
502 // mtime is unchanged, or the pane keeps the snapshot the old code made.
503 await refresh($, 'force')
504 await prsPublish($)
505 }
506 await handoffPublish($)
507 planTimer?.cancel()
508 prTimer?.cancel()
509 kickTimer?.cancel()
510 paneTimer?.cancel()
511 planTimer = prTimer = kickTimer = paneTimer = undefined
512 if (isInteractive) {
513 if (cfg.planPollMs > 0) planTimer = $.clock.every(cfg.planPollMs, () => bg(planTick($, gen)))
514 paneTimer = $.clock.every(PANE_SYNC_MS, () => bg(paneTick($, gen)))
515 prTimer = $.clock.every(Math.max(15_000, Math.floor(cfg.pollMs / 4)), () => bg(prsTick($, gen)))
516 kickoff($)
517 }
518 lastStatus = null
519 if (cfg.statusLine) await setStatus($)
520 else {
521 // Clear anything an earlier load (with the option on) left behind; nothing sets text after this.
522 lastStatus = undefined
523 $.ui.status(undefined)
524 }
525 if (resumed) await maybeAutoOpen($)
526}
527
528async function onPlanModeAttachment($: EngineInterface, path: string | undefined, hasPlan: boolean): Promise<void> {
529 try {
530 await setUi($, { inPlanMode: true })
531 if (path) {
532 await track($, path, 'plan_mode')
533 if (hasPlan) await refresh($, 'event')
534 }
535 await setStatus($)
536 } catch {
537 // observe only
538 }
539}
540
541async function onPlanModeExit($: EngineInterface, path: string | undefined, hasPlan: boolean): Promise<void> {
542 try {
543 await setUi($, { inPlanMode: false })
544 if (path && hasPlan) await track($, path, 'plan_mode')
545 await refresh($, 'force')
546 await setStatus($)
547 const t = await read($, tracked)
548 if (t) await prsSetPlanPath($, t.path)
549 await maybeAutoOpen($)
550 } catch {
551 // observe only
552 }
553}
554
555async function onExitPlanTool($: EngineInterface, filePath: string | undefined): Promise<void> {
556 try {
557 if (filePath) await track($, filePath, 'exit_plan')
558 await setUi($, { inPlanMode: false })
559 await refresh($, 'force')
560 await setStatus($)
561 } catch {
562 // observe only
563 }
564}
565
566async function isTrackedPath($: EngineInterface, path: string | undefined): Promise<boolean> {
567 const t = await read($, tracked)
568 return t !== null && samePath(path, t.path, await homeDir($))
569}
570
571/** `stat`'s `realPath` (every link followed), or undefined when the kit gives none or the path leads nowhere. */
572async function realPathOf($: EngineInterface, path: string): Promise<string | undefined> {
573 try {
574 const r = (await $.fs.stat(path, { resolve: true })).realPath
575 return r ? normalizePath(r) : undefined
576 } catch {
577 return undefined
578 }
579}
580
581/**
582 * The absolute path of `path` when it is a `.md` file under `planDir`, else null. `planDir` is
583 * `~`-expanded, and a relative one resolved against the home dir. A spelling outside it may still land
584 * inside through a link: then the plan dir's and the file's real paths are compared too.
585 */
586async function planDirFile($: EngineInterface, path: string | undefined): Promise<string | null> {
587 if (!path || !/\.md$/i.test(path)) return null
588 const h = await homeDir($)
589 const abs = normalizePath(expandHome(path, h))
590 const dir = planDirAbs(cfg.planDir, h)
591 if (isPlanDirPath(abs, dir)) return abs
592 if (!abs.startsWith('/')) return null
593 const realDir = await realPathOf($, dir)
594 if (realDir === undefined) return null
595 if (isPlanDirPath(abs, realDir)) return abs
596 const realFile = await realPathOf($, abs)
597 return realFile !== undefined && isPlanDirPath(realFile, realDir) ? abs : null
598}
599
600/**
601 * After the main session's `Write`/`Edit` of a plan file under `planDir`: lint the file as written and,
602 * when the findings changed since the last note for it, add a note to the result's hidden context.
603 * Never changes the result otherwise; any failure returns it as it came.
604 */
605async function lintAfterWrite<R>($: EngineInterface, e: { agentId?: string; file_path?: string }, ran: R, kind: 'write' | 'edit'): Promise<R> {
606 try {
607 const r = ran as { deny?: string; isError?: boolean; context?: readonly string[] }
608 if (e.agentId || !cfg.styleGuide || r.deny || r.isError) return ran
609 const abs = await planDirFile($, e.file_path)
610 if (!abs) return ran
611 if ((await $.fs.stat(abs)).size > MAX_PLAN_BYTES) return ran
612 const report = lintPlan(await $.fs.read(abs))
613 const key = findingsKey(report)
614 if ((await read($, lintNoted))[abs] === key) return ran
615 const note = writeNote(report, { file: basename(abs), kind, inPlanMode: (await read($, ui)).inPlanMode })
616 // A clean plan records its key; an edit leaving only nits records nothing, so a later write still names them.
617 if (!note && report.findings.length > 0) return ran
618 await update($, lintNoted, m => ({ ...m, [abs]: key }))
619 if (!note) return ran
620 return { ...ran, context: [...(r.context ?? []), note] } as R
621 } catch {
622 return ran
623 }
624}
625
626async function onTurnComplete($: EngineInterface): Promise<void> {
627 if (await read($, tracked)) await refresh($, 'event')
628}
629
630type Section = { id: string; text: string; scope: 'session' }
631
632async function composeSections($: EngineInterface): Promise<Section[]> {
633 const out: Section[] = []
634 const t = await read($, tracked)
635 const u = await read($, ui)
636 if (t || u.inPlanMode) out.push({ id: SECTION_ID, text: planSection(t?.path ?? '', u.inPlanMode), scope: 'session' })
637 // Static until the note is cleared or replaced, so it busts the prompt cache at most once.
638 const h = await read($, handoff)
639 if (h !== null && h.sessionId !== (await $.session.id())) {
640 out.push({ id: HANDOFF_SECTION_ID, text: earlierHandoffSection(h.summary), scope: 'session' })
641 }
642 return out
643}
644
645async function summary($: EngineInterface): Promise<string> {
646 const t = await read($, tracked)
647 if (!t) return 'waypoint: no plan tracked'
648 const s = await read($, plan)
649 const p = await read($, prs)
650 const needs = p.entries.filter(e => e.attention !== null).length
651 const prPart = (needs > 0 ? ` · ${needs} PR${needs === 1 ? ' needs' : 's need'} you` : '') + (p.note ? ` · ${p.note}` : '')
652 if (t.missing) return `waypoint: plan file missing: ${t.path}`
653 if (!s) return `waypoint: ${basename(t.path)} · not read yet${prPart}`
654 const cur = s.currentIndex === null ? undefined : s.steps[s.currentIndex]
655 const curPart = cur ? ` · ${cur.status === 'blocked' ? 'blocked on' : 'current'} ${cur.short}` : s.counts.total > 0 ? ' · complete' : ''
656 return `waypoint: ${s.displayTitle} · ${doneOf(s)}${curPart}${prPart}`
657}
658
659async function paneStatus($: EngineInterface): Promise<string> {
660 let isPlaced = false
661 try {
662 isPlaced = (await $.ui.panes()).some(p => p.id === PANE_ID && p.isPlaced)
663 } catch {
664 // diagnostics only
665 }
666 return paneStatusLine(lastPaneDraw, isPlaced, cfg.paneColumns)
667}
668
669/** `lint`/`conform` target: a picker number, slug or path as `use` takes it (not tracked), else the tracked plan. */
670async function styleTarget($: EngineInterface, arg: string): Promise<{ path: string } | { text: string }> {
671 if (arg) {
672 const picked = /^[1-9]\d*$/.test(arg) ? (await listRecent($))[Number(arg) - 1] : undefined
673 return { path: picked?.path ?? resolveUseArg(arg, await $.session.cwd(), cfg.planDir, await homeDir($)) }
674 }
675 const t = await read($, tracked)
676 return t ? { path: t.path } : { text: `No plan tracked.\n${await pickerReply($)}` }
677}
678
679/** Read and lint a plan file for a command; a reply text when it cannot be read. */
680async function lintTarget(
681 $: EngineInterface,
682 arg: string,
683): Promise<{ path: string; title: string; report: ReturnType<typeof lintPlan> } | { text: string }> {
684 const target = await styleTarget($, arg)
685 if ('text' in target) return target
686 const path = target.path
687 let size: number
688 try {
689 size = (await $.fs.stat(path)).size
690 } catch {
691 return { text: `No plan file at ${path}.` }
692 }
693 if (size > MAX_PLAN_BYTES) return { text: `Plan file over 4 MiB: ${basename(path)}.` }
694 const raw = await $.fs.read(path)
695 const s = await read($, plan)
696 const title = (s && samePath(s.path, path) ? s.displayTitle : planTitle(raw)?.displayTitle) || basename(path)
697 return { path, title, report: lintPlan(raw) }
698}
699
700async function conformCommand($: EngineInterface, arg: string): Promise<CommandReply> {
701 const [first = '', ...more] = arg.split(/\s+/)
702 const rewrite = first === 'rewrite'
703 pendingConform = null
704 const r = await lintTarget($, rewrite ? more.join(' ') : arg)
705 if ('text' in r) return { text: r.text }
706 const file = basename(r.path)
707 if (r.report.findings.length === 0 && !rewrite) return { text: `${r.title} (${file}): looks fine, nothing to conform.` }
708 // A submitted text may not begin with `/` (it would run as a command), so it gets a lead line of its own.
709 const instruction = `The person ran /waypoint conform${rewrite ? ' rewrite' : ''}; please carry it out.\n${conformInstruction(r.report, { path: r.path, title: r.title, rewrite })}`
710 // The engine refuses a submit from inside a command hook (it would wait on the turn the hook holds),
711 // so it is made from a timer once this command has answered.
712 $.clock.after(0, () => bg(deliverConform($, instruction)))
713 return {
714 text: `Asked this session to apply the plan style guide to "${r.title}" (${file}) ${rewrite ? 'with heading rewrites' : 'additively'}; it starts when the session is idle and shows the diff.`,
715 }
716}
717
718/** Take the pending conform instruction (it goes once); null when none, or older than 10 minutes (dropped). */
719async function takePendingConform($: EngineInterface): Promise<string | null> {
720 const p = pendingConform
721 pendingConform = null
722 if (p === null) return null
723 try {
724 return (await $.clock.now()) - p.at > PENDING_CONFORM_MS ? null : p.text
725 } catch {
726 return null
727 }
728}
729
730/**
731 * Submit the conform instruction as this plugin's prompt, a turn of its own once the session is idle.
732 * On failure it stays pending: a toast says so and it rides with the person's next prompt as context.
733 */
734async function deliverConform($: EngineInterface, instruction: string): Promise<void> {
735 let why: string | null = null
736 try {
737 const r = await $.prompt.submit({ text: instruction })
738 if ('drop' in r && r.drop) why = `dropped: ${r.drop}`
739 else pendingConform = null
740 } catch (err) {
741 why = String((err as Error)?.message ?? err)
742 }
743 if (why === null) return
744 try {
745 pendingConform = { text: instruction, at: await $.clock.now() }
746 } catch {
747 pendingConform = null
748 }
749 try {
750 $.ui.toast(`waypoint: could not hand the conform instruction to the session (${why}); it goes with your next prompt.`, { timeoutMs: 8000 })
751 } catch {
752 // cosmetic
753 }
754}
755
756async function runCommand($: EngineInterface, args: string): Promise<string | CommandReply> {
757 const [sub = '', ...rest] = args.trim().split(/\s+/)
758 const arg = rest.join(' ')
759 try {
760 switch (sub) {
761 case '': {
762 await refresh($, 'force')
763 if (await read($, tracked)) {
764 await openPane($)
765 return 'Waypoint opened.'
766 }
767 const picker = await pickerReply($)
768 await openPane($)
769 return `Waypoint opened. No plan tracked.\n${picker}`
770 }
771 case 'close':
772 await $.ui.close({ id: PANE_ID })
773 await setUi($, { paneOpen: false, isPlaced: false })
774 return 'Waypoint closed.'
775 case 'refresh': {
776 await refresh($, 'force')
777 await prsRepoll($)
778 const t = await read($, tracked)
779 const s = await read($, plan)
780 if (!t) return 'No plan tracked.'
781 if (t.missing) return `Plan file missing: ${t.path}`
782 return s ? `Re-read ${s.displayTitle} (${basename(t.path)}): ${doneOf(s)}.` : `Re-read ${basename(t.path)}.`
783 }
784 case 'use': {
785 if (!arg) return 'Usage: /waypoint use <path|slug|n>'
786 // A picker number (1–5) names the nth recent plan; a slug that happens to be a number still works past the list.
787 const picked = /^[1-9]\d*$/.test(arg) ? (await listRecent($))[Number(arg) - 1] : undefined
788 const p = picked?.path ?? resolveUseArg(arg, await $.session.cwd(), cfg.planDir, await homeDir($))
789 if (!(await $.fs.exists(p))) return `No plan file at ${p}\n${await pickerReply($)}`
790 await track($, p, 'manual')
791 await refresh($, 'force')
792 await openPane($)
793 const s = await read($, plan)
794 return s ? `Tracking ${basename(p)}: ${doneOf(s)}.` : `Tracking ${basename(p)}.`
795 }
796 case 'track':
797 return await prsTrackCommand($, arg)
798 case 'untrack':
799 return await prsUntrackCommand($, arg)
800 case 'handoff':
801 if (arg === '') return handoffText(await read($, handoff))
802 if (arg === 'clear') {
803 const had = (await read($, handoff)) !== null
804 await handoffWrite($, null)
805 return had ? 'Handoff note cleared.' : 'No handoff note.'
806 }
807 return 'Usage: /waypoint handoff [clear]'
808 case 'lint': {
809 const r = await lintTarget($, arg)
810 return 'text' in r ? r.text : lintText(r.report, { title: r.title, file: basename(r.path) })
811 }
812 case 'conform':
813 return await conformCommand($, arg)
814 case 'reset':
815 await resetTracking($)
816 return 'Waypoint reset: no plan tracked.'
817 case 'layout': {
818 if (arg === '') {
819 // No argument: forget the stored choice so the `layout` option applies again.
820 await $.store.delete(LAYOUT_KEY)
821 await setUi($, { layout: null })
822 return `Waypoint layout: ${cfg.layout} (from settings).`
823 }
824 if (arg !== 'auto' && arg !== 'compact' && arg !== 'wide') return 'Usage: /waypoint layout [auto|compact|wide]'
825 await setUi($, { layout: arg })
826 await $.store.set(LAYOUT_KEY, arg)
827 return `Waypoint layout: ${arg}.`
828 }
829 case 'status':
830 return `${await summary($)}\n${await paneStatus($)}\nstyle guide: ${cfg.styleGuide ? 'on' : 'off'}`
831 default:
832 return USAGE
833 }
834 } catch (err) {
835 return `waypoint: ${String((err as Error)?.message ?? err)}`
836 }
837}
838
839// --- PRs ---------------------------------------------------------------------
840
841const planPrsKey = (path: string): string => `waypoint:prs:plan/${path}`
842const sessionPrsKey = (id: string): string => `waypoint:prs:session/${id}`
843
844async function prsKey($: EngineInterface): Promise<string> {
845 const t = await read($, tracked)
846 return t ? planPrsKey(t.path) : sessionPrsKey(await $.session.id())
847}
848
849async function prsLoad($: EngineInterface, key: string): Promise<PrsStore> {
850 try {
851 return sanitizeStore(await $.store.get(key))
852 } catch {
853 return emptyStore()
854 }
855}
856
857/** Every PR store write: prune first; an empty store deletes the key instead. Returns what was kept. */
858async function prsSave($: EngineInterface, key: string, store: PrsStore): Promise<PrsStore> {
859 const kept: PrsStore = { ...store, entries: prune(store.entries, await $.clock.now()) }
860 if (Object.keys(kept.entries).length === 0) await $.store.delete(key)
861 else await $.store.set(key, kept)
862 return kept
863}
864
865/**
866 * Start of session: drop other sessions' idle unmigrated keys (a running
867 * session's stay) and plan stores nobody needs; prune the rest as a write would.
868 */
869async function prsSweep($: EngineInterface): Promise<void> {
870 try {
871 const own = sessionPrsKey(await $.session.id())
872 const now = await $.clock.now()
873 for (const k of await $.store.keys()) {
874 const isSession = k.startsWith(sessionPrsKey(''))
875 if (!isSession && !k.startsWith(planPrsKey(''))) continue
876 const store = await prsLoad($, k)
877 const drop = isSession ? k !== own && idleSessionStore(store, now) : staleStore(store, now)
878 if (drop) await $.store.delete(k)
879 else if (Object.keys(prune(store.entries, now)).length !== Object.keys(store.entries).length) {
880 await prsSave($, k, store)
881 }
882 }
883 } catch {
884 // housekeeping is best effort
885 }
886}
887
888/** Write the store (when given) and publish the current key's store into the `prs` atom. */
889async function prsPublish($: EngineInterface, write?: { key: string; store: PrsStore }, note?: string): Promise<void> {
890 const saved = write ? await prsSave($, write.key, write.store) : undefined
891 const key = await prsKey($)
892 const store = write && saved && write.key === key ? saved : await prsLoad($, key)
893 const now = await $.clock.now()
894 await update($, prs, prev => toView(store, now, note ?? prev.note))
895}
896
897async function sessionRepo($: EngineInterface): Promise<RepoRef | null> {
898 const now = await $.clock.now()
899 if (repoCache && now - repoCache.at < REPO_TTL_MS) return repoCache.repo
900 let repo: RepoRef | null = null
901 try {
902 repo = parseRemote((await $.session.repo())?.remote) ?? null
903 } catch {
904 repo = null
905 }
906 repoCache = { at: now, repo }
907 return repo
908}
909
910/** The plan path became known (or was cleared): fold the session's entries into the plan's store. */
911async function prsSetPlanPath($: EngineInterface, path: string | null): Promise<void> {
912 try {
913 if (path) {
914 const sKey = sessionPrsKey(await $.session.id())
915 const sess = await prsLoad($, sKey)
916 const ids = Object.keys(sess.entries)
917 if (ids.length > 0) {
918 const pKey = planPrsKey(path)
919 let merged = await prsLoad($, pKey)
920 const now = await $.clock.now()
921 for (const e of Object.values(sess.entries)) {
922 merged = merged.entries[e.id]
923 ? upsert(merged, e, e.source, now, e.stepId)
924 : { ...merged, entries: { ...merged.entries, [e.id]: e } }
925 }
926 await prsSave($, pKey, merged)
927 await $.store.delete(sKey)
928 }
929 }
930 await prsPublish($)
931 } catch {
932 // PR wiring never breaks tracking
933 }
934 await handoffSetPlanPath($, path)
935}
936
937/** Debounced first poll shortly after a discovery. */
938function kickoff($: EngineInterface): void {
939 if (!interactive) return
940 kickTimer?.cancel()
941 const gen = generation
942 kickTimer = $.clock.after(KICKOFF_MS, () => bg(prsTick($, gen)))
943}
944
945async function prsObserveBash($: EngineInterface, command: string | undefined, text: string | undefined): Promise<void> {
946 try {
947 if (!command || !/\b(gh|glab)\b/.test(command)) return
948 const repo = await sessionRepo($)
949 const refs = discoverFromBash({ command, text: text ?? null, repo })
950 if (refs.length === 0) return
951 const key = await prsKey($)
952 const before = await prsLoad($, key)
953 const now = await $.clock.now()
954 let store = before
955 for (const r of refs) store = upsert(store, r, 'session', now)
956 if (store === before) return
957 await prsPublish($, { key, store })
958 kickoff($)
959 } catch {
960 // observe only
961 }
962}
963
964async function onPlanParsed($: EngineInterface, snap: PlanSnapshot): Promise<void> {
965 const repo = await sessionRepo($)
966 const key = planPrsKey(snap.path)
967 const before = await prsLoad($, key)
968 const now = await $.clock.now()
969 let store = before
970 const keep: string[] = []
971 for (const step of snap.steps) {
972 for (const ref of extractPlanRefs(step.body, repo)) {
973 store = upsert(store, ref, 'plan', now, step.id)
974 keep.push(prId(ref))
975 }
976 }
977 store = dropPlanRefsNotIn(store, keep)
978 if (store === before) return
979 await prsPublish($, { key, store })
980 if (Object.values(store.entries).some(e => e.lastPolledAt === null)) kickoff($)
981}
982
983type CliResult = { ok: true; stdout: string } | { ok: false; error: PrErrorClass }
984
985/** Runs a read-only gh/glab view; never throws. */
986async function cli($: EngineInterface, argv: string[], env?: Record<string, string>): Promise<CliResult> {
987 try {
988 const r = await $.process.run(argv, env ? { timeoutMs: CLI_TIMEOUT_MS, env } : { timeoutMs: CLI_TIMEOUT_MS })
989 if (r.exitCode === 0) return { ok: true, stdout: r.stdout }
990 return { ok: false, error: classifyError(r.stderr) }
991 } catch (err) {
992 const msg = String((err as Error)?.message ?? err)
993 return { ok: false, error: classifyError(msg, /ENOENT|not found|no such file/i.test(msg)) }
994 }
995}
996
997function outcomeOf(entry: PrEntry, r: CliResult): PollOutcome {
998 if (!r.ok) return { ok: false, error: r.error }
999 let json: unknown
1000 try {
1001 json = JSON.parse(r.stdout)
1002 } catch {
1003 return { ok: false, error: 'other' }
1004 }
1005 const facts = entry.kind === 'github' ? parseGhPr(json) : parseGlabMr(json)
1006 return facts ? { ok: true, facts } : { ok: false, error: 'other' }
1007}
1008
1009async function showToasts($: EngineInterface, toasts: Toast[], native: boolean): Promise<void> {
1010 if (toasts.length === 0) return
1011 let fresh: Toast[] = []
1012 await update($, prsToasted, prev => {
1013 const r = newToasts(toasts, prev)
1014 fresh = r.fresh
1015 return r.toasted
1016 })
1017 for (const t of fresh) {
1018 $.ui.toast(t.text, { timeoutMs: 6000 })
1019 if (native && cfg.notify) {
1020 try {
1021 await $.ui.notify(t.text, { title: 'waypoint' })
1022 } catch {
1023 // notifications are optional
1024 }
1025 }
1026 }
1027}
1028
1029/** One poll pass: due entries in sequence, staggered; single-flight; never throws. */
1030async function prsTick($: EngineInterface, gen: number): Promise<void> {
1031 if (gen !== generation || ticking) return
1032 ticking = true
1033 try {
1034 const key = await prsKey($)
1035 const due = pickDue(Object.values((await prsLoad($, key)).entries), await $.clock.now(), cfg.pollMs)
1036 if (due.length === 0) return
1037 let note: string | null = null
1038 let anyOk = false
1039 for (const [i, entry] of due.entries()) {
1040 if (i > 0) await $.clock.sleep(STAGGER_MS)
1041 if (gen !== generation) return
1042 const outcome = outcomeOf(entry, await cli($, viewArgs(entry), entry.kind === 'gitlab' ? glabEnv(entry) : undefined))
1043 const now = await $.clock.now()
1044 // Re-read: a discovery or untrack may have landed during the call.
1045 const store = await prsLoad($, key)
1046 const prev = store.entries[entry.id]
1047 if (!prev) continue
1048 const next = applyPoll(prev, outcome, now)
1049 await prsSave($, key, { ...store, entries: { ...store.entries, [entry.id]: next } })
1050 await showToasts($, toastsFor(prev, next, repoNameOf(next)), true)
1051 if (outcome.ok) {
1052 anyOk = true
1053 } else {
1054 const cliName = entry.kind === 'github' ? 'gh' : 'glab'
1055 const text = errorText(outcome.error, cliName)
1056 if (text !== null) {
1057 note = text
1058 await showToasts($, [{ key: `err:${cliName}:${outcome.error}`, text: `waypoint: ${text}` }], false)
1059 }
1060 }
1061 }
1062 const prevNote = (await read($, prs)).note
1063 await prsPublish($, { key, store: await prsLoad($, key) }, note ?? (anyOk ? '' : prevNote))
1064 } catch {
1065 // polling is best effort
1066 } finally {
1067 ticking = false
1068 }
1069}
1070
1071/** `/waypoint refresh`: every entry due now. */
1072async function prsRepoll($: EngineInterface): Promise<void> {
1073 const key = await prsKey($)
1074 const store = await prsLoad($, key)
1075 if (Object.keys(store.entries).length === 0) return
1076 const entries: Record<string, PrEntry> = {}
1077 for (const [id, e] of Object.entries(store.entries)) entries[id] = { ...e, lastPolledAt: null }
1078 await prsPublish($, { key, store: { ...store, entries } })
1079 if (interactive) bg(prsTick($, generation))
1080}
1081
1082async function prsTrackCommand($: EngineInterface, arg: string): Promise<string> {
1083 const r = resolveManualRef(arg, await sessionRepo($))
1084 if (!r.ok) return r.error
1085 const key = await prsKey($)
1086 const store = upsert(await prsLoad($, key), r.ref, 'manual', await $.clock.now())
1087 await prsPublish($, { key, store })
1088 kickoff($)
1089 return `Tracking ${prLabel(r.ref)} (${r.ref.path}).`
1090}
1091
1092async function prsUntrackCommand($: EngineInterface, arg: string): Promise<string> {
1093 const a = arg.trim()
1094 if (!a) return 'Usage: /waypoint untrack <id | #n | !n | url>'
1095 const key = await prsKey($)
1096 const store = await prsLoad($, key)
1097 const r = resolveManualRef(a, await sessionRepo($))
1098 const all = Object.values(store.entries)
1099 const hit =
1100 (r.ok ? store.entries[prId(r.ref)] : undefined) ?? all.find(e => e.id === a) ?? all.find(e => prLabel(e) === a)
1101 if (!hit) return `Not tracked: ${a}`
1102 await prsDrop($, hit.id)
1103 return `Untracked ${prLabel(hit)} (${hit.path}).`
1104}
1105
1106async function prsDrop($: EngineInterface, id: string): Promise<void> {
1107 const key = await prsKey($)
1108 const store = await prsLoad($, key)
1109 if (!store.entries[id]) return
1110 const entries = { ...store.entries }
1111 delete entries[id]
1112 await prsPublish($, { key, store: { ...store, entries } })
1113}
1114
1115// --- handoff -------------------------------------------------------------------
1116
1117// Stored per plan like the PR store: `waypoint:handoff:plan/<abs path>` while a
1118// plan is tracked, else `waypoint:handoff:session/<id>`, folded into the plan's
1119// key when the plan path becomes known.
1120const HANDOFF_PREFIX = 'waypoint:handoff:'
1121const planHandoffKey = (path: string): string => `${HANDOFF_PREFIX}plan/${path}`
1122const sessionHandoffKey = (id: string): string => `${HANDOFF_PREFIX}session/${id}`
1123
1124async function handoffKey($: EngineInterface): Promise<string> {
1125 const t = await read($, tracked)
1126 return t ? planHandoffKey(t.path) : sessionHandoffKey(await $.session.id())
1127}
1128
1129async function handoffLoad($: EngineInterface, key: string): Promise<Handoff | null> {
1130 try {
1131 return sanitizeHandoff(await $.store.get(key))
1132 } catch {
1133 return null
1134 }
1135}
1136
1137async function setHandoff($: EngineInterface, h: Handoff | null): Promise<void> {
1138 const prev = await read($, handoff)
1139 if (prev === h || (prev === null && h === null)) return
1140 await update($, handoff, () => h)
1141 await setStatus($)
1142}
1143
1144/** Publish the current key's note into the atom. Never throws. */
1145async function handoffPublish($: EngineInterface): Promise<void> {
1146 try {
1147 await setHandoff($, await handoffLoad($, await handoffKey($)))
1148 } catch {
1149 // the note is best effort
1150 }
1151}
1152
1153/** Set (or, with null, clear) the note: atom first, then the store. */
1154async function handoffWrite($: EngineInterface, h: Handoff | null): Promise<void> {
1155 await setHandoff($, h)
1156 try {
1157 const key = await handoffKey($)
1158 if (h) await $.store.set(key, h)
1159 else await $.store.delete(key)
1160 } catch {
1161 // the atom still holds it for this session
1162 }
1163}
1164
1165/** The plan path became known (or was cleared): move the session's note into the plan's key, then publish. */
1166async function handoffSetPlanPath($: EngineInterface, path: string | null): Promise<void> {
1167 try {
1168 if (path) {
1169 const sKey = sessionHandoffKey(await $.session.id())
1170 const sess = await handoffLoad($, sKey)
1171 if (sess) {
1172 const pKey = planHandoffKey(path)
1173 const cur = await handoffLoad($, pKey)
1174 if (!cur || cur.at <= sess.at) await $.store.set(pKey, sess)
1175 await $.store.delete(sKey)
1176 }
1177 }
1178 } catch {
1179 // migration is best effort
1180 }
1181 await handoffPublish($)
1182}
1183
1184/** `/waypoint reset`: drop the note under the plan's key and the session's. */
1185async function handoffForget($: EngineInterface): Promise<void> {
1186 try {
1187 const t = await read($, tracked)
1188 if (t) await $.store.delete(planHandoffKey(t.path))
1189 await $.store.delete(sessionHandoffKey(await $.session.id()))
1190 } catch {
1191 // nothing stored
1192 }
1193 await setHandoff($, null)
1194}
1195
1196/** Start of session: drop unreadable and expired notes, and other sessions' idle session-key notes (`sweepDrops`). */
1197async function handoffSweep($: EngineInterface): Promise<void> {
1198 try {
1199 const now = await $.clock.now()
1200 const own = sessionHandoffKey(await $.session.id())hooks/pane.tsx 473 lines1// waypoint: JSX only. Draws a precomputed PaneModel / BandModel; no I/O, no
2// state writes, no `$`. Presses call the plain callbacks the wiring hands in.
3
4import type { EngineInterface, RenderElement, RenderInput, UiPressArgument } from 'claude-code'
5
6import { childProgress, colorOf, glyph, MAX_CHILD_LINES, ruleTail, shorten } from './view'
7import type { SubItem } from '../types'
8import type { BandModel, PaneModel, PrLine, Row } from './view'
9
10export type PaneActions = {
11 refresh: (press: UiPressArgument) => void
12 copyPath: (press: UiPressArgument) => void
13 close: (press: UiPressArgument) => void
14 open: (press: UiPressArgument) => void
15 pick: (path: string, press: UiPressArgument) => void
16 copyUrl: (url: string, press: UiPressArgument) => void
17 untrack: (id: string, press: UiPressArgument) => void
18}
19
20type PaneEvent = RenderInput<'Pane'>
21type BandEvent = RenderInput<'AbovePrompt'>
22/** The surface's element table: the wiring resolves it (`$.ui.resolve(e)`) and hands it in. */
23export type Kit = ReturnType<EngineInterface['ui']['resolve']>
24
25const T = 'truncate-end' as const
26
27/** The wide footer's hotkeys as the engine draws them: `r: refresh · p: path · c: close`. */
28export const FOOTER_KEYS_WIDTH = 31
29/** The least blank between the wide footer's left text and its hotkeys. */
30export const FOOTER_GAP = 2
31
32export function drawPane(kit: Kit, _e: PaneEvent, model: PaneModel, actions: PaneActions): RenderElement {
33 const { Box, Text, Button, Link } = kit
34
35 // Every state but the plan's leads with the handoff note, when there is one.
36 const handoffLead =
37 model.kind !== 'plan' && model.handoffLine !== null ? (
38 <Box key="handoff" flexDirection="row">
39 <Text color="warning" bold wrap={T}>{model.handoffLine}</Text>
40 </Box>
41 ) : null
42
43 if (model.kind === 'planning') {
44 return (
45 <Box flexDirection="column">
46 {handoffLead}
47 <Text color="planMode" wrap={T}>⚑ planning… the plan file appears here when written</Text>
48 </Box>
49 )
50 }
51
52 if (model.kind === 'none-tracked') {
53 return (
54 <Box flexDirection="column">
55 {handoffLead}
56 <Text wrap={T}>No plan tracked. Enter plan mode, or pick a recent plan:</Text>
57 {model.recent.length === 0 && <Text dimColor wrap={T}>No plans found in {model.planDir}.</Text>}
58 {model.recent.map((r, i) => (
59 <Box key={`recent-${r.path}`} flexDirection="column">
60 <Box flexDirection="row">
61 <Button key={`pick-${r.path}`} label={r.label} hotkey={String(i + 1)} plain onPress={p => actions.pick(r.path, p)} />
62 </Box>
63 {r.where !== null && <Text dimColor wrap={T}>{` ${r.where}`}</Text>}
64 </Box>
65 ))}
66 </Box>
67 )
68 }
69
70 if (model.kind === 'missing') {
71 const c = model.lastCounts
72 return (
73 <Box flexDirection="column">
74 {handoffLead}
75 <Text color="warning" wrap={T}>⚠ plan file missing: {model.path}</Text>
76 {c !== null && <Text dimColor wrap={T}>last seen: {c.done + c.skipped}/{c.total} done</Text>}
77 <Text dimColor wrap={T}>/waypoint reset to stop tracking it, or /waypoint use <path></Text>
78 </Box>
79 )
80 }
81
82 if (model.kind === 'no-steps') {
83 return (
84 <Box flexDirection="column">
85 {handoffLead}
86 <Box flexDirection="row" justifyContent="space-between">
87 <Text bold wrap={T}>⚑ {model.title}</Text>
88 <Text dimColor> no steps found</Text>
89 </Box>
90 <Text dimColor wrap={T}>No step headings recognised. Mark them</Text>
91 <Text dimColor wrap={T}>`### [ ] WP1 — Title`, or /waypoint use <path>.</Text>
92 </Box>
93 )
94 }
95
96 const wide = model.layout === 'wide'
97 const narrow = model.narrow
98 const W = model.width
99 /** The wide layouts' left indent: two spaces, one when narrow. */
100 const ind = narrow ? ' ' : ' '
101 const lines: RenderElement[] = []
102 const gap = (key: string) => (
103 <Box key={key} flexDirection="row">
104 <Text> </Text>
105 </Box>
106 )
107
108 lines.push(
109 <Box key="header" flexDirection="row" justifyContent="space-between">
110 <Text bold wrap={T}>{model.header.title}</Text>
111 <Box flexDirection="row">
112 <Text dimColor={!wide}> {model.header.lead}</Text>
113 <Text bold color="planMode">{model.header.pct}</Text>
114 </Box>
115 </Box>,
116 )
117 const b = model.bar
118 lines.push(
119 <Box key="bar" flexDirection="row">
120 {b.done !== '' && <Text color="success" wrap={T}>{b.done}</Text>}
121 {b.current !== '' && <Text color={b.currentColor} wrap={T}>{b.current}</Text>}
122 {b.rest !== '' && <Text color="inactive" wrap={T}>{b.rest}</Text>}
123 </Box>,
124 )
125 const gaps = wide && model.gaps
126 if (gaps) lines.push(gap('gap-steps'))
127
128 const rows = model.rows
129 rows.forEach((row, i) => {
130 const next = rows[i + 1]
131 // In compact a trailing `+n more` shares the last step's line.
132 if (!wide && row.kind === 'more' && row.position === 'after' && rows[i - 1]?.kind === 'step') return
133 const mergedMore = !wide && row.kind === 'step' && next?.kind === 'more' && next.position === 'after' ? next.count : 0
134 if (row.kind === 'children' && row.perLine) lines.push(...drawChildLines(kit, row.items, titleColumn(narrow, model.shortWidth), W))
135 else lines.push(drawRow(kit, row, wide, narrow, mergedMore, model.shortWidth, W))
136 })
137
138 const ho = model.handoff
139 if (ho !== null && ho.compact && !wide) {
140 lines.push(
141 <Box key="handoff" flexDirection="row">
142 <Text color="warning" bold wrap={T}>{ho.line}</Text>
143 </Box>,
144 )
145 }
146
147 if (model.prSection !== null) {
148 const { count, rows: hot, others, polled, note, head } = model.prSection
149 const polledPart = polled === null ? '' : ` · polled ${polled}`
150 const indent = wide ? ind : ''
151 const noteLine =
152 note === '' ? null : (
153 <Box key="prs-note" flexDirection="row">
154 <Text color="warning" dimColor wrap={T}>{indent}⚠ {narrow ? shorten(note, W - 3) : note}</Text>
155 </Box>
156 )
157 if (!model.prSection.compact) {
158 if (gaps) lines.push(gap('gap-prs'))
159 // A rule carrying the header: `── Pull requests · … ───…` to exactly the width.
160 const tail = ruleTail(`── Pull requests${head.before}${head.need}${head.after}`, W)
161 lines.push(
162 <Box key="prs-head" flexDirection="row">
163 <Text dimColor>── </Text>
164 <Text dimColor={!head.hot} wrap={T}>Pull requests{head.before}</Text>
165 <Text color={head.hot ? 'warning' : undefined} dimColor={!head.hot} bold={head.hot} wrap={T}>{head.need}</Text>
166 <Text dimColor wrap={T}>{head.after}</Text>
167 {tail !== '' && <Text dimColor wrap={T}>{tail}</Text>}
168 </Box>,
169 )
170 if (noteLine !== null) lines.push(noteLine)
171 for (const pr of [...hot, ...others]) lines.push(narrow ? drawPrNarrow(kit, pr, W, others.includes(pr)) : drawPrWide(kit, pr, actions, W, others.includes(pr)))
172 } else {
173 const pr = hot[0] ?? others[0]
174 if (pr !== undefined) {
175 const lead = count > 0 ? `${count} PR${count === 1 ? ' needs' : 's need'} you` : 'PRs tracked'
176 const used = Array.from(`${indent}${pr.glyph} ${lead} · ${pr.label} ${pr.status}${polledPart}`).length
177 const showPolled = polledPart !== '' && (!narrow || used <= W)
178 lines.push(
179 <Box key={`pr-${pr.id}`} flexDirection="row">
180 <Text color={pr.color} dimColor={pr.color === undefined} wrap={T}>{indent}{pr.glyph} {lead} · </Text>
181 <Link href={pr.url} label={pr.label} />
182 <Text color={pr.color} dimColor={pr.color === undefined} wrap={T}> {pr.status}</Text>
183 {showPolled && <Text dimColor wrap={T}>{polledPart}</Text>}
184 </Box>,
185 )
186 }
187 if (noteLine !== null) lines.push(noteLine)
188 }
189 }
190
191 if (ho !== null && ho.compact && wide) {
192 lines.push(
193 <Box key="handoff" flexDirection="row">
194 <Text color="warning" bold wrap={T}>{ind}{ho.line}</Text>
195 </Box>,
196 )
197 }
198
199 if (ho !== null && !ho.compact) {
200 if (gaps) lines.push(gap('gap-handoff'))
201 const head = `Waiting on you · ${ho.fromEarlier ? (narrow ? 'earlier · ' : 'from an earlier session · ') : ''}${ho.ago}`
202 const tail = ruleTail(`── ${head}`, W)
203 lines.push(
204 <Box key="handoff-head" flexDirection="row">
205 <Text dimColor>── </Text>
206 <Text color="warning" bold wrap={T}>{head}</Text>
207 {tail !== '' && <Text dimColor wrap={T}>{tail}</Text>}
208 </Box>,
209 )
210 ho.summary.forEach((s, i) =>
211 lines.push(
212 <Box key={`handoff-summary-${i}`} flexDirection="row">
213 <Text wrap={T}>{ind}{s}</Text>
214 </Box>,
215 ),
216 )
217 ho.items.forEach((it, i) =>
218 lines.push(
219 <Box key={`handoff-item-${i}`} flexDirection="row" justifyContent="space-between">
220 <Box flexDirection="row" gap={narrow ? 1 : 2}>
221 <Text color={it.blocking ? 'warning' : undefined} dimColor={!it.blocking} bold={it.blocking}>{ind}{it.blocking ? '!' : '·'}</Text>
222 <Text dimColor>{it.kind}</Text>
223 <Text wrap={T}>{it.title}</Text>
224 </Box>
225 {it.ref !== null &&
226 (it.ref.href !== null ? (
227 <Box flexDirection="row">
228 <Text> </Text>
229 <Link href={it.ref.href} label={it.ref.text} />
230 </Box>
231 ) : (
232 <Text dimColor> {it.ref.text}</Text>
233 ))}
234 </Box>,
235 ),
236 )
237 if (ho.more > 0) {
238 lines.push(
239 <Box key="handoff-more" flexDirection="row">
240 <Text dimColor wrap={T}>{narrow ? ' ' : ' '}+{ho.more} more</Text>
241 </Box>,
242 )
243 }
244 }
245
246 if (wide) {
247 const f = model.footer
248 if (gaps) {
249 lines.push(gap('gap-footer'))
250 lines.push(
251 <Box key="footer-rule" flexDirection="row">
252 <Text dimColor wrap={T}>{'─'.repeat(W)}</Text>
253 </Box>,
254 )
255 }
256 const hotkeys = (
257 <Box key="footer-keys" flexDirection="row" gap={1} paddingLeft={narrow ? 1 : 0}>
258 <Button key="refresh" label="refresh" hotkey="r" plain onPress={actions.refresh} />
259 <Text dimColor>·</Text>
260 <Button key="copy-path" label="path" hotkey="p" plain onPress={actions.copyPath} />
261 <Text dimColor>·</Text>
262 <Button key="close" label="close" hotkey="c" plain onPress={actions.close} />
263 </Box>
264 )
265 if (narrow) {
266 // Two rows: the path (or the error), then the hotkeys.
267 lines.push(
268 <Box key="footer" flexDirection="row">
269 {f.error !== null ? (
270 <Text color="warning" wrap={T}> {shorten(`⚠ ${f.error}`, W - 1)}</Text>
271 ) : (
272 <Text dimColor wrap={T}> {shorten(`${f.path} · changed ${f.changedAgo}`, W - 1)}</Text>
273 )}
274 </Box>,
275 hotkeys,
276 )
277 } else {
278 // One row: the left text, a spacer of at least FOOTER_GAP, then the hotkeys
279 // (FOOTER_KEYS_WIDTH as the engine draws them). The left text is cut to fit.
280 const left = W - FOOTER_KEYS_WIDTH - FOOTER_GAP - 2
281 const suffix = ` · changed ${f.changedAgo}`
282 const text = f.error !== null ? shorten(`⚠ ${f.error}`, Math.max(4, left)) : `${shorten(f.path, Math.max(4, left - Array.from(suffix).length))}${suffix}`
283 const pad = Math.max(FOOTER_GAP, W - 2 - Array.from(text).length - FOOTER_KEYS_WIDTH)
284 lines.push(
285 <Box key="footer" flexDirection="row">
286 {f.error !== null ? <Text color="warning" wrap={T}> {text}</Text> : <Text dimColor wrap={T}> {text}</Text>}
287 <Text wrap={T}>{' '.repeat(pad)}</Text>
288 {hotkeys}
289 </Box>,
290 )
291 }
292 }
293
294 return <Box flexDirection="column">{lines}</Box>
295}
296
297/** One line in place of the pane when building it failed. */
298export function drawError(kit: Kit, text: string): RenderElement {
299 const { Box, Text } = kit
300 return (
301 <Box flexDirection="column">
302 <Text color="warning" wrap={T}>⚠ {text}</Text>
303 </Box>
304 )
305}
306
307export function drawBand(kit: Kit, _e: BandEvent, model: BandModel, actions: PaneActions): RenderElement {
308 const { Box, Text, Button } = kit
309 return (
310 <Box flexDirection="row">
311 <Text color={model.color} wrap={T}>{model.text} </Text>
312 <Button key="open" label="open" plain onPress={actions.open} />
313 </Box>
314 )
315}
316
317/** Where a wide step's title starts: indent, glyph, gap, the padded short, gap. Children line up with it. */
318const titleColumn = (narrow: boolean, shortWidth: number): number => (narrow ? 1 + 1 + 1 + shortWidth + 1 : 2 + 1 + 2 + shortWidth + 2)
319
320function drawRow(kit: Kit, row: Row, wide: boolean, narrow: boolean, mergedMore: number, shortWidth: number, width: number): RenderElement {
321 const { Box, Text } = kit
322 const indent = !wide ? '' : narrow ? ' ' : ' '
323
324 if (row.kind === 'done-summary') {
325 const shorts = row.shorts.slice(0, 6).join(' ') + (row.shorts.length > 6 ? ' …' : '')
326 return (
327 <Box key="done-summary" flexDirection="row">
328 <Text color="success" wrap={T}>{indent}✓{wide && !narrow ? ' ' : ' '}{row.count} done · {narrow ? shorten(shorts, width - 13) : shorts}</Text>
329 </Box>
330 )
331 }
332
333 if (row.kind === 'more') {
334 const text = row.position === 'before' ? `↑ ${row.count} earlier` : `+${row.count} more`
335 return (
336 <Box key={`more-${row.position}`} flexDirection="row">
337 <Text dimColor wrap={T}>{!wide ? '' : narrow ? ' ' : ' '}{text}</Text>
338 </Box>
339 )
340 }
341
342 if (row.kind === 'children') {
343 const pad = wide ? titleColumn(narrow, shortWidth) : 2
344 return (
345 <Box key="children" flexDirection="row" gap={3} paddingLeft={pad}>
346 {(narrow ? fitInline(row.items, width - pad) : row.items.slice(0, 6).map(c => ({ c, text: shorten(c.text, 24) }))).map(({ c, text }, i) => (
347 <Text key={`child-${i}`} color={colorOf(c.status)} dimColor={c.status === 'pending'} wrap={T}>
348 {glyph(c.status)} {text}
349 </Text>
350 ))}
351 </Box>
352 )
353 }
354
355 const { step, isCurrent } = row
356 const st = step.status
357 const notes: string[] = []
358 if (isCurrent) {
359 const kids = childProgress(step)
360 if (kids !== '') notes.push(kids)
361 }
362 if (st === 'blocked') notes.push(wide && !narrow ? 'waiting on you' : 'needs you')
363 if (mergedMore > 0) notes.push(`+${mergedMore} more`)
364 const noteText = notes.length > 0 ? ` ${notes.join(' ')}` : ''
365
366 const label = wide ? step.short.padEnd(shortWidth) : step.short
367 const sep = wide && !narrow ? ' ' : ' '
368 const lead = `${indent}${glyph(st)}${sep}`
369 const room = width - Array.from(lead).length - Array.from(label).length - sep.length - Array.from(noteText).length
370 const title = shorten(step.title, Math.max(4, room))
371
372 const glyphColor = isCurrent && st !== 'blocked' ? 'planMode' : colorOf(st)
373 const strong = isCurrent || st === 'blocked'
374 const textColor = st === 'blocked' ? 'warning' : isCurrent ? 'planMode' : st === 'skipped' ? 'subtle' : undefined
375 const dim = st === 'pending' && !isCurrent
376
377 return (
378 <Box key={`step-${step.id}`} flexDirection="row" justifyContent="space-between">
379 <Box flexDirection="row">
380 <Text color={glyphColor} bold={strong} dimColor={dim}>{lead}</Text>
381 <Text color={textColor} bold={strong} dimColor={dim} strikethrough={st === 'skipped'}>{label}{sep}</Text>
382 <Text color={textColor} bold={strong} dimColor={dim} strikethrough={st === 'skipped'} wrap={T}>{title}</Text>
383 </Box>
384 {noteText !== '' && (
385 <Text color={st === 'blocked' ? 'warning' : isCurrent ? 'planMode' : undefined} dimColor={mergedMore > 0 && notes.length === 1}>
386 {noteText}
387 </Text>
388 )}
389 </Box>
390 )
391}
392
393/** Narrow: as many children as fit in one row of `room` cells, each cut to 16, three cells apart (at least the first). */
394function fitInline(items: SubItem[], room: number): { c: SubItem; text: string }[] {
395 const out: { c: SubItem; text: string }[] = []
396 let used = 0
397 for (const c of items.slice(0, 6)) {
398 const text = shorten(c.text, 16)
399 const cost = (out.length > 0 ? 3 : 0) + 2 + Array.from(text).length
400 if (used + cost > room) break
401 out.push({ c, text })
402 used += cost
403 }
404 const first = items[0]
405 if (out.length === 0 && first !== undefined) out.push({ c: first, text: shorten(first.text, Math.max(4, room - 2)) })
406 return out
407}
408
409/** Wide: the current step's children one per line under its title (`pad` cells in), up to 8, then `+n more`. */
410function drawChildLines(kit: Kit, items: SubItem[], pad: number, width: number): RenderElement[] {
411 const { Box, Text } = kit
412 const out = items.slice(0, MAX_CHILD_LINES).map((c, i) => (
413 <Box key={`child-${i}`} flexDirection="row" paddingLeft={pad}>
414 <Text color={colorOf(c.status)} dimColor={c.status === 'pending'} strikethrough={c.status === 'skipped'} wrap={T}>
415 {glyph(c.status)} {shorten(c.text, Math.max(4, width - pad - 2))}
416 </Text>
417 </Box>
418 ))
419 if (items.length > MAX_CHILD_LINES) {
420 out.push(
421 <Box key="child-more" flexDirection="row" paddingLeft={pad}>
422 <Text dimColor wrap={T}>+{items.length - MAX_CHILD_LINES} more</Text>
423 </Box>,
424 )
425 }
426 return out
427}
428
429/** ` ○ #1 <title> WP5 draft copy · untrack`, the title cut to what is left. */
430function drawPrWide(kit: Kit, pr: PrLine, actions: PaneActions, width: number, quiet: boolean): RenderElement {
431 const { Box, Text, Button, Link } = kit
432 const name = pr.title || pr.branch || pr.repoName
433 const fixed = 2 + 1 + 2 + Array.from(pr.label).length + 2 + 3 + Array.from(pr.stepShort).length + 2 + Array.from(pr.status).length + 2 + 14
434 const color = quiet ? undefined : pr.color
435 return (
436 <Box key={`pr-${pr.id}`} flexDirection="row" justifyContent="space-between">
437 <Box flexDirection="row" gap={2}>
438 <Text color={color} dimColor={color === undefined}> {pr.glyph}</Text>
439 <Link href={pr.url} label={pr.label} />
440 <Text dimColor={quiet} wrap={T}>{shorten(name, Math.max(8, width - fixed))}</Text>
441 </Box>
442 <Box flexDirection="row" gap={2}>
443 <Text dimColor>{pr.stepShort}</Text>
444 <Text color={pr.color} dimColor={pr.color === undefined || quiet}>{pr.status}</Text>
445 <Box flexDirection="row" gap={1}>
446 <Button key={`copy-${pr.id}`} label="copy" plain dimColor onPress={p => actions.copyUrl(pr.url, p)} />
447 <Text dimColor>·</Text>
448 <Button key={`untrack-${pr.id}`} label="untrack" plain dimColor onPress={p => actions.untrack(pr.id, p)} />
449 </Box>
450 </Box>
451 </Box>
452 )
453}
454
455/** Narrow: ` ○ #1 <title> draft`; no step short and no buttons (the link opens it; `/waypoint untrack` remains). */
456function drawPrNarrow(kit: Kit, pr: PrLine, width: number, quiet: boolean): RenderElement {
457 const { Box, Text, Link } = kit
458 const name = pr.title || pr.branch || pr.repoName
459 // indent 1, glyph 1, gap 1, label, gap 1, title, at least 2, status
460 const fixed = 1 + 1 + 1 + Array.from(pr.label).length + 1 + 2 + Array.from(pr.status).length
461 const color = quiet ? undefined : pr.color
462 return (
463 <Box key={`pr-${pr.id}`} flexDirection="row" justifyContent="space-between">
464 <Box flexDirection="row" gap={1}>
465 <Text color={color} dimColor={color === undefined}> {pr.glyph}</Text>
466 <Link href={pr.url} label={pr.label} />
467 <Text dimColor={quiet} wrap={T}>{shorten(name, Math.max(4, width - fixed))}</Text>
468 </Box>
469 <Text color={pr.color} dimColor={pr.color === undefined || quiet}>{pr.status}</Text>
470 </Box>
471 )
472}
473hooks/handoff.ts 128 lines1// Handoff: the note the model leaves (through the tool `mcp__waypoint__handoff`)
2// when only the person can move the plan. Pure: validation, store shape, text.
3
4import type { Handoff, HandoffItem, HandoffKind } from '../types'
5
6export const HANDOFF_TOOL = 'handoff'
7export const HANDOFF_TOOL_FULL = 'mcp__waypoint__handoff'
8export const MAX_SUMMARY = 1200
9export const MAX_ITEMS = 10
10export const MAX_TITLE = 200
11export const MAX_REF = 500
12/** A stored note nobody has touched for this long is dropped at session start. */
13export const HANDOFF_TTL_MS = 30 * 86_400_000
14/** Another session's session-key note (it never saw a plan) is dropped at session start after this long. */
15export const SESSION_HANDOFF_IDLE_MS = 86_400_000
16
17const KINDS: readonly HandoffKind[] = ['decision', 'action', 'answer']
18
19export const HANDOFF_DESCRIPTION =
20 'Leave the person a short note when you stop because only they can move the plan (a decision, a merge, a credential, an answer). ' +
21 'It shows in the waypoint pane as "Waiting on you". Give a 2–4 sentence summary of where things stand and what happens after they act, ' +
22 'plus one item per thing they must do, decide or answer. Call it with clear: true when work resumes after they act, or call it again to replace the note.'
23
24export const HANDOFF_SCHEMA: Record<string, unknown> = {
25 type: 'object',
26 properties: {
27 summary: { type: 'string', description: '2–4 sentences: where the work stands and what happens after the person acts' },
28 items: {
29 type: 'array',
30 items: {
31 type: 'object',
32 properties: {
33 kind: { enum: ['decision', 'action', 'answer'] },
34 title: { type: 'string' },
35 ref: { type: 'string', description: 'PR/MR url, #n or !n this is about' },
36 blocking: { type: 'boolean' },
37 },
38 required: ['kind', 'title'],
39 },
40 },
41 clear: { type: 'boolean', description: 'true clears the note when work resumes; other fields are ignored' },
42 },
43}
44
45const clip = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
46// C0 and C1 control characters, tab and newline aside (they become spaces below).
47const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
48/** Control characters stripped, whitespace (newlines included) collapsed to single spaces. */
49export const oneLine = (s: string): string => s.replace(CONTROL, '').replace(/\s+/g, ' ').trim()
50
51function itemOf(raw: unknown): HandoffItem | null {
52 if (typeof raw !== 'object' || raw === null) return null
53 const r = raw as Record<string, unknown>
54 const title = typeof r.title === 'string' ? clip(oneLine(r.title), MAX_TITLE) : ''
55 if (title === '') return null
56 const kind = KINDS.includes(r.kind as HandoffKind) ? (r.kind as HandoffKind) : 'action'
57 const ref = typeof r.ref === 'string' ? clip(oneLine(r.ref), MAX_REF) : ''
58 return ref ? { kind, title, ref, blocking: r.blocking === true } : { kind, title, blocking: r.blocking === true }
59}
60
61export type HandoffSet = { op: 'set'; summary: string; items: HandoffItem[]; droppedItems: number; summaryClipped: boolean }
62
63/** `ok`: a clear or a set to apply. `invalid`: nothing to apply; `error` is the model's error result. */
64export type HandoffInput =
65 | { ok: true; input: { op: 'clear' } | HandoffSet }
66 | { ok: false; error: string }
67
68/** Prefix of every error result the tool returns. */
69export const NOT_NOTED = 'Handoff not noted: '
70
71/** What the model sent, made safe: never throws, whatever `raw` is. */
72export function parseHandoffInput(raw: unknown): HandoffInput {
73 const r = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
74 if (r.clear === true) return { ok: true, input: { op: 'clear' } }
75 const full = typeof r.summary === 'string' ? oneLine(r.summary) : ''
76 const summary = clip(full, MAX_SUMMARY)
77 const all = (Array.isArray(r.items) ? r.items : []).map(itemOf).filter((x): x is HandoffItem => x !== null)
78 const items = all.slice(0, MAX_ITEMS)
79 if (summary === '' && items.length === 0) {
80 return { ok: false, error: `${NOT_NOTED}give a summary or at least one item with a title (or clear: true).` }
81 }
82 return { ok: true, input: { op: 'set', summary, items, droppedItems: all.length - items.length, summaryClipped: summary !== full } }
83}
84
85/** A stored value read back: a Handoff, or null when it is not one. */
86export function sanitizeHandoff(raw: unknown): Handoff | null {
87 if (typeof raw !== 'object' || raw === null) return null
88 const r = raw as Record<string, unknown>
89 if (typeof r.summary !== 'string' || !Array.isArray(r.items) || typeof r.at !== 'number' || !Number.isFinite(r.at)) return null
90 const items = r.items.map(itemOf).filter((x): x is HandoffItem => x !== null).slice(0, MAX_ITEMS)
91 return { summary: clip(oneLine(r.summary), MAX_SUMMARY), items, at: r.at, sessionId: typeof r.sessionId === 'string' ? r.sessionId : '' }
92}
93
94/**
95 * Session-start sweep: drop an unreadable note, one older than HANDOFF_TTL_MS, and
96 * another session's session-key note older than a day. The current session's key always stays.
97 */
98export function sweepDrops(h: Handoff | null, now: number, key: { isSession: boolean; isOwn: boolean }): boolean {
99 if (key.isOwn) return false
100 if (h === null || now - h.at > HANDOFF_TTL_MS) return true
101 return key.isSession && now - h.at > SESSION_HANDOFF_IDLE_MS
102}
103
104const itemsWord = (n: number): string => `${n} item${n === 1 ? '' : 's'}`
105
106/** What the model reads back after a set. */
107export function noteResult(p: HandoffSet): string {
108 const extra = [
109 p.droppedItems > 0 ? `kept the first ${MAX_ITEMS} items` : '',
110 p.summaryClipped ? `summary shortened to ${MAX_SUMMARY} characters` : '',
111 ].filter(Boolean)
112 return `Handoff noted: ${itemsWord(p.items.length)}.${extra.length ? ` (${extra.join('; ')}.)` : ''}`
113}
114
115export const CLEARED_RESULT = 'Handoff cleared.'
116
117/** The toast (and native notification) on a set. */
118export const handoffToast = (h: Handoff): string =>
119 h.items.length > 0 ? `waypoint: waiting on you · ${itemsWord(h.items.length)}` : 'waypoint: waiting on you'
120
121/** `/waypoint handoff`. */
122export function handoffText(h: Handoff | null): string {
123 if (!h) return 'No handoff note.'
124 const lines = ['Waiting on you:', h.summary].filter(Boolean)
125 for (const it of h.items) lines.push(`${it.blocking ? '!' : '·'} ${it.kind}: ${it.title}${it.ref ? ` (${it.ref})` : ''}`)
126 return lines.join('\n')
127}
128hooks/plan.ts 411 lines1// waypoint: pure plan parser, markdown -> PlanSnapshot. No `$`, no I/O.
2import type { PlanCounts, PlanLink, PlanMode, PlanSnapshot, Step, StepStatus, SubItem } from '../types'
3
4export const MAX_BYTES = 512 * 1024
5
6const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
7const HEADING_RE = /^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/
8const MARKER_RE = /^\[([ xX~!\-])\][ \t]*(.*)$/
9const CHILD_RE = /^(?: {0,8}|\t{0,2})(?:[-*+]|\d{1,3}[.)])[ \t]+\[([ xX~!\-])\][ \t]+(.*)$/
10const TOP_CHECKBOX_RE = /^(?:[-*+]|\d{1,3}[.)])[ \t]+\[([ xX~!\-])\][ \t]+(.*)$/
11const ORDERED_RE = /^ {0,3}\d{1,3}[.)][ \t]+(.+)$/
12const STEP_LIKE_RE =
13 /^(?:WP|Step|Phase|Part|PR|Stage|Milestone|Task|Sprint|Wave)\s?[A-Z]?\d|^[A-Z]{1,2}\d{1,2}\b|^\d{1,2}[.)](?:\s|$)|^\d{1,2}\s*[—–:-]\s/i
14const SHORT_RE =
15 /^(?:((?:WP|Step|Phase|Part|PR|Stage|Milestone|Task|Sprint|Wave)\s?[A-Z]?\d+[a-z]?|[A-Z]{1,2}\d{1,2}[a-z]?)\b|(\d{1,2})(?:[.)]|\s*[—–:-]))/i
16const NON_STEP_RE = new RegExp(
17 '^(?:' +
18 [
19 'context', 'background', 'summary', 'overview', 'tl;dr', 'goals', 'objectives', 'motivation',
20 'scope', 'out of scope', 'non-goals', 'decisions', 'assumptions', 'constraints',
21 'execution model', 'ground rules', 'principles', 'architecture', 'design notes',
22 'verification', 'verify', 'validation', 'acceptance', 'risks', 'open questions', 'notes',
23 'references', 'related', 'appendix', 'addendum', 'files', 'critical files', 'follow-ups',
24 'source material', 'effort', 'estimates', 'needs\\b.*?\\b(?:call|decision|input)s?',
25 'known blockers', 'prerequisites', 'inputs checklist', 'coordination', 'rollback', 'glossary',
26 ].join('|') +
27 ')(?![\\w-])',
28 'i',
29)
30
31const MARK_STATUS: Record<string, StepStatus> = {
32 ' ': 'pending', x: 'done', X: 'done', '~': 'active', '!': 'blocked', '-': 'skipped',
33}
34const statusOf = (ch: string | undefined): StepStatus => MARK_STATUS[ch ?? ' '] ?? 'pending'
35
36// ---------- normalize / hash ----------
37
38const utf8Len = (s: string, upTo = s.length): number => {
39 let n = 0
40 for (let i = 0; i < upTo; i++) {
41 const c = s.charCodeAt(i)
42 if (c < 0x80) n += 1
43 else if (c < 0x800) n += 2
44 else if (c >= 0xd800 && c <= 0xdbff) n += 4, i++
45 else n += 3
46 }
47 return n
48}
49
50/** Strip BOM, CRLF/CR -> LF, cut at the last newline before MAX_BYTES. `bytes` is the UTF-8 size before the cut. */
51export function normalize(raw: string, maxBytes = MAX_BYTES): { text: string; bytes: number; truncated: boolean } {
52 const text = raw.replace(/^/, '').replace(/\r\n?/g, '\n')
53 const bytes = utf8Len(text)
54 if (bytes <= maxBytes) return { text, bytes, truncated: false }
55 let n = 0
56 let k = 0
57 for (; k < text.length; k++) {
58 const c = text.charCodeAt(k)
59 const w = c < 0x80 ? 1 : c < 0x800 ? 2 : c >= 0xd800 && c <= 0xdbff ? 4 : 3
60 if (n + w > maxBytes) break
61 n += w
62 if (w === 4) k++
63 }
64 const nl = text.lastIndexOf('\n', k - 1)
65 return { text: nl >= 0 ? text.slice(0, nl + 1) : text.slice(0, k), bytes, truncated: true }
66}
67
68/** FNV-1a 32-bit over UTF-16 code units, 8 hex chars. */
69/** Bump when the parser's output for the same text changes, so old snapshots never compare equal. */
70export const PARSER_VERSION = 2
71
72export function hashText(s: string): string {
73 let h = 0x811c9dc5
74 for (let i = 0; i < s.length; i++) {
75 h ^= s.charCodeAt(i)
76 h = Math.imul(h, 0x01000193) >>> 0
77 }
78 return h.toString(16).padStart(8, '0')
79}
80
81// ---------- titles ----------
82
83export type TitleLink = { label: string; href: string }
84
85/** Markdown links keep their label; autolinks and bare URLs are dropped. All hrefs are collected. */
86export function cleanTitle(s: string): { text: string; links: TitleLink[] } {
87 const links: TitleLink[] = []
88 let t = s.replace(/\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g, (_m, label: string, href: string) => {
89 links.push({ label: label.trim() || href, href })
90 return label
91 })
92 t = t.replace(/<(https?:\/\/[^>\s]+)>/g, (_m, href: string) => {
93 links.push({ label: href, href })
94 return ''
95 })
96 t = t.replace(/https?:\/\/[^\s)>\]]+/g, href => {
97 links.push({ label: href, href })
98 return ''
99 })
100 t = t.replace(/\*\*|__|`/g, '').replace(/\s+/g, ' ').trim()
101 t = t.replace(/[\s—–:|,-]+$/, '').replace(/\(\s*\)/g, '').replace(/\s+/g, ' ').trim()
102 return { text: t, links }
103}
104
105export const isNonStep = (title: string): boolean => NON_STEP_RE.test(title.trim())
106export const isStepLike = (title: string): boolean => STEP_LIKE_RE.test(title.trim())
107
108/** `WP2`, `Step 3`, `PR 1`, `D0`, `1`, else `${index+1}`; title is the rest after the label and its separator. */
109export function shortLabel(text: string, index: number): { short: string; title: string } {
110 const m = SHORT_RE.exec(text)
111 if (!m) return { short: String(index + 1), title: text }
112 const short = (m[1] ?? m[2] ?? '').trim()
113 const rest = text.slice(m[0].length).replace(/^[\s—–:\-.)/]+/, '')
114 return { short, title: rest || text }
115}
116
117// ---------- scanning ----------
118
119export type Heading = {
120 /** 0-based line index */
121 idx: number
122 depth: number
123 marker: StepStatus | null
124 text: string
125 links: TitleLink[]
126 /** index into the headings array of the nearest preceding shallower heading of depth >= 2 */
127 parent: number | null
128 /** a non-step ancestor (not counting itself) */
129 underNonStep: boolean
130 nonStep: boolean
131}
132
133export type Scan = { lines: string[]; fenced: boolean[]; headings: Heading[] }
134
135export function scanHeadings(text: string): Scan {
136 const lines = text.split('\n')
137 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
138 const fenced: boolean[] = []
139 const headings: Heading[] = []
140 let open: { ch: string; len: number } | null = null
141 for (let i = 0; i < lines.length; i++) {
142 const line = lines[i] ?? ''
143 const f = FENCE_RE.exec(line)
144 if (open) {
145 fenced.push(true)
146 const fc = f?.[1]
147 if (fc && fc[0] === open.ch && fc.length >= open.len && line.trim() === fc) open = null
148 continue
149 }
150 if (f?.[1]) {
151 fenced.push(true)
152 open = { ch: f[1][0] ?? '`', len: f[1].length }
153 continue
154 }
155 fenced.push(false)
156 const h = HEADING_RE.exec(line)
157 if (!h) continue
158 const depth = (h[1] ?? '').length
159 let rest = h[2] ?? ''
160 let marker: StepStatus | null = null
161 const mk = MARKER_RE.exec(rest)
162 if (mk) {
163 marker = statusOf(mk[1])
164 rest = mk[2] ?? ''
165 }
166 const { text: clean, links } = cleanTitle(rest)
167 let parent: number | null = null
168 if (depth >= 2) {
169 for (let j = headings.length - 1; j >= 0; j--) {
170 const p = headings[j]
171 if (p && p.depth >= 2 && p.depth < depth) {
172 parent = j
173 break
174 }
175 }
176 }
177 const par = parent === null ? undefined : headings[parent]
178 const underNonStep = par ? par.nonStep : false
179 headings.push({ idx: i, depth, marker, text: clean, links, parent, underNonStep, nonStep: underNonStep || isNonStep(clean) })
180 }
181 return { lines, fenced, headings }
182}
183
184// ---------- selection ----------
185
186export type Selection =
187 | { mode: 'marked' | 'pattern' | 'sections'; depth: number; headings: Heading[] }
188 | { mode: 'checkboxes' | 'ordered'; depth: null; items: number[] }
189 | { mode: 'none'; depth: null }
190
191export function selectSteps(scan: Scan): Selection {
192 const cands = scan.headings.filter(h => h.depth >= 2 && h.depth <= 4)
193
194 const marked = cands.filter(h => h.marker !== null)
195 if (marked.length > 0) {
196 const depth = Math.min(...marked.map(h => h.depth))
197 const parents = new Set(marked.filter(h => h.depth === depth).map(h => h.parent))
198 // A marker is the author's explicit word: a marked heading is a step even
199 // under a prose section such as `## Context`. Only unmarked siblings need
200 // a step-like name and a parent outside prose.
201 const hs = cands.filter(
202 h => h.depth === depth && (h.marker !== null || (!h.underNonStep && isStepLike(h.text) && parents.has(h.parent))),
203 )
204 if (hs.length > 0) return { mode: 'marked', depth, headings: hs }
205 }
206
207 for (const depth of [3, 2]) {
208 const c = cands.filter(h => h.depth === depth && !h.nonStep)
209 const like = c.map(h => isStepLike(h.text))
210 if (like.filter(Boolean).length < 2) continue
211 const hs = c.filter((h, i) => {
212 if (like[i]) return true
213 if (h.marker !== null) return false
214 const before = c.some((o, j) => j < i && like[j] && o.parent === h.parent)
215 const after = c.some((o, j) => j > i && like[j] && o.parent === h.parent)
216 return before && after
217 })
218 return { mode: 'pattern', depth, headings: hs }
219 }
220
221 const sections = cands.filter(h => h.depth === 2 && !h.nonStep)
222 if (sections.length >= 2) return { mode: 'sections', depth: 2, headings: sections }
223
224 const boxes: number[] = []
225 scan.lines.forEach((l, i) => {
226 if (!scan.fenced[i] && TOP_CHECKBOX_RE.test(l)) boxes.push(i)
227 })
228 if (boxes.length > 0) return { mode: 'checkboxes', depth: null, items: boxes }
229
230 const ordered = firstOrderedList(scan)
231 if (ordered.length >= 2) return { mode: 'ordered', depth: null, items: ordered }
232
233 return { mode: 'none', depth: null }
234}
235
236function firstOrderedList(scan: Scan): number[] {
237 let items: number[] = []
238 for (let i = 0; i < scan.lines.length; i++) {
239 const l = scan.lines[i] ?? ''
240 const isHeading = scan.headings.some(h => h.idx === i)
241 if (!scan.fenced[i] && !isHeading && ORDERED_RE.test(l)) {
242 items.push(i)
243 continue
244 }
245 if (items.length === 0) continue
246 // a list continues across blank and indented lines (fenced lines count as indented content)
247 if (!isHeading && (l.trim() === '' || /^[ \t]/.test(l) || scan.fenced[i])) continue
248 if (items.length >= 2) return items
249 items = []
250 }
251 return items
252}
253
254// ---------- step building ----------
255
256const isHeadingLine = (scan: Scan, i: number): Heading | undefined => scan.headings.find(h => h.idx === i)
257
258function childrenOf(scan: Scan, from: number, to: number): SubItem[] {
259 const out: SubItem[] = []
260 for (let i = from; i <= to; i++) {
261 if (scan.fenced[i]) continue
262 const m = CHILD_RE.exec(scan.lines[i] ?? '')
263 if (m) out.push({ text: cleanTitle(m[2] ?? '').text, status: statusOf(m[1]), line: i + 1 })
264 }
265 return out
266}
267
268function linksOf(scan: Scan, from: number, to: number): PlanLink[] {
269 const out: PlanLink[] = []
270 for (let i = from; i <= to; i++) {
271 if (scan.fenced[i]) continue
272 for (const l of cleanTitle(scan.lines[i] ?? '').links) out.push({ ...l, line: i + 1 })
273 }
274 return out
275}
276
277const slug = (s: string): string =>
278 s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60).replace(/-+$/, '') || 'step'
279
280type Draft = Omit<Step, 'index' | 'id'>
281
282function headingDrafts(scan: Scan, hs: Heading[]): Draft[] {
283 return hs.map((h, k) => {
284 const next = scan.headings.find(o => o.idx > h.idx && o.depth <= h.depth)
285 const end = (next ? next.idx : scan.lines.length) - 1
286 const { short, title } = shortLabel(h.text, k)
287 return {
288 short,
289 title,
290 status: h.marker ?? 'pending',
291 marked: h.marker !== null,
292 depth: h.depth,
293 line: h.idx + 1,
294 endLine: Math.max(end, h.idx) + 1,
295 body: scan.lines.slice(h.idx + 1, end + 1).join('\n'),
296 children: childrenOf(scan, h.idx + 1, end),
297 links: [...h.links.map(l => ({ ...l, line: h.idx + 1 })), ...linksOf(scan, h.idx + 1, end)],
298 }
299 })
300}
301
302function listDrafts(scan: Scan, items: number[], checkbox: boolean): Draft[] {
303 return items.map((idx, k) => {
304 const nextItem = items[k + 1]
305 let end = idx
306 for (let i = idx + 1; i < scan.lines.length; i++) {
307 if (i === nextItem || isHeadingLine(scan, i)) break
308 const l = scan.lines[i] ?? ''
309 if (!scan.fenced[i] && l.trim() !== '' && !/^[ \t]/.test(l)) break
310 end = i
311 }
312 while (end > idx && (scan.lines[end] ?? '').trim() === '') end--
313 const line = scan.lines[idx] ?? ''
314 const m = checkbox ? TOP_CHECKBOX_RE.exec(line) : ORDERED_RE.exec(line)
315 const raw = (checkbox ? m?.[2] : m?.[1]) ?? line
316 const { text, links } = cleanTitle(raw)
317 return {
318 short: String(k + 1),
319 title: text,
320 status: checkbox ? statusOf(m?.[1]) : 'pending',
321 marked: checkbox,
322 depth: 0,
323 line: idx + 1,
324 endLine: end + 1,
325 body: scan.lines.slice(idx + 1, end + 1).join('\n'),
326 children: childrenOf(scan, idx + 1, end),
327 links: [...links.map(l => ({ ...l, line: idx + 1 })), ...linksOf(scan, idx + 1, end)],
328 }
329 })
330}
331
332function withIds(drafts: Draft[]): Step[] {
333 const seen = new Map<string, number>()
334 return drafts.map((d, index) => {
335 const base = slug(`${d.short} ${d.title}`)
336 const n = (seen.get(base) ?? 0) + 1
337 seen.set(base, n)
338 return { ...d, index, id: n === 1 ? base : `${base}-${n}` }
339 })
340}
341
342/** The first `#` heading outside fences, else the first short (≤ 80) non-heading line before any other text; null when neither. */
343function scanTitle(scan: Scan): string | null {
344 const h1 = scan.headings.find(h => h.depth === 1)
345 if (h1 && h1.text) return h1.text
346 for (let i = 0; i < scan.lines.length; i++) {
347 const l = (scan.lines[i] ?? '').trim()
348 if (scan.fenced[i] || l === '' || isHeadingLine(scan, i)) continue
349 const t = cleanTitle(l).text
350 if (t && t.length <= 80) return t
351 break
352 }
353 return null
354}
355
356const displayOf = (title: string): string => title.replace(/^plan:\s*/i, '') || title
357
358/** A plan file's title as the parser takes it (`displayTitle` without a `Plan:` prefix); null when the text has none. */
359export function planTitle(raw: string): { title: string; displayTitle: string } | null {
360 const title = scanTitle(scanHeadings(normalize(raw).text))
361 return title === null ? null : { title, displayTitle: displayOf(title) }
362}
363
364function titleOf(scan: Scan, path: string): string {
365 const t = scanTitle(scan)
366 if (t !== null) return t
367 const base = path.split('/').pop() ?? path
368 return base.replace(/\.md$/i, '') || path
369}
370
371export function parsePlan(raw: string, opts: { path: string; mtimeMs: number; now: number }): PlanSnapshot {
372 const { text, bytes, truncated } = normalize(raw)
373 const warnings: string[] = []
374 if (truncated) warnings.push(`plan larger than ${MAX_BYTES / 1024} KiB; parsed the first part only`)
375 const scan = scanHeadings(text)
376 const sel = selectSteps(scan)
377
378 let drafts: Draft[] = []
379 if (sel.mode === 'marked' || sel.mode === 'pattern' || sel.mode === 'sections') drafts = headingDrafts(scan, sel.headings)
380 else if (sel.mode === 'checkboxes' || sel.mode === 'ordered') drafts = listDrafts(scan, sel.items, sel.mode === 'checkboxes')
381 const mode: PlanMode = sel.mode
382 if (mode === 'none') warnings.push('no steps found: mark step headings with [ ], [~], [x]')
383
384 const steps = withIds(drafts)
385 const counts: PlanCounts = { total: steps.length, done: 0, active: 0, blocked: 0, pending: 0, skipped: 0 }
386 for (const s of steps) counts[s.status]++
387 // floor: 100% only when every step is finished (199 of 200 is 99%, not a rounded 100%).
388 const percent = counts.total === 0 ? 0 : Math.floor((100 * (counts.done + counts.skipped)) / counts.total)
389 const first = (st: StepStatus): number | null => steps.find(s => s.status === st)?.index ?? null
390 const currentIndex = first('active') ?? first('blocked') ?? first('pending')
391
392 const title = titleOf(scan, opts.path)
393 return {
394 path: opts.path,
395 title,
396 displayTitle: displayOf(title),
397 steps,
398 mode,
399 stepDepth: sel.depth,
400 counts,
401 percent,
402 currentIndex,
403 mtimeMs: opts.mtimeMs,
404 bytes,
405 truncated,
406 hash: hashText(`v${PARSER_VERSION}\n${text}`),
407 parsedAt: opts.now,
408 warnings,
409 }
410}
411hooks/prompt.ts 47 lines1// The system-prompt section waypoint appends (id `waypoint:plan`, scope `session`).
2// Static per (path, mode): never interpolate counts or progress, or every edit
3// to the plan file would bust the prompt cache.
4
5import { PLAN_STYLE_RULES } from './style'
6
7export const SECTION_ID = 'waypoint:plan'
8
9export function planSection(path: string, inPlanMode: boolean): string {
10 return inPlanMode ? planningText() : executingText(path)
11}
12
13function executingText(path: string): string {
14 return [
15 '# Waypoint: plan progress',
16 `A pane tracks the plan at ${path} and draws its steps. Keep the file's status markers current:`,
17 '- A step is a heading whose text starts with a marker: `### [ ] WP1 — Title` pending, `[~]` in progress, `[x]` done, `[!]` blocked on the user, `[-]` dropped. An unmarked step heading counts as pending.',
18 '- When you start a step, Edit its heading to `[~]`; when it is finished, `[x]`; if you need the user, `[!]`. Keep one `[~]` at a time. Child `- [ ]` items under a step may be ticked `- [x]` as you go.',
19 '- When you open or wait on a PR/MR for a step, add a line `- PR: <url>` under that step.',
20 '- When you stop because only the person can move the plan (a decision, a merge, a credential, an answer), first call the tool mcp__waypoint__handoff with a 2–4 sentence summary of where things stand and what happens next, plus one item per thing they must do, decide or answer (kind decision | action | answer, blocking when the plan cannot move without it). When you resume after they act, call it with clear: true, or replace it.',
21 'Change only markers and such lines; do not restructure the plan file.',
22 ].join('\n')
23}
24
25function planningText(): string {
26 return [
27 '# Waypoint: plan progress',
28 'A pane will draw the plan file\'s steps as progress.',
29 PLAN_STYLE_RULES,
30 'Start every step heading\'s text with `[ ]`, e.g. `### [ ] WP1 — Title`. Leave Context, Decisions, Verification and other prose sections unmarked. During execution the marker becomes `[~]` (in progress), `[x]` (done), `[!]` (blocked on the user) or `[-]` (dropped).',
31 ].join('\n')
32}
33
34export const HANDOFF_SECTION_ID = 'waypoint:handoff'
35
36/**
37 * Appended (id `waypoint:handoff`, scope `session`) only while a handoff note left by
38 * an earlier session is showing. Static until that note is cleared or replaced.
39 */
40export function earlierHandoffSection(summary: string): string {
41 const one = Array.from(summary.replace(/\s+/g, ' ').trim()).slice(0, 120).join('')
42 return [
43 '# Waypoint: earlier handoff',
44 `A handoff note from an earlier session is still showing in the pane: "${one}". When the person has acted on it, call mcp__waypoint__handoff with clear: true, or replace it.`,
45 ].join('\n')
46}
47hooks/style.ts 290 lines1// waypoint: the Plan Style Guide and a pure linter for plan files. No `$`, no I/O.
2import type { PlanMode } from '../types'
3import { hashText, isNonStep, isStepLike, normalize, parsePlan, scanHeadings, type Heading } from './plan'
4
5export const PLAN_STYLE_GUIDE = `# Waypoint: plan style
6A pane reads this plan file and draws its steps, so give the file this shape. Prose is free; only step lines are structural.
7
8 # <What this plan delivers, in plain words>
9
10 ## Context
11 Why, constraints, links, decisions so far. Any length. Never a step.
12
13 ### [ ] WP1 — <short imperative title, about 45 characters>
14 One or two lines on intent and what "done" means.
15 - [ ] <checkable outcome>
16 - [ ] <checkable outcome>
17 - PR: <url> (added once it exists)
18
19 ### [ ] WP2 — ...
20
21 ## Decisions / Open questions / Verification
22 Unmarked closing sections, excluded by name.
23
24Rules:
25- Every unit of work is a \`###\` heading that starts with \`[ ]\`, numbered WP1, WP2, …; nothing else is a step. Checkbox lines are children of a step, never steps.
26- Prose lives under \`## Context\`, under a step, or in a named closing section; never as a bare \`##\` heading that reads like a unit of work.
27- Markers \`[ ]\` \`[~]\` \`[x]\` \`[!]\` \`[-]\` and \`- PR:\` lines are the only lines that change while the plan runs.
28- Revising a plan another session may have open: add or re-mark steps; keep its prose as it is.`
29
30export const PLAN_STYLE_RULES =
31 'Plan shape: one `#` title; `## Context` for the why; each unit of work a `### [ ] WPn — Title` heading (one depth, about 45 characters, children as `- [ ]` lines); unmarked closing sections such as `## Verification`. Checkbox lines are children, never steps.'
32
33export type LintSeverity = 'fix' | 'nit'
34export interface LintFinding {
35 code: string
36 severity: LintSeverity
37 line?: number
38 message: string
39 /** The heading or step title the finding is about, when there is one; keys the finding across line shifts. */
40 anchor?: string
41}
42/** One entry per step the parser found, in order. */
43export interface LintHeading { line: number; title: string; marked: boolean }
44export interface LintReport { mode: PlanMode; steps: number; headings: LintHeading[]; findings: LintFinding[] }
45
46/** The pane truncates step titles past roughly this many columns. */
47export const TITLE_WIDTH = 45
48const MAX_CHILDREN = 8
49const NOTE_LINES = 6
50
51/** A step label such as `WP1`, `WP6b`, `Step 2a`, `1.` or `1)`. */
52const LABEL_RE = /^(?:(?:WP|Step|Phase|Part|PR|Stage|Milestone|Task|Sprint|Wave)\s?[A-Z]?\d+[a-z]?|[A-Z]{1,2}\d{1,2}[a-z]?|\d{1,3}[a-z]?[.)]|\d{1,3}\s*[—–:-])(?=[\s—–:.)/-]|$)/i
53export const hasStepLabel = (heading: string): boolean => LABEL_RE.test(heading.trim()) || isStepLike(heading)
54
55const ANY_CHECKBOX_RE = /^[ \t]*(?:[-*+]|\d{1,3}[.)])[ \t]+\[[ xX~!\-]\](?:[ \t]|$)/
56const len = (s: string): number => Array.from(s).length
57const hashes = (h: Heading): string => '#'.repeat(h.depth)
58const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
59
60export function lintPlan(text: string): LintReport {
61 const snap = parsePlan(text, { path: 'plan.md', mtimeMs: 0, now: 0 })
62 const scan = scanHeadings(normalize(text).text)
63 const mode = snap.mode
64 const steps = snap.steps
65 const fix: LintFinding[] = []
66 const nit: LintFinding[] = []
67 const headingAt = (line: number): Heading | undefined => scan.headings.find(h => h.idx === line - 1)
68
69 // ---- fix ----
70 if (steps.length === 0) {
71 fix.push({ code: 'no-steps', severity: 'fix', message: 'No steps found; add a `### [ ] WP1 — Title` heading for each unit of work.' })
72 } else if (mode !== 'marked') {
73 fix.push({ code: 'heuristic-steps', severity: 'fix', message: heuristicMessage(mode, steps.length) })
74 }
75
76 if (mode === 'marked') {
77 const loose: number[] = []
78 scan.lines.forEach((l, i) => {
79 if (scan.fenced[i] || !ANY_CHECKBOX_RE.test(l)) return
80 const line = i + 1
81 if (!steps.some(s => line > s.line && line <= s.endLine)) loose.push(line)
82 })
83 if (loose.length > 0)
84 fix.push({
85 code: 'loose-checkboxes',
86 severity: 'fix',
87 line: loose[0],
88 message: `${plural(loose.length, 'checkbox line')} ${loose.length === 1 ? 'sits' : 'sit'} outside any step; move ${loose.length === 1 ? 'it' : 'them'} under a step heading or make ${loose.length === 1 ? 'it a plain bullet' : 'them plain bullets'}.`,
89 })
90 }
91
92 const marked = scan.headings.filter(h => h.depth >= 2 && h.marker !== null)
93 const depths = [...new Set(marked.map(h => h.depth))].sort((a, b) => a - b)
94 if (depths.length > 1) {
95 const main = snap.stepDepth ?? depths[0] ?? 3
96 const odd = marked.find(h => h.depth !== main)
97 fix.push({
98 code: 'mixed-depth',
99 severity: 'fix',
100 line: odd ? odd.idx + 1 : undefined,
101 anchor: odd?.text,
102 message: `Marked headings sit at ${depths.map(d => '#'.repeat(d)).join(' and ')}; put every step at one depth (\`###\` in the guide) and turn the others into \`- [ ]\` children.`,
103 })
104 }
105
106 if (mode === 'marked' && snap.stepDepth !== null) {
107 const stepHeadings = steps.map(s => headingAt(s.line)).filter((h): h is Heading => h !== undefined)
108 const stepParents = new Set(stepHeadings.map(h => h.parent))
109 for (const h of scan.headings) {
110 if (h.depth !== snap.stepDepth || h.marker !== null || isNonStep(h.text)) continue
111 // A prose sub-heading under a prose section (e.g. `### Reuse` under `## Context`) is fine,
112 // unless the steps themselves live under that section.
113 if (h.underNonStep && !stepParents.has(h.parent)) continue
114 nit.push({
115 code: 'unmarked-step',
116 severity: 'nit',
117 line: h.idx + 1,
118 anchor: h.text,
119 message: `Add \`[ ]\` to "${hashes(h)} ${h.text}" or move it under a closing section.`,
120 })
121 }
122 }
123
124 // ---- nit ----
125 const h1 = scan.headings.find(h => h.depth === 1)
126 if (!h1) nit.push({ code: 'no-title', severity: 'nit', message: 'No `#` title; start the file with `# <what this plan delivers>`.' })
127 else if (/^plan:/i.test(h1.text))
128 nit.push({ code: 'title-prefix', severity: 'nit', line: h1.idx + 1, anchor: h1.text, message: 'Drop the `Plan:` prefix from the title; the pane already says it is a plan.' })
129
130 for (const s of mode === 'marked' ? steps : []) {
131 const n = len(s.title)
132 if (n > TITLE_WIDTH)
133 nit.push({ code: 'long-title', severity: 'nit', line: s.line, anchor: s.title, message: `"${s.title}" is ${n} characters; shorten it, the pane truncates past ${TITLE_WIDTH}.` })
134 }
135 for (const s of steps) {
136 if (s.children.length > MAX_CHILDREN)
137 nit.push({
138 code: 'many-children',
139 severity: 'nit',
140 line: s.line,
141 anchor: s.title,
142 message: `Step ${s.short} has ${s.children.length} checkbox children; split it into smaller steps or keep at most ${MAX_CHILDREN}.`,
143 })
144 }
145 const active = steps.filter(s => s.status === 'active')
146 if (active.length > 1)
147 nit.push({
148 code: 'multiple-active',
149 severity: 'nit',
150 line: active[1]?.line,
151 anchor: active.map(s => s.title).join(' | '),
152 message: `${active.length} steps are marked \`[~]\`; keep one in progress and set the others to \`[ ]\` or \`[x]\`.`,
153 })
154 if (mode === 'marked' && steps.length > 0 && !steps.some(s => hasStepLabel(headingAt(s.line)?.text ?? '')))
155 nit.push({ code: 'unnumbered', severity: 'nit', message: 'No step carries a number; label them `WP1 — `, `WP2 — `, … so they can be referred to.' })
156
157 const headings = steps.map(s => ({ line: s.line, title: s.title, marked: s.marked }))
158 return { mode, steps: steps.length, headings, findings: sortFindings([...fix, ...nit]) }
159}
160
161/** By line, findings without a line first; stable otherwise. */
162function sortFindings(fs: LintFinding[]): LintFinding[] {
163 return fs
164 .map((f, i) => ({ f, i }))
165 .sort((a, b) => (a.f.line ?? 0) - (b.f.line ?? 0) || a.i - b.i)
166 .map(x => x.f)
167}
168
169function heuristicMessage(mode: PlanMode, n: number): string {
170 const steps = plural(n, 'step')
171 switch (mode) {
172 case 'pattern':
173 return `Mode pattern: the ${steps} were guessed from heading names; start every step heading with \`[ ]\`.`
174 case 'sections':
175 return `Mode sections: every \`##\` heading was read as a step (${steps}); mark the real steps \`### [ ] WPn — Title\` and keep prose under \`## Context\`.`
176 case 'checkboxes':
177 return `Mode checkboxes: top-level checkboxes were read as steps (${steps}); put them under \`### [ ] WPn — Title\` step headings.`
178 case 'ordered':
179 return `Mode ordered: a numbered list was read as steps (${steps}); turn each item into a \`### [ ] WPn — Title\` heading.`
180 default:
181 return `Mode ${mode}: steps were found without markers (${steps}); start every step heading with \`[ ]\`.`
182 }
183}
184
185/** "5 steps from markers", "5 steps from ## sections, none marked", "0 steps found". */
186export function summaryOf(r: LintReport): string {
187 if (r.steps === 0) return '0 steps found'
188 const n = plural(r.steps, 'step')
189 switch (r.mode) {
190 case 'marked': return `${n} from markers`
191 case 'pattern': return `${n} guessed from heading names, none marked`
192 case 'sections': return `${n} from ## sections, none marked`
193 case 'checkboxes': return `${n} from top-level checkboxes`
194 case 'ordered': return `${n} from a numbered list, none marked`
195 default: return `${n} found`
196 }
197}
198
199const CODE_WIDTH = 18
200const LOC_WIDTH = 9
201const row = (f: LintFinding): string =>
202 ` ${(f.line === undefined ? '' : `line ${f.line}`).padEnd(LOC_WIDTH)}${f.code.padEnd(CODE_WIDTH)}${f.message}`
203
204/** Plain-text report for `/waypoint lint`. */
205export function lintText(report: LintReport, opts: { title: string; file: string }): string {
206 const out = [`${opts.title} (${opts.file}): ${summaryOf(report)}`]
207 const fixes = report.findings.filter(f => f.severity === 'fix')
208 const nits = report.findings.filter(f => f.severity === 'nit')
209 if (fixes.length > 0) out.push('Fix', ...fixes.map(row))
210 if (nits.length > 0) out.push('Nits', ...nits.map(row))
211 if (report.findings.length === 0) out.push('Looks fine.')
212 else out.push('Run /waypoint conform to apply the guide without touching prose, or /waypoint conform rewrite to reshape headings too.')
213 return out.join('\n')
214}
215
216const bullet = (f: LintFinding): string => `- ${f.message}${f.line === undefined ? '' : ` (line ${f.line})`}`
217
218/** Hidden context after the model writes (all findings) or edits (fixes only) a plan file; undefined when clean. */
219export function writeNote(report: LintReport, opts: { file: string; kind: 'write' | 'edit'; inPlanMode: boolean }): string | undefined {
220 const shown = opts.kind === 'edit' ? report.findings.filter(f => f.severity === 'fix') : report.findings
221 if (shown.length === 0) return undefined
222 const lines = shown.slice(0, NOTE_LINES).map(bullet)
223 if (shown.length > NOTE_LINES) lines.push(`… and ${shown.length - NOTE_LINES} more`)
224 return [
225 `Waypoint read ${opts.file} after that ${opts.kind}: ${summaryOf(report)}.`,
226 ...lines,
227 opts.inPlanMode
228 ? 'Fix the shape now while the plan is in hand; /waypoint lint repeats this report.'
229 : 'If this shape is intentional, ignore this note. /waypoint conform applies the guide without touching prose; /waypoint lint repeats this report.',
230 '',
231 PLAN_STYLE_RULES,
232 ].join('\n')
233}
234
235/** Hidden context for `/waypoint conform` (additive) and `/waypoint conform rewrite`. */
236export function conformInstruction(report: LintReport, opts: { path: string; title: string; rewrite: boolean }): string {
237 const headingMode = report.mode === 'marked' || report.mode === 'pattern' || report.mode === 'sections'
238 const listed =
239 report.headings.length === 0
240 ? ['- none']
241 : report.headings.map(h => `- line ${h.line}: ${h.title} (${h.marked ? 'marked' : 'unmarked'})`)
242 const how = opts.rewrite
243 ? [
244 'Rewrite mode: you may reshape headings into the guide skeleton.',
245 '- Put every step at one depth (`###`), labelled `WP1 — `, `WP2 — `, … with titles of about 45 characters.',
246 '- Move checkbox lines under the step they belong to.',
247 '- You may drop a `Plan:` prefix from the `#` title.',
248 '- Reword and move headings as needed, but never delete a line of prose.',
249 ]
250 : [
251 'Additive mode: change as little as possible. Keep every existing prose line and its order.',
252 headingMode && report.headings.length > 0
253 ? report.mode === 'marked'
254 ? '- Only add a marker to these existing step headings (the lines listed above); do not reword or move them.'
255 : '- Only add a marker to these existing step headings (the lines listed above), or, if these headings are not units of work, leave them alone and add a `## Steps` section before any closing section whose `### [ ] WPn — Title` entries name the sections or paragraphs they execute. Do not reword or move them.'
256 : '- There are no step headings: add a `## Steps` section before any closing section, whose `### [ ] WPn — Title` entries name the sections or paragraphs they execute.',
257 '- Add new `- [ ]` lines only where the prose lists outcomes, and leave every existing bullet exactly as it is.',
258 '- Do not change the title.',
259 ]
260 const findings =
261 report.findings.length === 0
262 ? ['- none']
263 : report.findings.map(f => `- ${f.line === undefined ? '' : `line ${f.line} `}${f.code}: ${f.message}`)
264 return [
265 `/waypoint conform${opts.rewrite ? ' rewrite' : ''}: bring the plan "${opts.title}" at ${opts.path} into the Waypoint plan style.`,
266 'Change it with the Edit tool, one edit at a time; never rewrite the file with Write.',
267 `The parser took these as steps (${summaryOf(report)}):`,
268 ...listed,
269 ...how,
270 'In both modes:',
271 '- Mark steps the prose already says are done as `[x]`.',
272 '- Mark at most one step `[~]`.',
273 '- Leave excluded closing sections (Context, Decisions, Open questions, Verification and the like) unmarked.',
274 PLAN_STYLE_RULES,
275 'Lint findings:',
276 ...findings,
277 'Show the diff of your edits in the reply. After the edit the pane re-reads the file by itself.',
278 ].join('\n')
279}
280
281/**
282 * A short stable key over mode, step count and sorted `code@anchor` (or `code`), so the caller can
283 * skip an identical note. Line numbers are left out: a `- PR:` line above a heading keeps the key.
284 */
285export function findingsKey(report: LintReport): string {
286 const parts = report.findings.map(f => (f.anchor === undefined ? f.code : `${f.code}@${f.anchor}`)).sort()
287 return hashText(`${report.mode}:${report.steps}|${parts.join(',')}`)
288}
289
290hooks/prs/index.ts 63 lines1// waypoint PRs: public surface for register.tsx. Pure.
2
3import type { PrEntry, RepoRef } from '../../types'
4import { colorOf, statusWord, type PrColor } from './classify'
5import { prLabel } from './refs'
6
7export * from './refs'
8export * from './commands'
9export * from './classify'
10export * from './poll'
11
12/** Last path segment: `ecfx/ecfx-backend` → `ecfx-backend`. */
13export function repoNameOf(ref: Pick<RepoRef, 'path'>): string {
14 return ref.path.split('/').filter(Boolean).pop() ?? ref.path
15}
16
17export type PrRow = {
18 glyph: string
19 label: string
20 repoName: string
21 branch: string
22 status: string
23 color: PrColor
24 url: string
25 id: string
26 stepId: string | undefined
27 /** The PR/MR title; '' until the first poll fills it. */
28 title: string
29}
30
31function glyphOf(e: PrEntry): string {
32 if (e.state === 'merged') return '●'
33 if (e.state === 'closed') return '⊘'
34 if (e.draft) return '◌'
35 switch (e.attention) {
36 case 'fix-ci':
37 case 'resolve-conflicts':
38 return '✗'
39 case 'address-review':
40 return '✎'
41 case 'ready-to-merge':
42 return '✓'
43 default:
44 return '○'
45 }
46}
47
48/** Pane row data (no JSX). Errored entries show `⚠ <class>` as their status. */
49export function rowOf(entry: PrEntry, _now: number): PrRow {
50 return {
51 glyph: glyphOf(entry),
52 label: prLabel(entry),
53 repoName: repoNameOf(entry),
54 branch: entry.headRef ?? '',
55 status: entry.error ? `⚠ ${entry.error}` : statusWord(entry),
56 color: colorOf(entry),
57 url: entry.url,
58 id: entry.id,
59 stepId: entry.stepId,
60 title: entry.title ?? '',
61 }
62}
63hooks/track.ts 115 lines1// waypoint: pure tracking helpers (paths, resume rule, source precedence). No `$`, no I/O.
2import type { PlanSource, StoredPlan } from '../types'
3
4const DAY_MS = 24 * 60 * 60 * 1000
5
6/** `~` and `~/x` expand against `home`; anything else is returned as is. */
7export function expandHome(p: string, home: string): string {
8 if (p === '~') return home
9 if (p.startsWith('~/')) return `${home.replace(/\/+$/, '')}/${p.slice(2)}`
10 return p
11}
12
13/** Collapse `//`, `.` and `..` segments; strip a trailing slash (root stays `/`). */
14export function normalizePath(p: string): string {
15 const abs = p.startsWith('/')
16 const out: string[] = []
17 for (const seg of p.split('/')) {
18 if (seg === '' || seg === '.') continue
19 if (seg === '..') {
20 if (out.length > 0 && out[out.length - 1] !== '..') out.pop()
21 else if (!abs) out.push('..')
22 continue
23 }
24 out.push(seg)
25 }
26 const s = out.join('/')
27 return abs ? `/${s}` : s || '.'
28}
29
30/** Same file after `~` expansion (when `home` is given) and path normalization. Empty paths never match. */
31export function samePath(a: string | null | undefined, b: string | null | undefined, home?: string): boolean {
32 if (!a || !b) return false
33 const x = home === undefined ? a : expandHome(a, home)
34 const y = home === undefined ? b : expandHome(b, home)
35 return normalizePath(x) === normalizePath(y)
36}
37
38/**
39 * `/waypoint use <arg>`: absolute and `~/` paths as given; anything containing `/` is cwd-relative;
40 * a bare name is a slug in `planDir` (`<planDir>/<slug>.md`, a trailing `.md` on the slug is not doubled).
41 */
42export function resolveUseArg(arg: string, cwd: string, planDir: string, home: string): string {
43 const a = arg.trim()
44 if (a.startsWith('/')) return normalizePath(a)
45 if (a === '~' || a.startsWith('~/')) return normalizePath(expandHome(a, home))
46 if (a.includes('/')) return normalizePath(`${cwd}/${a}`)
47 const dir = normalizePath(expandHome(planDir, home))
48 return `${dir === '/' ? '' : dir}/${a.replace(/\.md$/i, '')}.md`
49}
50
51/** `$.store` key holding the last plan tracked in this cwd. */
52export const storeKey = (cwd: string): string => `last:${cwd}`
53
54/**
55 * Resume a stored plan at session start when the file exists (`stat` non-null), was modified within
56 * `resumeDays`, and is either unfinished or modified within the last 24 h. False means: delete the key.
57 */
58export function shouldResume(
59 rec: StoredPlan | null | undefined,
60 stat: { mtimeMs: number } | null | undefined,
61 now: number,
62 resumeDays: number,
63): boolean {
64 if (!rec || !rec.path || !stat) return false
65 if (!(resumeDays > 0)) return false
66 const age = now - stat.mtimeMs
67 if (age > resumeDays * DAY_MS) return false
68 return rec.percent < 100 || age <= DAY_MS
69}
70
71const RANK: Record<PlanSource, number> = { resume: 0, exit_plan: 1, plan_mode: 2, manual: 3 }
72
73/** What `track(path, source)` should do given what is tracked now. */
74export type TrackDecision = 'replace' | 'same' | 'keep'
75
76/**
77 * Source precedence: manual > plan_mode > exit_plan > resume. An incoming source replaces the current one
78 * when it ranks the same or higher (so manual is only replaced by manual). The same path is `same`
79 * (refresh only, source unchanged), whatever the source.
80 */
81export function nextSource(
82 current: { path: string; source: PlanSource } | null | undefined,
83 incoming: { path: string; source: PlanSource },
84 home?: string,
85): TrackDecision {
86 if (!current) return 'replace'
87 if (samePath(current.path, incoming.path, home)) return 'same'
88 return RANK[incoming.source] >= RANK[current.source] ? 'replace' : 'keep'
89}
90
91const LAST_PREFIX = storeKey('')
92/** A store key holding a `last:<cwd>` record. */
93export const isLastKey = (key: string): boolean => key.startsWith(LAST_PREFIX)
94
95/**
96 * The stored `last:<cwd>` record for `path` among `records` (key, value pairs): the newest by `at` wins.
97 * Gives its percent (null when not a number) and the cwd from its key.
98 */
99export function recordFor(
100 path: string,
101 records: { key: string; value: unknown }[],
102): { percent: number | null; cwd: string } | null {
103 let best: { at: number; percent: number | null; cwd: string } | null = null
104 for (const { key, value } of records) {
105 if (!isLastKey(key) || !value || typeof value !== 'object') continue
106 const r = value as Partial<StoredPlan>
107 if (typeof r.path !== 'string' || !samePath(r.path, path)) continue
108 const at = typeof r.at === 'number' ? r.at : 0
109 if (best !== null && best.at >= at) continue
110 const percent = typeof r.percent === 'number' && Number.isFinite(r.percent) ? r.percent : null
111 best = { at, percent, cwd: key.slice(LAST_PREFIX.length) }
112 }
113 return best === null ? null : { percent: best.percent, cwd: best.cwd }
114}
115hooks/view.ts 680 lines1// waypoint: pure presentation. No `$`, no JSX, no I/O: everything the pane,
2// band and status line draw is computed here from the atoms' values.
3
4import type { Handoff, Layout, PlanCounts, PlanSnapshot, PrsView, Step, StepStatus, SubItem, Tracked, WaypointUi } from '../types'
5
6export type StatusColor = 'success' | 'planMode' | 'inactive' | 'warning' | 'subtle'
7
8/** One PR/MR row as the PR subsystem's `rowOf(entry, now)` returns it. */
9export type PrRow = {
10 glyph: string
11 label: string
12 repoName: string
13 branch: string
14 status: string
15 color: 'error' | 'warning' | 'success' | 'subtle' | undefined
16 url: string
17 id: string
18 stepId?: string
19 /** The PR/MR title, drawn first in the wide row. */
20 title?: string
21}
22
23/** A PR row plus the short label of the step it belongs to, if any. */
24export type PrLine = PrRow & { stepShort: string }
25
26export type Row =
27 | { kind: 'step'; step: Step; isCurrent: boolean }
28 | { kind: 'done-summary'; count: number; shorts: string[] }
29 | { kind: 'more'; count: number; position: 'before' | 'after' }
30 /** The current step's checkbox items: one row (`perLine` false), or one row each (wide, when the rows allow). */
31 | { kind: 'children'; items: SubItem[]; perLine: boolean }
32
33/** A recent plan file for the picker: `name` is the file slug; title, percent and cwd when known. */
34export type RecentPlan = { name: string; path: string; mtimeMs: number; title: string | null; percent: number | null; cwd: string | null }
35/** `label` is the Button label, fitted to the pane; `where` is the dim `in ~/…` line (wide only, cwd known). */
36export type RecentRow = { name: string; path: string; ago: string; label: string; where: string | null }
37
38export type BarModel = { done: string; current: string; currentColor: 'planMode' | 'warning'; rest: string }
39
40/** One item of the handoff note, ready to draw. `ref.href` is set when it resolves to a tracked PR or is a URL. */
41export type HandoffLine = { blocking: boolean; kind: string; title: string; ref: { text: string; href: string | null } | null }
42
43export type HandoffSection = {
44 ago: string
45 /** Left by another session (shown as a recap on resume): the header says so. */
46 fromEarlier: boolean
47 /** Drawn as `line` alone (compact layout, or wide out of rows). */
48 compact: boolean
49 /** The summary pre-wrapped to width − 2, at most 6 lines (fewer when rows are tight); a cut last line ends in `…`. */
50 summary: string[]
51 items: HandoffLine[]
52 more: number
53 /** Compact's one line: `! waiting on you · <first item>` (+n). */
54 line: string
55}
56
57export type PrSection = {
58 /** PRs that need you (all of them, not only the shown ones). */
59 count: number
60 /** Open PRs, all of them. */
61 open: number
62 /** The header after `Pull requests`: `· ` + `need` (warning when `hot`) + `after`. */
63 head: { need: string; hot: boolean; before: string; after: string }
64 rows: PrLine[]
65 others: PrLine[]
66 polled: string | null
67 note: string
68 /** Drawn as one line for the PR most in need, plus the note (compact layout, or wide out of rows). */
69 compact: boolean
70}
71
72export type PaneModel =
73 | {
74 kind: 'plan'
75 layout: 'compact' | 'wide'
76 /** Wide at 44–63 columns: one-space indents, no PR buttons, the footer on two rows. */
77 narrow: boolean
78 width: number
79 header: { title: string; right: string; lead: string; pct: string }
80 bar: BarModel
81 rows: Row[]
82 /** Width the step shorts are padded to (wide). */
83 shortWidth: number
84 /** `rows` need attention; `others` are the remaining open PRs. Merged and closed PRs are never listed. */
85 prSection: PrSection | null
86 handoff: HandoffSection | null
87 /** Wide: the blank rows between sections (dropped when rows are short). */
88 gaps: boolean
89 footer: { path: string; changedAgo: string; error: string | null }
90 }
91 | { kind: 'none-tracked'; recent: RecentRow[]; planDir: string; handoffLine: string | null }
92 | { kind: 'planning'; handoffLine: string | null }
93 | { kind: 'missing'; path: string; lastCounts: PlanCounts | null; handoffLine: string | null }
94 | { kind: 'no-steps'; title: string; path: string; handoffLine: string | null }
95
96export type PaneInput = {
97 snap: PlanSnapshot | null
98 tracked: Tracked | null
99 prs: PrsView
100 prRows: PrRow[]
101 ui: WaypointUi
102 bodyColumns: number
103 rows: number
104 placement: 'dock' | 'inline' | undefined
105 now: number
106 opts: { layout: Layout }
107 recent?: RecentPlan[]
108 planDir?: string
109 /** The model's handoff note, drawn as the "Waiting on you" section. */
110 handoff?: Handoff | null
111 /** The current session's id: a handoff from another session is marked as from an earlier one. */
112 sessionId?: string
113 /** $HOME, for `~` in the footer; absent → a `/Users/<x>/` or `/home/<x>/` prefix becomes `~/`. */
114 home?: string
115}
116
117export const MIN_WIDTH = 40
118export const WIDE_MIN_COLUMNS = 60
119/** Auto layout: the sectioned (wide) layout from this many columns in a dock; compact below. */
120export const SECTIONED_MIN_COLUMNS = 44
121/** Below this the wide layout is narrow. */
122export const NARROW_BELOW = 64
123export const INLINE_ROW_CAP = 8
124export const MAX_PR_ROWS = 5
125export const MAX_RECENT = 5
126/** Columns the engine draws before a Button with a hotkey (`n: `). */
127export const HOTKEY_COLUMNS = 3
128export const MAX_HANDOFF_ITEMS = 5
129/** The wide layouts start the handoff summary at this many lines; the fit loop may give some up. */
130export const MAX_SUMMARY_LINES = 6
131export const MAX_CHILD_LINES = 8
132
133/** Rows the current step's children take drawn one per line: up to 8, then one `+n more` row. */
134export const childLinesCost = (n: number): number => Math.min(n, MAX_CHILD_LINES) + (n > MAX_CHILD_LINES ? 1 : 0)
135
136/** The dim `─` tail of a section rule: one space, then `─` to exactly `width` after `used` (empty when no room). */
137export function ruleTail(used: string, width: number): string {
138 const room = Math.floor(width) - Array.from(used).length - 1
139 return room >= 1 ? ' ' + '─'.repeat(room) : ''
140}
141
142const GLYPHS: Record<StepStatus, string> = { done: '✓', active: '▶', pending: '·', blocked: '!', skipped: '–' }
143const COLORS: Record<StepStatus, StatusColor> = {
144 done: 'success',
145 active: 'planMode',
146 pending: 'inactive',
147 blocked: 'warning',
148 skipped: 'subtle',
149}
150
151export const glyph = (status: StepStatus): string => GLYPHS[status]
152export const colorOf = (status: StepStatus): StatusColor => COLORS[status]
153
154/** Cuts `s` to at most `n` characters, the last one an ellipsis when cut. */
155export function shorten(s: string, n: number): string {
156 const chars = Array.from(s)
157 if (chars.length <= n) return s
158 if (n <= 0) return ''
159 if (n === 1) return '…'
160 return chars.slice(0, n - 1).join('').trimEnd() + '…'
161}
162
163export function fmtAgo(now: number, at: number): string {
164 const s = Math.max(0, Math.floor((now - at) / 1000))
165 if (s < 60) return `${s}s ago`
166 const m = Math.floor(s / 60)
167 if (m < 60) return `${m}m ago`
168 const h = Math.floor(m / 60)
169 if (h < 24) return `${h}h ago`
170 return `${Math.floor(h / 24)}d ago`
171}
172
173/** The recent plans the picker numbers 1–5: newest first, at most `MAX_RECENT`. */
174export function orderRecent<T extends { mtimeMs: number }>(recent: readonly T[]): T[] {
175 return [...recent].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RECENT)
176}
177
178/** A picker label: `<title or slug> · 71% · 36m ago` (percent only when known); the title is cut so the label fits `max`. */
179export function recentLabel(r: { name: string; path: string; title: string | null; percent: number | null }, ago: string, max?: number): string {
180 const tail = `${r.percent === null ? '' : ` · ${r.percent}%`} · ${ago}`
181 const name = r.title || r.name || slugOf(r.path)
182 if (max === undefined) return name + tail
183 return shorten(name, Math.max(4, max - Array.from(tail).length)) + tail
184}
185
186/** The picker as text, for `/waypoint` and `/waypoint use` replies: one `n: label` line per recent plan. */
187export function pickerText(recent: readonly RecentPlan[], now: number, planDir: string): string {
188 const list = orderRecent(recent)
189 if (list.length === 0) return `No plans found in ${planDir}.`
190 return ['Recent plans (/waypoint use <n> to track one):', ...list.map((r, i) => `${i + 1}: ${recentLabel(r, fmtAgo(now, r.mtimeMs))}`)].join('\n')
191}
192
193/** auto → compact when inline or narrower than 44 columns, else wide (narrow below 64, see `isNarrow`). */
194export function chooseLayout(pref: Layout, bodyColumns: number, placement: 'dock' | 'inline' | undefined): 'compact' | 'wide' {
195 if (pref !== 'auto') return pref
196 return placement === 'inline' || bodyColumns < SECTIONED_MIN_COLUMNS ? 'compact' : 'wide'
197}
198
199/** The wide layout's narrow variant: under 64 columns. */
200export const isNarrow = (layout: 'compact' | 'wide', width: number): boolean => layout === 'wide' && width < NARROW_BELOW
201
202/** Rows the pane may use: the engine's body rows, else viewport rows less 4 (at least 6). */
203export function rowBudget(bodyRows: number | undefined, viewportRows: number | undefined): number {
204 return bodyRows ?? Math.max(6, (viewportRows ?? 24) - 4)
205}
206
207/**
208 * The three-span bar, exactly `width` cells: finished steps `█`, the current
209 * step's own share `▓` (warning when blocked), the rest `░`. The done span takes
210 * the rounding remainder (the rest span does when nothing is finished).
211 */
212export function bar(finishedCount: number, total: number, current: StepStatus | null, width: number): BarModel {
213 const w = Math.max(0, Math.floor(width))
214 const t = Math.max(0, Math.floor(total))
215 const f = Math.min(t, Math.max(0, Math.floor(finishedCount)))
216 const hasCur = current !== null && f < t && t > 0
217 const currentColor = current === 'blocked' ? 'warning' : 'planMode'
218 if (t === 0) return { done: '', current: '', currentColor, rest: '░'.repeat(w) }
219 const cur = hasCur ? Math.min(w, Math.max(1, Math.floor(w / t))) : 0
220 let rest = Math.floor((w * (t - f - (hasCur ? 1 : 0))) / t)
221 let done = w - cur - rest
222 if (f === 0) {
223 rest += done
224 done = 0
225 }
226 return { done: '█'.repeat(done), current: '▓'.repeat(cur), currentColor, rest: '░'.repeat(rest) }
227}
228
229const finished = (c: PlanCounts): number => c.done + c.skipped
230const isClosed = (s: Step | undefined): boolean => s !== undefined && (s.status === 'done' || s.status === 'skipped')
231
232const currentStep = (snap: PlanSnapshot): Step | undefined =>
233 snap.currentIndex === null ? undefined : snap.steps[snap.currentIndex]
234
235/**
236 * Header: `⚑ <title>` cut to fit, and the progress on the right: `5/6 83%`
237 * under 60 columns, `WP5 · 5 of 6 · 83%` wide, `WP5 · 5/6 · 83%` in the narrow
238 * wide layout (`done · …` when complete). `lead` + `pct` = `right`.
239 */
240export function headerLine(snap: PlanSnapshot, width: number, narrow = false): { title: string; right: string; lead: string; pct: string } {
241 const c = snap.counts
242 const pct = `${snap.percent}%`
243 const cur = currentStep(snap)
244 const where = cur !== undefined ? `${cur.short} · ` : finished(c) >= c.total && c.total > 0 ? 'done · ' : ''
245 let lead: string
246 if (narrow) lead = `${where}${finished(c)}/${c.total} · `
247 else if (width < WIDE_MIN_COLUMNS) lead = `${finished(c)}/${c.total} `
248 else lead = `${where}${finished(c)} of ${c.total} · `
249 const right = lead + pct
250 const title = shorten(`⚑ ${snap.displayTitle}`, Math.max(4, width - Array.from(right).length - 2))
251 return { title, right, lead, pct }
252}
253
254/** `/Users/me/x` → `~/x` with `home`; without it, any `/Users/<x>/` or `/home/<x>/` prefix. */
255export function homePath(path: string, home?: string): string {
256 if (home !== undefined && home !== '' && home !== '/') {
257 const h = home.replace(/\/+$/, '')
258 if (path === h) return '~'
259 if (path.startsWith(h + '/')) return '~' + path.slice(h.length)
260 return path
261 }
262 return path.replace(/^\/(?:Users|home)\/[^/]+(?=\/)/, '~')
263}
264
265/**
266 * Word-wraps `text` (whitespace collapsed) to `width` columns, at most `maxLines`
267 * lines; the last kept line ends in `…` when text was left over. Over-long words are cut.
268 */
269export function wrapSummary(text: string, width: number, maxLines = MAX_SUMMARY_LINES): string[] {
270 const w = Math.max(1, Math.floor(width))
271 const words = text.trim().split(/\s+/).filter(Boolean)
272 const lines: string[] = []
273 let cur = ''
274 for (const word0 of words) {
275 let word = word0
276 while (Array.from(word).length > w) {
277 if (cur !== '') {
278 lines.push(cur)
279 cur = ''
280 }
281 const chars = Array.from(word)
282 lines.push(chars.slice(0, w).join(''))
283 word = chars.slice(w).join('')
284 }
285 if (word === '') continue
286 const len = Array.from(cur).length
287 if (cur === '') cur = word
288 else if (len + 1 + Array.from(word).length <= w) cur += ' ' + word
289 else {
290 lines.push(cur)
291 cur = word
292 }
293 }
294 if (cur !== '') lines.push(cur)
295 const max = Math.max(1, maxLines)
296 if (lines.length <= max) return lines
297 const kept = lines.slice(0, max)
298 const last = Array.from(kept[max - 1] ?? '')
299 kept[max - 1] = (last.length < w ? last.join('').trimEnd() : last.slice(0, w - 1).join('').trimEnd()) + '…'
300 return kept
301}
302
303/** Where the engine last drew the pane, as the `Pane` render props said. */
304export type PaneDraw = { placement: 'dock' | 'inline'; bodyColumns: number }
305
306/**
307 * `/waypoint status`'s pane line: `pane: dock · 76 columns (asked 48)`, `(asked engine share)`
308 * with no `paneColumns`, or `pane: not placed` while the pane is closed, waiting or never drawn.
309 */
310export function paneStatusLine(draw: PaneDraw | null, isPlaced: boolean, asked: number | undefined): string {
311 if (!isPlaced || draw === null) return 'pane: not placed'
312 return `pane: ${draw.placement} · ${draw.bodyColumns} columns (asked ${asked ?? 'engine share'})`
313}
314
315const prPhrase = (n: number): string => (n === 1 ? '1 PR needs you' : `${n} PRs need you`)
316
317export function bandText(snap: PlanSnapshot | null, prsNeedingYou: number): string {
318 if (snap === null) return '⚑ planning…'
319 const total = snap.counts.total
320 if (total === 0) return '⚑ no steps found'
321 const prs = prsNeedingYou > 0 ? ` · ${prPhrase(prsNeedingYou)}` : ''
322 const cur = currentStep(snap)
323 if (cur === undefined) return `⚑ plan complete · ${finished(snap.counts)}/${total}${prs}`
324 const done = `${snap.counts.done} done`
325 if (cur.status === 'blocked') return `⚑ ${cur.short} of ${total} blocked on you · ${done}${prs}`
326 return `⚑ ${cur.short} of ${total} · ${done}${prs}`
327}
328
329/** The status-line segment; undefined clears it (nothing tracked, not planning). */
330export function statusText(snap: PlanSnapshot | null, inPlanMode: boolean, handoff?: Handoff | null): string | undefined {
331 if (snap === null) return inPlanMode ? '⚑ planning' : handoff != null ? '⚑ needs you' : undefined
332 const total = snap.counts.total
333 if (total === 0) return handoff != null ? '⚑ no steps · needs you' : '⚑ no steps'
334 const n = `${finished(snap.counts)}/${total}`
335 const cur = currentStep(snap)
336 if (cur?.status === 'blocked') return `⚑ ${n} ${cur.short}!`
337 if (handoff != null) return `⚑ ${n}${cur === undefined ? '' : ` ${cur.short}`} · needs you`
338 if (cur === undefined) return `⚑ ${n} ✓`
339 return `⚑ ${n} ${cur.short}`
340}
341
342/** The band's text while a handoff note is up: `⚑ waiting on you (2) · WP5 of 6 · 5 done`. */
343export function handoffBandText(snap: PlanSnapshot | null, handoff: Handoff): string {
344 const head = `⚑ waiting on you (${handoff.items.length})`
345 if (snap === null || snap.counts.total === 0) return head
346 const cur = currentStep(snap)
347 const total = snap.counts.total
348 if (cur === undefined) return `${head} · plan complete · ${finished(snap.counts)}/${total}`
349 return `${head} · ${cur.short} of ${total} · ${finished(snap.counts)} done`
350}
351
352/**
353 * The step rows for `rows` lines: the current step always visible, leading
354 * finished steps collapsed into one summary row when everything does not fit,
355 * and a `+n more` row when steps remain below. In `compact` the `more` row is
356 * drawn on the last step's line, so it costs no line of its own. In `wide` the
357 * current step's children follow it, one per line when `perLine` and they fit,
358 * else in one row, else not at all.
359 */
360export function fitSteps(steps: Step[], currentIndex: number | null, rows: number, layout: 'compact' | 'wide', perLine = false): Row[] {
361 const n = steps.length
362 if (n === 0) return []
363 const budget = Math.max(1, Math.floor(rows))
364 const cur = Math.min(n - 1, Math.max(0, currentIndex ?? n - 1))
365 const curStep = steps[cur]
366 const hasKids = layout === 'wide' && currentIndex !== null && curStep !== undefined && curStep.children.length > 0
367 type Kids = { cost: number; perLine: boolean | null }
368 const options: Kids[] = !hasKids
369 ? [{ cost: 0, perLine: null }]
370 : [...(perLine ? [{ cost: childLinesCost(curStep.children.length), perLine: true }] : []), { cost: 1, perLine: false }, { cost: 0, perLine: null }]
371 const moreCost = layout === 'compact' ? 0 : 1
372
373 const emit = (s: number, e: number, kids: Kids): Row[] => {
374 const out: Row[] = []
375 if (s > 0) {
376 const before = steps.slice(0, s)
377 out.push(
378 before.every(isClosed)
379 ? { kind: 'done-summary', count: s, shorts: before.map(st => st.short) }
380 : { kind: 'more', count: s, position: 'before' },
381 )
382 }
383 for (let i = s; i < e; i++) {
384 const step = steps[i]
385 if (step === undefined) continue
386 const isCurrent = currentIndex !== null && i === cur
387 out.push({ kind: 'step', step, isCurrent })
388 if (isCurrent && kids.perLine !== null) out.push({ kind: 'children', items: step.children, perLine: kids.perLine })
389 }
390 if (e < n) out.push({ kind: 'more', count: n - e, position: 'after' })
391 return out
392 }
393
394 // Children go to one row, then are dropped, before the window shrinks to the current step alone.
395 // A window that cannot hold one step plus its `more` row is not tried (-1).
396 const windowEnd = (s: number, kids: number): number => {
397 const room = budget - (s > 0 ? 1 : 0) - kids
398 if (n - s <= room) return n
399 return room - moreCost >= 1 ? s + room - moreCost : -1
400 }
401
402 let lead = 0
403 while (lead < cur && isClosed(steps[lead])) lead++
404 const starts = [lead >= 2 ? lead : 0, Math.max(0, cur - 1), cur]
405 for (const kids of options) {
406 if (n + kids.cost <= budget) return emit(0, n, kids)
407 for (const s of starts) {
408 if (s > cur) continue
409 const e = windowEnd(s, kids.cost)
410 if (e > cur) return emit(s, e, kids)
411 }
412 }
413 // One or two rows: the current step, then what follows (else what precedes) when there is room.
414 const step: Row = { kind: 'step', step: steps[cur] as Step, isCurrent: currentIndex !== null }
415 if (budget < 2) return [step]
416 if (cur < n - 1) return [step, { kind: 'more', count: n - cur - 1, position: 'after' }]
417 if (cur > 0) return [emit(cur, n, { cost: 0, perLine: null })[0] as Row, step]
418 return [step]
419}
420
421export const needsYouCount = (prs: PrsView): number => prs.entries.filter(p => p.attention !== null).length
422
423const slugOf = (path: string): string => (path.split('/').pop() ?? path).replace(/\.md$/i, '')
424
425/** Rows a drawn step list takes: in compact a trailing `+n more` shares the last step's line; children one per line take a row each. */
426function stepRowsDrawn(rows: Row[], wide: boolean): number {
427 let n = 0
428 rows.forEach((r, i) => {
429 if (!wide && r.kind === 'more' && r.position === 'after' && rows[i - 1]?.kind === 'step') return
430 n += r.kind === 'children' && r.perLine ? childLinesCost(r.items.length) : 1
431 })
432 return n
433}
434
435/** The rows `drawPane` draws for `m`, one per line. The row budget is spent against this count. */
436export function paneRows(m: PaneModel): number {
437 if (m.kind !== 'plan') {
438 const lead = m.handoffLine === null ? 0 : 1
439 if (m.kind === 'planning') return lead + 1
440 if (m.kind === 'none-tracked') return lead + 1 + Math.max(1, m.recent.length) + m.recent.filter(r => r.where !== null).length
441 if (m.kind === 'missing') return lead + 2 + (m.lastCounts === null ? 0 : 1)
442 return lead + 3
443 }
444 const wide = m.layout === 'wide'
445 const gap = wide && m.gaps ? 1 : 0
446 let n = 2 + gap + stepRowsDrawn(m.rows, wide)
447 const ho = m.handoff
448 if (ho !== null) n += ho.compact ? 1 : gap + 1 + ho.summary.length + ho.items.length + (ho.more > 0 ? 1 : 0)
449 const pr = m.prSection
450 if (pr !== null) {
451 const note = pr.note === '' ? 0 : 1
452 n += pr.compact ? 1 + note : gap + 1 + note + pr.rows.length + pr.others.length
453 }
454 // Wide: the footer (two rows when narrow), with its gap row and full-width rule above it when there are gaps.
455 if (wide) n += 2 * gap + (m.narrow ? 2 : 1)
456 return n
457}
458
459/** Compact's one handoff line: `! waiting on you · <first item title> (+n)`, or the summary when there are no items. */
460export function handoffLine(h: Handoff, width: number): string {
461 const first = h.items[0]
462 const extra = h.items.length > 1 ? ` (+${h.items.length - 1})` : ''
463 const what = first?.title ?? h.summary.replace(/\s+/g, ' ').trim()
464 const head = `! waiting on you${what === '' ? '' : ' · '}`
465 return head + shorten(what, Math.max(4, width - Array.from(head).length - extra.length)) + extra
466}
467
468export function buildPaneModel(input: PaneInput): PaneModel {
469 const { snap, tracked, ui, now } = input
470 const width = Math.max(MIN_WIDTH, Math.floor(input.bodyColumns))
471 const h = input.handoff ?? null
472 const hLine = h === null ? null : handoffLine(h, width)
473
474 if (tracked === null) {
475 if (ui.inPlanMode) return { kind: 'planning', handoffLine: hLine }
476 const wide = chooseLayout(ui.layout ?? input.opts.layout, width, input.placement) === 'wide'
477 const ordered = orderRecent(input.recent ?? [])
478 const budget = input.placement === 'inline' ? Math.min(input.rows, INLINE_ROW_CAP) : input.rows
479 const lead = (hLine === null ? 0 : 1) + 1 + Math.max(1, ordered.length)
480 const withCwd = ordered.filter(r => r.cwd).length
481 // The `in ~/…` lines only in wide, and only when every one of them fits the rows.
482 const showWhere = wide && lead + withCwd <= budget
483 const recent = ordered.map(r => {
484 const ago = fmtAgo(now, r.mtimeMs)
485 return {
486 name: r.name || slugOf(r.path),
487 path: r.path,
488 ago,
489 label: recentLabel(r, ago, width - HOTKEY_COLUMNS),
490 where: showWhere && r.cwd ? shorten(`in ${homePath(r.cwd, input.home)}`, width - HOTKEY_COLUMNS) : null,
491 }
492 })
493 return { kind: 'none-tracked', recent, planDir: input.planDir ?? '~/.claude/plans', handoffLine: hLine }
494 }
495 if (tracked.missing) return { kind: 'missing', path: tracked.path, lastCounts: snap?.counts ?? null, handoffLine: hLine }
496 if (snap === null) return { kind: 'planning', handoffLine: hLine }
497 if (snap.steps.length === 0) return { kind: 'no-steps', title: snap.displayTitle, path: snap.path, handoffLine: hLine }
498
499 const layout = chooseLayout(ui.layout ?? input.opts.layout, width, input.placement)
500 const wide = layout === 'wide'
501 const narrow = isNarrow(layout, width)
502 const budget = input.placement === 'inline' ? Math.min(input.rows, INLINE_ROW_CAP) : input.rows
503
504 const byId = new Map(input.prs.entries.map(e => [e.id, e]))
505 const open = input.prRows.filter(r => {
506 const st = byId.get(r.id)?.state
507 return st !== 'merged' && st !== 'closed'
508 })
509 const hot = open.filter(r => (byId.get(r.id)?.attention ?? null) !== null)
510 const cold = open.filter(r => (byId.get(r.id)?.attention ?? null) === null)
511 const ordered = [...hot, ...cold]
512 const summaryWidth = narrow ? width - 1 : width - 2
513 const allSummary = h === null ? [] : wrapSummary(h.summary, summaryWidth, MAX_SUMMARY_LINES)
514
515 // What the sections may take; given up step by step (below) to keep a few steps in view.
516 const fit = {
517 prShown: ordered.length === 0 ? 0 : wide ? Math.min(ordered.length, MAX_PR_ROWS) : 1,
518 summaryShown: allSummary.length,
519 itemsShown: h === null ? 0 : Math.min(h.items.length, MAX_HANDOFF_ITEMS),
520 gaps: wide,
521 childLines: wide && (currentStep(snap)?.children.length ?? 0) > 0,
522 handoffMore: true,
523 handoffCompact: !wide,
524 prCompact: !wide,
525 }
526
527 const shortOf = new Map(snap.steps.map(st => [st.id, st.short]))
528 const line = (r: PrRow): PrLine => ({
529 ...r,
530 stepShort: (r.stepId === undefined ? undefined : shortOf.get(r.stepId)) ?? '—',
531 })
532 const hotIds = new Set(hot.map(r => r.id))
533 const count = needsYouCount(input.prs)
534 const polled = input.prs.lastPolledAt === null ? null : fmtAgo(now, input.prs.lastPolledAt)
535 const polledPart = polled === null ? '' : ` · polled ${polled}`
536 const otherCount = open.length - hot.length
537 const cur = currentStep(snap)
538 const fromEarlier = h !== null && input.sessionId !== undefined && h.sessionId !== input.sessionId
539 const wideHead: PrSection['head'] =
540 count > 0
541 ? { before: ' · ', need: count === 1 ? '1 needs you' : `${count} need you`, hot: true, after: (otherCount > 0 ? ` · ${otherCount} other` : '') + polledPart }
542 : { before: ` · ${open.length} open · `, need: 'none need you', hot: false, after: polledPart }
543
544 const assemble = (rows: Row[]): PaneModel => {
545 const shown = ordered.slice(0, fit.prShown)
546 let ho: HandoffSection | null = null
547 if (h !== null) {
548 // Fewer lines than wrapped: re-wrap to that many, so the cut last line keeps its `…`.
549 const summary = fit.summaryShown < allSummary.length ? wrapSummary(h.summary, summaryWidth, fit.summaryShown) : allSummary
550 ho = handoffSection(h, now, width, summary, fit.itemsShown, input.prRows, fromEarlier, narrow)
551 if (fit.handoffCompact) ho = { ...ho, compact: true, summary: [], items: [], more: 0 }
552 else if (!fit.handoffMore) ho = { ...ho, more: 0 }
553 }
554 return {
555 kind: 'plan',
556 layout,
557 narrow,
558 width,
559 header: headerLine(snap, width, narrow),
560 bar: bar(finished(snap.counts), snap.counts.total, cur?.status ?? null, width),
561 rows,
562 shortWidth: Math.max(0, ...snap.steps.map(st => Array.from(st.short).length)),
563 prSection:
564 fit.prShown === 0
565 ? null
566 : {
567 count,
568 open: open.length,
569 head: narrow ? narrowPrHead(count, open.length, polledPart, width) : wideHead,
570 rows: shown.filter(r => hotIds.has(r.id)).map(line),
571 others: shown.filter(r => !hotIds.has(r.id)).map(line),
572 polled,
573 note: input.prs.note,
574 compact: fit.prCompact,
575 },
576 handoff: ho,
577 gaps: fit.gaps,
578 footer: {
579 path: homePath(tracked.path, input.home),
580 changedAgo: snap.mtimeMs > 0 ? fmtAgo(now, snap.mtimeMs) : 'unknown',
581 error: ui.lastError,
582 },
583 }
584 }
585
586 // The sections come first; they give up rows, in this order, only to keep a few steps in view:
587 // the current step's children to one row, PR rows, summary lines, items, the gap rows (and the
588 // footer rule), the `+n more` item row, then each section to one line.
589 const minSteps = Math.min(snap.steps.length, 3)
590 const kidLines = childLinesCost(cur?.children.length ?? 0)
591 const left = (): number => budget - paneRows(assemble([]))
592 while (wide && left() < minSteps + (fit.childLines ? kidLines : 0)) {
593 if (fit.childLines) fit.childLines = false
594 else if (fit.prShown > 1) fit.prShown--
595 else if (fit.summaryShown > 1) fit.summaryShown--
596 else if (fit.itemsShown > 1) fit.itemsShown--
597 else if (fit.gaps) fit.gaps = false
598 else if (fit.handoffMore && h !== null && h.items.length > fit.itemsShown) fit.handoffMore = false
599 else if (!fit.handoffCompact && h !== null) fit.handoffCompact = true
600 else if (!fit.prCompact && fit.prShown > 0) fit.prCompact = true
601 else break
602 }
603 return assemble(fitSteps(snap.steps, snap.currentIndex, Math.max(1, left()), layout, fit.childLines))
604}
605
606/** Narrow PR rule header: `· n need(s) you` or `· n open`, then `· polled …` while the rule still fits. */
607export function narrowPrHead(count: number, open: number, polledPart: string, width: number): PrSection['head'] {
608 const head: PrSection['head'] =
609 count > 0
610 ? { before: ' · ', need: count === 1 ? '1 needs you' : `${count} need you`, hot: true, after: polledPart }
611 : { before: ` · ${open} open`, need: '', hot: false, after: polledPart }
612 const used = Array.from(`── Pull requests${head.before}${head.need}${head.after}`).length
613 return used + 2 > width ? { ...head, after: '' } : head
614}
615
616const KIND_PAD = 8
617
618/** `#1` / `!6677` / an id / a URL → a Link target when a tracked PR matches or it is a URL. */
619export function resolveRef(ref: string, prRows: PrRow[]): { text: string; href: string | null } {
620 const r = ref.trim()
621 const hit = prRows.find(p => p.id === r || p.url === r || p.label === r)
622 if (hit !== undefined) return { text: hit.label, href: hit.url || null }
623 if (/^https?:\/\//i.test(r)) {
624 const num = /\/(?:pull|merge_requests)\/(\d+)/.exec(r)
625 const text = num === null ? shorten(r, 24) : `${r.includes('merge_requests') ? '!' : '#'}${num[1]}`
626 return { text, href: r }
627 }
628 return { text: shorten(r, 16), href: null }
629}
630
631function handoffSection(
632 h: Handoff,
633 now: number,
634 width: number,
635 summary: string[],
636 itemsShown: number,
637 prRows: PrRow[],
638 fromEarlier: boolean,
639 narrow = false,
640): HandoffSection {
641 const items = h.items.slice(0, itemsShown).map(it => {
642 const ref = it.ref === undefined || it.ref.trim() === '' ? null : resolveRef(it.ref, prRows)
643 // wide: indent 2, mark 1, gap 2, kind 8, gap 2, title, gap 2, ref; narrow: indent 1, mark 1, gap 1, kind 8, gap 1, title, gap 2, ref
644 const fixed = narrow ? 1 + 1 + 1 + KIND_PAD + 1 : 2 + 1 + 2 + KIND_PAD + 2
645 const room = width - fixed - (ref === null ? 0 : 2 + Array.from(ref.text).length)
646 return { blocking: it.blocking, kind: it.kind.padEnd(KIND_PAD), title: shorten(it.title, Math.max(4, room)), ref }
647 })
648 return {
649 ago: fmtAgo(now, h.at),
650 fromEarlier,
651 compact: false,
652 summary,
653 items,
654 more: h.items.length - items.length,
655 // Narrow draws the line after a one-space indent.
656 line: handoffLine(h, narrow ? width - 1 : width),
657 }
658}
659
660/** `2/5` for the current step's checkbox children, or '' when it has none. */
661export function childProgress(step: Step): string {
662 if (step.children.length === 0) return ''
663 const done = step.children.filter(c => c.status === 'done' || c.status === 'skipped').length
664 return `${done}/${step.children.length}`
665}
666
667export type BandModel = { text: string; color: 'planMode' | 'warning' }
668
669/** The band's one row; the wiring decides whether the band shows at all (`bandQuiet`). */
670export function buildBandModel(snap: PlanSnapshot | null, prs: PrsView, handoff?: Handoff | null): BandModel {
671 if (handoff != null) return { text: handoffBandText(snap, handoff), color: 'warning' }
672 const cur = snap === null ? undefined : currentStep(snap)
673 return { text: bandText(snap, needsYouCount(prs)), color: cur?.status === 'blocked' ? 'warning' : 'planMode' }
674}
675
676/** The band yields to a survey, the option and a placed pane; with nothing tracked it shows only for planning or a handoff note. */
677export function bandQuiet(q: { hasSurvey: boolean; bandOn: boolean; tracked: boolean; inPlanMode: boolean; isPlaced: boolean; handoff: boolean }): boolean {
678 return q.hasSurvey || !q.bandOn || q.isPlaced || (!q.tracked && !q.inPlanMode && !q.handoff)
679}
680hooks/prs/classify.ts 193 lines1// waypoint PRs: gh/glab JSON → facts → attention, status word, colour. Pure.
2
3import type { PrAttention, PrChecks, PrEntry, PrFacts, PrMergeable, PrReview, PrState } from '../../types'
4
5type Obj = Record<string, unknown>
6
7function toObj(json: unknown): Obj | undefined {
8 let v = json
9 if (typeof v === 'string') {
10 try {
11 v = JSON.parse(v)
12 } catch {
13 return undefined
14 }
15 }
16 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : undefined
17}
18
19const str = (v: unknown): string => (typeof v === 'string' ? v : '')
20const up = (v: unknown): string => str(v).toUpperCase()
21
22function opt(facts: PrFacts, headRef: string, updatedAt: string): PrFacts {
23 if (headRef) facts.headRef = headRef
24 if (updatedAt) facts.updatedAt = updatedAt
25 return facts
26}
27
28// --- GitHub ---
29
30const GH_FAIL = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
31
32function ghChecks(rollup: unknown): PrChecks {
33 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
34 let failing = false
35 let pending = false
36 for (const item of rollup) {
37 const c = toObj(item)
38 if (!c) continue
39 const isStatus = c['__typename'] === 'StatusContext' || (c['__typename'] === undefined && 'state' in c)
40 if (isStatus) {
41 const s = up(c['state'])
42 if (s === 'FAILURE' || s === 'ERROR') failing = true
43 else if (s === 'PENDING' || s === 'EXPECTED') pending = true
44 } else if (up(c['status']) !== 'COMPLETED') pending = true
45 else if (GH_FAIL.has(up(c['conclusion']))) failing = true
46 }
47 return failing ? 'failing' : pending ? 'pending' : 'passing'
48}
49
50function ghReview(v: unknown): PrReview {
51 switch (up(v)) {
52 case 'APPROVED':
53 return 'approved'
54 case 'CHANGES_REQUESTED':
55 return 'changes_requested'
56 case 'REVIEW_REQUIRED':
57 return 'review_required'
58 default:
59 return 'none' // "" is what solo repos return
60 }
61}
62
63function ghMergeable(mergeable: unknown, mergeState: unknown): PrMergeable {
64 const m = up(mergeable)
65 const s = up(mergeState)
66 if (m === 'CONFLICTING' || s === 'DIRTY') return 'conflicting'
67 if (s === 'CLEAN' || s === 'HAS_HOOKS' || s === 'UNSTABLE') return 'clean'
68 if (s === 'BLOCKED' || s === 'BEHIND' || s === 'DRAFT') return 'blocked'
69 return 'unknown'
70}
71
72/** `gh pr view --json GH_FIELDS` output (string or parsed) → facts; garbage → undefined. */
73export function parseGhPr(json: unknown): PrFacts | undefined {
74 const o = toObj(json)
75 if (!o) return undefined
76 const st = up(o['state'])
77 const state: PrState | undefined =
78 st === 'OPEN' ? 'open' : st === 'MERGED' ? 'merged' : st === 'CLOSED' ? 'closed' : undefined
79 if (!state) return undefined
80 return opt(
81 {
82 state,
83 draft: o['isDraft'] === true,
84 checks: ghChecks(o['statusCheckRollup']),
85 review: ghReview(o['reviewDecision']),
86 mergeable: ghMergeable(o['mergeable'], o['mergeStateStatus']),
87 title: str(o['title']),
88 url: str(o['url']),
89 },
90 str(o['headRefName']),
91 str(o['updatedAt']),
92 )
93}
94
95// --- GitLab ---
96
97function glabChecks(pipeline: unknown): PrChecks {
98 const s = str(toObj(pipeline)?.['status']).toLowerCase()
99 if (s === 'success') return 'passing'
100 if (s === 'failed' || s === 'canceled' || s === 'cancelled') return 'failing'
101 if (['running', 'pending', 'created', 'preparing', 'waiting_for_resource', 'scheduled'].includes(s)) return 'pending'
102 return 'none' // manual, skipped, missing: a manual head pipeline is normal
103}
104
105function glabReview(dms: string): PrReview {
106 if (dms === 'requested_changes' || dms === 'discussions_not_resolved') return 'changes_requested'
107 if (dms === 'not_approved') return 'review_required'
108 if (dms === 'mergeable') return 'approved'
109 return 'none'
110}
111
112function glabMergeable(dms: string, hasConflicts: unknown): PrMergeable {
113 if (hasConflicts === true || dms === 'has_conflicts' || dms === 'conflict' || dms === 'need_rebase') return 'conflicting'
114 if (dms === 'mergeable') return 'clean'
115 if (dms === '' || ['checking', 'unchecked', 'preparing', 'approvals_syncing'].includes(dms)) return 'unknown'
116 return 'blocked'
117}
118
119/** `glab mr view -F json` output (string or parsed) → facts; garbage → undefined. */
120export function parseGlabMr(json: unknown): PrFacts | undefined {
121 const o = toObj(json)
122 if (!o) return undefined
123 const st = str(o['state']).toLowerCase()
124 const state: PrState | undefined =
125 st === 'opened' || st === 'locked' ? 'open' : st === 'merged' ? 'merged' : st === 'closed' ? 'closed' : undefined
126 if (!state) return undefined
127 const dms = str(o['detailed_merge_status']).toLowerCase()
128 return opt(
129 {
130 state,
131 draft: o['draft'] === true || o['work_in_progress'] === true,
132 checks: glabChecks(o['head_pipeline']),
133 review: glabReview(dms),
134 mergeable: glabMergeable(dms, o['has_conflicts']),
135 title: str(o['title']),
136 url: str(o['web_url']),
137 },
138 str(o['source_branch']),
139 str(o['updated_at']),
140 )
141}
142
143// --- Derived ---
144
145/** First match wins; see the plan's priority table. */
146export function attentionOf(f: PrFacts): PrAttention {
147 if (f.state !== 'open') return null
148 if (f.draft) return null
149 if (f.mergeable === 'conflicting') return 'resolve-conflicts'
150 if (f.checks === 'failing') return 'fix-ci'
151 if (f.review === 'changes_requested') return 'address-review'
152 if (
153 (f.review === 'approved' || f.review === 'none') &&
154 f.mergeable === 'clean' &&
155 (f.checks === 'passing' || f.checks === 'none')
156 ) {
157 return 'ready-to-merge'
158 }
159 return null
160}
161
162export function statusWord(e: PrFacts & { attention?: PrAttention }): string {
163 if (e.state === 'merged') return 'merged'
164 if (e.state === 'closed') return 'closed'
165 if (e.draft) return 'draft'
166 if (e.mergeable === 'conflicting') return 'conflicts'
167 if (e.checks === 'failing') return 'ci failed'
168 if (e.review === 'changes_requested') return 'changes requested'
169 if ((e.attention ?? attentionOf(e)) === 'ready-to-merge') return 'ready to merge'
170 if (e.checks === 'pending') return 'ci pending'
171 if (e.review === 'review_required') return 'awaiting review'
172 if (e.mergeable === 'blocked') return 'blocked'
173 return 'open'
174}
175
176export type PrColor = 'error' | 'warning' | 'success' | 'subtle' | undefined
177
178/** Theme key for a row; undefined = dim (merged/closed). */
179export function colorOf(e: Pick<PrEntry, 'state' | 'attention'>): PrColor {
180 if (e.state !== 'open') return undefined
181 switch (e.attention) {
182 case 'fix-ci':
183 case 'resolve-conflicts':
184 return 'error'
185 case 'address-review':
186 return 'warning'
187 case 'ready-to-merge':
188 return 'success'
189 default:
190 return 'subtle'
191 }
192}
193hooks/prs/refs.ts 286 lines1// waypoint PRs: discovering PR/MR references in remotes, text, Bash calls and
2// `/waypoint track` arguments. Pure: no `$`, no I/O.
3
4import type { PrKind, PrRef, RepoRef } from '../../types'
5
6/** Parse a git remote (scp-like, ssh:// or https://) into a repo reference. */
7export function parseRemote(remote: string | null | undefined): RepoRef | undefined {
8 if (!remote) return undefined
9 const r = remote.trim()
10 let host: string | undefined
11 let path: string | undefined
12 const scp = /^[\w.-]+@([^:/\s]+):(?!\/)(.+)$/.exec(r)
13 const url = /^(?:ssh|git|https?):\/\/(?:[^@/\s]+@)?([^/\s]+)\/(.+)$/i.exec(r)
14 if (url) {
15 host = url[1]
16 path = url[2]
17 // An ssh port is not the web port: drop it. Keep an https port.
18 if (!/^https?:/i.test(r) && host) host = host.replace(/:\d+$/, '')
19 } else if (scp) {
20 host = scp[1]
21 path = scp[2]
22 }
23 if (!host || !path) return undefined
24 path = path.replace(/\/+$/, '').replace(/\.git$/i, '').replace(/\/+$/, '')
25 if (!/^[\w.-]+(?:\/[\w.-]+)+$/.test(path)) return undefined
26 host = host.toLowerCase()
27 return { kind: host.includes('github') ? 'github' : 'gitlab', host, path }
28}
29
30export function prId(ref: PrRef): string {
31 return `${ref.host}/${ref.path}${ref.kind === 'github' ? '#' : '!'}${ref.number}`
32}
33
34export function prLabel(ref: PrRef): string {
35 return `${ref.kind === 'github' ? '#' : '!'}${ref.number}`
36}
37
38export function prUrl(ref: PrRef): string {
39 return ref.kind === 'github'
40 ? `https://${ref.host}/${ref.path}/pull/${ref.number}`
41 : `https://${ref.host}/${ref.path}/-/merge_requests/${ref.number}`
42}
43
44function dedup(refs: PrRef[]): PrRef[] {
45 const seen = new Set<string>()
46 const out: PrRef[] = []
47 for (const r of refs) {
48 const id = prId(r)
49 if (seen.has(id)) continue
50 seen.add(id)
51 out.push(r)
52 }
53 return out
54}
55
56function ref(kind: PrKind, host: string, path: string, n: string): PrRef {
57 return { kind, host: host.toLowerCase(), path, number: Number(n) }
58}
59
60/** PR/MR web URLs in `text`. Kind is decided by the URL form, not the host. */
61export function extractUrls(text: string): PrRef[] {
62 const found: { at: number; ref: PrRef }[] = []
63 for (const m of text.matchAll(/https?:\/\/([a-z0-9.-]+)\/([a-z0-9_.-]+\/[a-z0-9_.-]+)\/pull\/(\d+)\b/gi)) {
64 if (m[1] && m[2] && m[3]) found.push({ at: m.index ?? 0, ref: ref('github', m[1], m[2], m[3]) })
65 }
66 for (const m of text.matchAll(
67 /https?:\/\/([a-z0-9.-]+)\/((?:[a-z0-9_.-]+\/)+[a-z0-9_.-]+)\/-\/merge_requests\/(\d+)\b/gi,
68 )) {
69 if (m[1] && m[2] && m[3]) found.push({ at: m.index ?? 0, ref: ref('gitlab', m[1], m[2], m[3]) })
70 }
71 found.sort((a, b) => a.at - b.at)
72 return dedup(found.map((f) => f.ref))
73}
74
75/** Replace every URL with spaces so fragments like `#issuecomment-1` or `page#12` never read as refs. */
76function blankUrls(text: string): string {
77 return text.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, (u) => ' '.repeat(u.length))
78}
79
80/** `#n` (GitHub) or `!n` / `group/project!n` (GitLab) refs, resolved against `repo`. URLs are ignored. */
81export function extractShortRefs(text: string, repo: RepoRef): PrRef[] {
82 let t = blankUrls(text)
83 const out: PrRef[] = []
84 if (repo.kind === 'github') {
85 for (const m of t.matchAll(/(?<![\w/#])#(\d{1,6})\b/g)) {
86 if (m[1]) out.push(ref('github', repo.host, repo.path, m[1]))
87 }
88 } else {
89 t = t.replace(/(?<![\w/.-])((?:[a-z0-9_.-]+\/)+[a-z0-9_.-]+)!(\d{1,6})\b/gi, (whole, path: string, n: string) => {
90 out.push(ref('gitlab', repo.host, path, n))
91 return ' '.repeat(whole.length)
92 })
93 for (const m of t.matchAll(/(?<![\w/!])!(\d{1,6})\b/g)) {
94 if (m[1]) out.push(ref('gitlab', repo.host, repo.path, m[1]))
95 }
96 }
97 return dedup(out)
98}
99
100/** A step-body line that declares its PR/MR: `- PR: …`, `* MR: …`, `1. **PR:** …` (case-insensitive). */
101const PR_LINE = /^\s*(?:(?:[-*+]|\d+[.)])\s+)?(?:\*\*|__)?(?:PR|MR)s?(?:\*\*|__)?\s*:(?:\*\*|__)?(.*)$/i
102
103/**
104 * Refs a plan step names: every full PR/MR URL anywhere in the body, plus short
105 * refs (`#n`, `!n`, `group/project!n`) only on `- PR:` / `- MR:` lines or as a
106 * markdown link target. A bare `#123` in prose is ignored. URLs first, deduped.
107 */
108export function extractPlanRefs(body: string, repo?: RepoRef | null): PrRef[] {
109 const urls = extractUrls(body)
110 if (!repo) return urls
111 const short: PrRef[] = []
112 for (const line of body.split('\n')) {
113 const m = PR_LINE.exec(line)
114 if (m) short.push(...extractShortRefs(m[1] ?? '', repo))
115 }
116 for (const m of body.matchAll(/\]\(\s*([^)\s]+)\s*\)/g)) {
117 if (m[1] && /^(?:(?:[a-z0-9_.-]+\/)+[a-z0-9_.-]+)?[#!]\d{1,6}$/i.test(m[1])) short.push(...extractShortRefs(m[1], repo))
118 }
119 return dedup([...urls, ...short])
120}
121
122/** URLs plus, when a repo is known, short refs. Deduplicated by id, URLs first. */
123export function extractRefs(text: string, repo?: RepoRef | null): PrRef[] {
124 const urls = extractUrls(text)
125 return repo ? dedup([...urls, ...extractShortRefs(text, repo)]) : urls
126}
127
128const GH_SUBS = new Set(['create', 'view', 'checks', 'merge', 'edit', 'ready', 'review', 'comment', 'diff', 'close', 'reopen'])
129const GLAB_SUBS = new Set([
130 'create', 'view', 'approve', 'revoke', 'merge', 'update', 'note', 'diff', 'close', 'reopen', 'rebase', 'checkout',
131])
132
133function unquote(tok: string): string {
134 return tok.replace(/^['"]|['"]$/g, '')
135}
136
137/** `[HOST/]OWNER/REPO` or `GROUP/…/REPO`: path segments only, no quotes, commas, `$` or braces. */
138const REPO_VALUE = /^(?:[a-z0-9.-]+\/)?[a-z0-9_.-]+(?:\/[a-z0-9_.-]+)+$/i
139const PR_NUMBER = /^[#!]?\d{1,6}$/
140/** Shell or quoting debris that never appears in a real ref argument. */
141const JUNK = /[\s'"`$,{}()<>;|&\\]/
142
143/** The repo named by a `--repo` / `-R` value, for the CLI's kind; undefined when the value is not a clean repo. */
144function repoFromFlag(value: string, kind: PrKind, session: RepoRef | null | undefined): RepoRef | undefined {
145 const v = value.trim()
146 if (!v || JUNK.test(v)) return undefined
147 if (/:\/\/|^[\w.-]+@/.test(v)) {
148 const r = parseRemote(v)
149 return r && /^[a-z0-9.-]+(?::\d+)?$/.test(r.host) ? { ...r, kind } : undefined
150 }
151 const bare = v.replace(/\/+$/, '')
152 if (!REPO_VALUE.test(bare)) return undefined
153 const segs = bare.split('/')
154 const defHost = session?.kind === kind ? session.host : kind === 'github' ? 'github.com' : 'gitlab.com'
155 // gh takes [HOST/]OWNER/REPO; glab takes GROUP/NAMESPACE/REPO (a host needs a URL).
156 if (kind === 'github' && segs.length >= 3) {
157 return { kind, host: (segs[0] ?? '').toLowerCase(), path: segs.slice(1).join('/') }
158 }
159 return { kind, host: defHost, path: segs.join('/') }
160}
161
162type Scan = { scanned: boolean; named: boolean; refs: PrRef[] }
163const NONE: Scan = { scanned: false, named: false, refs: [] }
164
165function scanSegment(seg: string, session: RepoRef | null | undefined): Scan {
166 const toks = seg.trim().split(/\s+/).map(unquote).filter(Boolean)
167 let at = -1
168 let kind: PrKind = 'github'
169 for (let i = 0; i + 2 < toks.length; i++) {
170 const bin = (toks[i] ?? '').split('/').pop()
171 const noun = toks[i + 1]
172 const sub = toks[i + 2] ?? ''
173 if (bin === 'gh' && noun === 'pr' && GH_SUBS.has(sub)) {
174 at = i + 3
175 kind = 'github'
176 break
177 }
178 if (bin === 'glab' && noun === 'mr' && GLAB_SUBS.has(sub)) {
179 at = i + 3
180 kind = 'gitlab'
181 break
182 }
183 }
184 if (at < 0) return NONE
185 let repo: RepoRef | undefined = session?.kind === kind ? session : undefined
186 // Only the first positional counts, and only if no other flag precedes it:
187 // a flag's value (`--interval 10`, `--body "3 places"`) is never the PR.
188 let positional: string | undefined
189 let done = false
190 for (let i = at; i < toks.length; i++) {
191 const t = toks[i] ?? ''
192 // A `--repo` value that is not a clean repo (quote, comma, `$`…) means this
193 // is not a real invocation (e.g. a quoted string inside a script): no ref.
194 if (t === '--repo' || t === '-R') {
195 const r = repoFromFlag(toks[i + 1] ?? '', kind, session)
196 if (!r) return NONE
197 repo = r
198 i++
199 continue
200 }
201 const eq = /^(?:--repo=|-R=?)(.+)$/.exec(t)
202 if (eq && eq[1]) {
203 const r = repoFromFlag(eq[1], kind, session)
204 if (!r) return NONE
205 repo = r
206 continue
207 }
208 if (done) continue
209 done = true
210 if (!t.startsWith('-')) positional = t
211 }
212 if (positional !== undefined) {
213 if (JUNK.test(positional)) return NONE
214 const urls = extractUrls(positional)
215 if (urls[0]) return { scanned: true, named: true, refs: [urls[0]] }
216 if (/^[#!]?\d+$/.test(positional)) {
217 if (!PR_NUMBER.test(positional)) return NONE
218 const refs = repo ? [{ ...repo, number: Number(positional.replace(/^[#!]/, '')) }] : []
219 return { scanned: true, named: true, refs }
220 }
221 }
222 return { scanned: true, named: false, refs: [] }
223}
224
225/**
226 * Refs touched by one Bash call: only single-PR `gh pr` / `glab mr` subcommands
227 * count; list/search/api calls and anything else yield nothing. The output is
228 * read only when a segment named no PR (`create`, bare `view`), and then only
229 * its first PR/MR URL.
230 */
231export function discoverFromBash(input: {
232 command: string
233 text?: string | null
234 repo?: RepoRef | null
235}): PrRef[] {
236 const out: PrRef[] = []
237 let unnamed = false
238 for (const seg of input.command.split(/&&|\|\||;|\||\n/)) {
239 const r = scanSegment(seg, input.repo)
240 if (!r.scanned) continue
241 if (!r.named) unnamed = true
242 out.push(...r.refs)
243 }
244 if (unnamed && input.text) {
245 const have = new Set(out.map(prId))
246 const first = extractUrls(input.text).find((r) => !have.has(prId(r)))
247 if (first) out.push(first)
248 }
249 return dedup(out)
250}
251
252export type ManualRef = { ok: true; ref: PrRef } | { ok: false; error: string }
253
254/**
255 * `/waypoint track|untrack` argument: a URL, an id (`host/path#n`,
256 * `host/path!n`), `group/proj!n`, or `#n` / `!n` against the session repo.
257 */
258export function resolveManualRef(arg: string, repo?: RepoRef | null): ManualRef {
259 const a = arg.trim()
260 if (!a) return { ok: false, error: 'usage: /waypoint track <url | #n | !n | group/project!n>' }
261 if (JUNK.test(a)) return { ok: false, error: `not a PR/MR reference: ${a}` }
262 const urls = extractUrls(a)
263 if (urls[0]) return { ok: true, ref: urls[0] }
264 const short = /^([#!])(\d{1,6})$/.exec(a)
265 if (short && short[2]) {
266 const kind: PrKind = short[1] === '#' ? 'github' : 'gitlab'
267 if (repo?.kind !== kind) {
268 const name = kind === 'github' ? 'GitHub' : 'GitLab'
269 return { ok: false, error: `no ${name} repo for this session; give a full URL for ${a}` }
270 }
271 return { ok: true, ref: { ...repo, number: Number(short[2]) } }
272 }
273 const full = /^((?:[\w.-]+\/)+[\w.-]+)([#!])(\d{1,6})$/.exec(a)
274 if (full && full[1] && full[3]) {
275 const kind: PrKind = full[2] === '#' ? 'github' : 'gitlab'
276 const segs = full[1].split('/')
277 const first = segs[0] ?? ''
278 if (first.includes('.') && segs.length >= 3) {
279 return { ok: true, ref: { kind, host: first.toLowerCase(), path: segs.slice(1).join('/'), number: Number(full[3]) } }
280 }
281 const host = repo?.kind === kind ? repo.host : kind === 'github' ? 'github.com' : 'gitlab.com'
282 return { ok: true, ref: { kind, host, path: full[1], number: Number(full[3]) } }
283 }
284 return { ok: false, error: `not a PR/MR reference: ${a}` }
285}
286hooks/prs/commands.ts 32 lines1// waypoint PRs: argv builders for polling one PR/MR. Pure.
2
3import type { PrRef } from '../../types'
4
5export const GH_FIELDS =
6 'number,title,url,state,isDraft,reviewDecision,mergeable,mergeStateStatus,statusCheckRollup,headRefName,updatedAt'
7
8/** `gh pr view <n> --repo [host/]owner/repo --json <fields>` */
9export function ghViewArgs(ref: PrRef): string[] {
10 const repo = ref.host === 'github.com' ? ref.path : `${ref.host}/${ref.path}`
11 return ['gh', 'pr', 'view', String(ref.number), '--repo', repo, '--json', GH_FIELDS]
12}
13
14/** `glab mr view <n> -R <path | https://host/path> -F json` */
15export function glabViewArgs(ref: PrRef): string[] {
16 const repo = ref.host === 'gitlab.com' ? ref.path : `https://${ref.host}/${ref.path}`
17 return ['glab', 'mr', 'view', String(ref.number), '-R', repo, '-F', 'json']
18}
19
20/**
21 * Env for a self-managed GitLab host. Whether glab needs GITLAB_HOST as well as
22 * the URL-form `-R` is unverified (only gitlab.com is testable); both are sent.
23 */
24export function glabEnv(ref: PrRef): Record<string, string> | undefined {
25 return ref.host === 'gitlab.com' ? undefined : { GITLAB_HOST: ref.host }
26}
27
28/** The view argv for any ref. */
29export function viewArgs(ref: PrRef): string[] {
30 return ref.kind === 'github' ? ghViewArgs(ref) : glabViewArgs(ref)
31}
32