SLOPSHOPPER

waypoint

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

newpanebandguardcommandprompt
v0.3.0MITupdated 2026-10-15phatjam98/claude-mods/mods/waypoint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waypoint
│ ┃ Waypoint ✕ › fix the failing auth test and add an audit log call │ ┃ No plan tracked. Enter plan mode, or pick a… │ ┃ No plans found in ~/.claude/plans. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /waypoint │ ⎿ waypoint: Waypoint opened. No plan tracked. │ ⎿ waypoint: No plans found in ~/.claude/plans. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Waypoint
No plan tracked. Enter plan mode, or pick a recent plan: No plans found in ~/.claude/plans.
README

waypoint

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.

Requirements

  • Claude Code 2.1.296 or later. Mods are early access.
  • gh and/or glab installed and logged in, for PR/MR tracking. Plan progress works without them.

Install

/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

What you see

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:

  • the plan title, and on the right the current step, done of total and the percentage (3/9 33% in compact);
  • a bar the full width of the pane in three spans: finished steps █ (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;
  • the step list, one glyph per step: ✓ 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;
  • a "Pull requests" section while any tracked PR/MR is open: the header counts those that need you (failing CI, requested changes, conflicts, ready to merge; in warning colour when any do), the other open ones, and when PRs were last polled. Rows list those needing you first, then the others dimmed (5 rows at most), each with its link, title, step, status and 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;
  • a "Waiting on you" section while the model has left a handoff note (see below): when it was left, its summary (6 lines at most, fewer when rows are tight) and one line per item, ! 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);
  • in the wide layout, a footer with the plan path (~ 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).

The plan convention

A step is a heading whose text starts with a marker:

MarkerMeaning
[ ]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.

Plan style

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.

Handoff: when the session waits on you

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.

Commands

CommandDoes
/waypointRe-read the plan and open the pane (at any width)
/waypoint closeClose the pane
/waypoint refreshRe-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 handoffPrint the model's current "waiting on you" note
/waypoint handoff clearClear 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 resetForget the tracked plan and its handoff note
`/waypoint layout auto\compact\wide`Choose the pane layout; this choice wins over the layout option
/waypoint layoutForget that choice, so the layout option applies again
/waypoint statusOne-line summary, then a pane: line (placement, drawn width, width asked)

Keys

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.

Configure

Set these in /config, or under pluginConfigs["waypoint"].options in settings.json.

OptionDefaultPurpose
openOnStarttrueOpen the pane when a plan is found at session start (placed only in wide terminals; the band shows otherwise)
bandtrueDraw a one-line plan summary above the prompt while the pane is not placed
statusLinetruePin the current step under the prompt
layoutautoauto picks compact inline and wide when docked; or compact / wide
paneColumns48Body columns the docked pane asks for; 0 leaves the engine's share. Ignored inline and once you drag the dock.
planDir~/.claude/plansWhere /waypoint use <slug> looks for plan files
planPollSeconds5How often the plan file is stat-ed for outside edits; 0 disables
resumeDays14A plan last touched within this many days comes back in a new session in the same directory
pollSeconds120How often tracked PRs/MRs are polled with gh/glab; minimum 30
notifyfalseAlso raise a system notification when a PR/MR starts needing attention or the model leaves a handoff note (toasts always show)
styleGuidetrueAppend 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)

What it can reach

  • Reads the tracked plan file, and lists 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.
  • Reads the HOME environment variable, to expand ~ in paths.
  • Reads the session's git 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.
  • Observes Bash tool calls whose command mentions 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.
  • Observes 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.
  • Observes ExitPlanMode calls (main session only), to pick up the plan file the call names.
  • Observes the plan-mode attachments (plan_mode, plan_mode_reentry, plan_mode_exit; main session only), for the plan file path and whether plan mode is on.
  • Observes turn completion (main session only), to re-check the plan file's modification time.
  • Observes prompt submissions (main session only), solely to attach a /waypoint conform instruction as hidden context once, when the plugin's own submit of it failed. It never reads or changes what you typed.
  • Runs read-only 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.
  • Registers one tool, 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.
  • Adds a short section to the system prompt while a plan is tracked or in plan mode. It asks the model to keep the step markers current: "A step is a heading whose text starts with a marker: ### [ ] 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.
  • Appends advisory text the model reads, never changing what a tool writes: the plan style guide on the engine's full plan-mode reminder (the one sent on the first request of a planning session and after a compaction, not the sparse repeats; a one-paragraph version of the rules rides in the compose section the rest of the time), a hidden note after a 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.
  • Writes to the clipboard only when you press p (plan path) or copy (PR URL).
  • Notifies with native system notifications only when the notify option is on (a PR/MR starting to need attention, a new handoff note); toasts show otherwise.
  • Stores, in the plugin's own store: 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.
  • Sends nothing itself: it makes no network requests of its own (gh/glab do, with your logins).
  • Never writes to repositories or to the plan file itself, and never changes a tool's input or what it writes.

Troubleshooting

  • Pane does not appear. It opens automatically only at 144 columns or wider. Run /waypoint to open it at any width; below that width the band above the prompt shows the summary.
  • The pane is too tall, or too wide. The dock is always full height; that is the engine's design, not an option. Its width comes from 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.
  • Hotkeys do nothing. They work only while the pane is focused (ctrl+x, then tab).
  • No PR status, or an error note. Check gh auth status / glab auth status. The pane shows the error class and retries with backoff.
  • Non-gitlab.com GitLab host. Support is untested; only gitlab.com has been verified.
  • A PR appears that is not yours. A #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.
  • See what it hooks. Run claude plugin validate . in mods/waypoint.

Develop

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.

License

MIT.

Source 14 files
hooks/register.tsx 1461 lines
1// 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 lines
1// 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 &lt;path&gt;</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 &lt;path&gt;.</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}
473
hooks/handoff.ts 128 lines
1// 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}
128
hooks/plan.ts 411 lines
1// 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}
411
hooks/prompt.ts 47 lines
1// 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}
47
hooks/style.ts 290 lines
1// 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
290
hooks/prs/index.ts 63 lines
1// 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}
63
hooks/track.ts 115 lines
1// 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}
115
hooks/view.ts 680 lines
1// 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}
680
hooks/prs/classify.ts 193 lines
1// 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}
193
hooks/prs/refs.ts 286 lines
1// 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}
286
hooks/prs/commands.ts 32 lines
1// 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