SLOPSHOPPER

astrolabe

Spec Kit progress and usage governance for Claude Code: a phase rail above the prompt, the next command in the prompt hint, the current task on the spinner, a…

newpanebandspinnerrowsguard
★ 1v0.113.0MITupdated 2026-10-08jonyfs/astrolabe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · astrolabe
│ ┃ 🧭 Astrolabe ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ [ ▸ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ]h: ? hf │ astrolabe │ │ ┃ filter features ⏺ Read(src/auth.ts) │ 🧭 Astrolabe is on: /astrolabe opens the │ │ ┃ This project does not use Spec Kit. Run spe… ⎿ Read 6 lines │ pane, /astrolabe help lists the commands │ │ ┃ 1-7 tabs · h help · f filters · s status · … ⏺ 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 │ ┃ │ ┃ › /astrolabe │ ┃ ⎿ astrolabe: Astrolabe pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ astrolabe: ◆ no Spec Kit · 5h 31%

Draws

Pane · 🧭 Astrolabe
[ ▸ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ]h: ? hf: ⌕ fs: s all✕ filter features This project does not use Spec Kit. Run specify init to sta… 1-7 tabs · h help · f filters · s status · j/k scroll · Esc… ──────────────────────────────────────────────────────── ◆ no Spec Kit 5h 31%  49%
README

🧭 Astrolabe

A Claude Code mod that shows where your Spec Kit work stands and keeps your session under its usage limits: a phase rail above the prompt, the next command to run, the task in progress, a pane with every feature, toasts when something changes, buttons that install updates, and a status entry with your fullest usage window.

Astrolabe in a 180-column terminal

Version 0.8.1. Every image in this README is a capture of the real mod running in Claude Code 2.1.292, made with scripts/capture/scene.sh in the demo project docs/demo/ (see How the images are made).

At a glance

WhereWhat Astrolabe drawsSection
Above the promptThe active feature, the six Spec Kit steps, a progress bar, and buttons for updatesThe band
Under the prompt◆ 002 · implement 45% · 5h 42%: feature, phase, progress, usage windowThe status entry
The hint linenext: /speckit-implement · 11 tasks leftThe prompt hint
The spinner… T011 · Show an empty-cart message · 1s while Claude works on the featureThe spinner
/astrolabeA pane with Specs, Tasks and Session tabsThe pane
ToastsA task ticked with no code edited; a phase finishedToasts
Tool callsNew subagents capped or held, the session paused near the limitUsage governance

Why "Astrolabe"

An astrolabe is a handheld instrument that astronomers and navigators used for centuries. You sight a star or the sun through it, read how high the body stands above the horizon, and from that one reading work out the time of day and where you are. Its front plate, the rete, is a rotating map of the sky laid over the fixed coordinates of one place.

The mod does the same job for a coding session. It reads altitude: how full the context window is, and how far the 5-hour and weekly usage windows have climbed. It turns those readings into time: when each window resets and, at the current burn rate, when you would reach 80%. It gives your position: the directory, branch and pull request, and which Spec Kit feature you are in and at which phase (specify, clarify, plan, tasks, implement).

Navigators also steered by the astrolabe, and the mod acts on its readings too. When a usage window gets high it slows down or queues new subagent dispatches, and it resumes them after the reset.

The emoji is 🧭 because Unicode has no astrolabe. The compass is the closest navigation instrument it offers, and the repository, the mod and its messages all use it.

Install

You need Claude Code 2.1.292 or later (claude --version). In a Claude Code terminal session, type:

/plugin install astrolabe --marketplace jonyfs/astrolabe

Claude Code asks Add marketplace?. Answer y, pick the user scope with Enter, and you see Installed astrolabe. Plugin is now active. The status entry appears in that same session, with no restart. To check it from a shell:

claude plugin list

The two-step form does the same thing:

/plugin marketplace add jonyfs/astrolabe
/plugin install astrolabe@astrolabe

From a shell it is the same two commands (a real capture, on a machine where Astrolabe was not installed):

claude plugin marketplace add and install

The four options start unset, which means their defaults (see Options).

Installing changes no settings file by itself. Claude Code records the plugin and its marketplace in ~/.claude/settings.json (enabledPlugins and extraKnownMarketplaces), the same as for any plugin, and the mod never writes there.

Update and uninstall

claude plugin update astrolabe@astrolabe

claude plugin update

Then type /reload-plugins in a running session, or start a new one. Astrolabe also checks for its own new release once a day and offers a button for it (see Update notices).

claude plugin uninstall astrolabe@astrolabe
claude plugin marketplace remove astrolabe

Install from a local clone

An install read from a folder runs the code on disk, but a session keeps the module it loaded. With autoReload on (the default), Astrolabe compares the version on disk with its own after each turn and runs /reload-plugins once when they differ, so a pull or a new release loads by itself. The first version with this check needs one /reload-plugins of your own.

To try a working copy, add the folder as a marketplace. Claude Code then reads the plugin from that folder, so edits show after /reload-plugins:

git clone https://github.com/jonyfs/astrolabe ~/src/astrolabe
claude plugin marketplace add ~/src/astrolabe
claude plugin install astrolabe@astrolabe

For a one-off session without installing, run claude --plugin-dir ~/src/astrolabe.

What the status entry shows

Astrolabe adds one entry to the status line under the prompt. Claude Code puts the mod's name in front of it:

  ⚠ astrolabe: ◆ 002 · implement 45% · 7d 81% hold

Each part, from left to right:

PartExampleWhat it means
⚠ astrolabe:Drawn by Claude Code, not by the mod. It marks a line that a mod wrote, and it names the mod.
◆The Spec Kit marker. Every Astrolabe entry starts with it.
Feature id001The three-digit number of the active feature, from its folder name specs/001-core-state/. The Spec Kit part is at most 68 characters with the ⚠ astrolabe: prefix, and the usage part adds at most 18 more, so it fits whole at 100 columns and wider; Claude Code cuts what does not fit.
~ before the id~002The feature was guessed. .specify/feature.json exists but is broken or points at a folder that is not there, so Astrolabe fell back to the git branch or the newest open feature. Fix or delete feature.json and the ~ goes away.
PhaseimplementThe first Spec Kit step this feature has not finished. See Phases.
Percentage87%Ticked tasks out of all tasks in the feature's tasks.md, rounded down. 43 of 49 is 87%. It appears only once tasks.md has tasks.
Running step· plan…A Spec Kit skill such as /speckit-plan was called during this turn. It disappears when the turn ends.
Usage window· 7d 81% hold (Mon 14:00)The window that decides, its band when it is not ok, and when it resets: a countdown such as 2h13m when the reset is less than a day away, else the weekday and time. A window at or past 100% reads full. The other window follows. See Usage governance.
Context·  61%How full the context window is. Past 85% it reads 86% compact soon.
Model and effort·  opus 5.5 highThe model and effort of Claude's last request in the main thread. With the 1M context window it reads opus 5.5 1M.
Running skill· ⟳ implement · sonnet 5.5The Spec Kit or gstack skill running now, with the model skillModels picked for it.
Git· main ↑2 3The branch, commits to push and to pull, and changed or untracked files, from one git status at the end of each turn. Without a repository it is left out.
Burn rate· 🔥 12/h → 96%Usage points an hour over the session, and where the window that decides will be at its reset at that pace. It shows once the session has two readings that rise.
Duration· 1h05mHow long the session has run, from its first minute on.

The footer in place of a statusline

The footer carries what a statusline under the prompt usually shows, so you can remove a separate statusLine command from your settings. By default it sits at the bottom of the /astrolabe pane, under every tab, and the status entry keeps only the Spec Kit part and the window that decides (◆ 002 · implement 45% · 5h 42% (2h13m)). Set footerIn to status to put the whole footer back in the status entry, or both for both places. On a tab shorter than the pane the footer holds the last rows; the Dashboard is longer, so its footer follows the charts.

In the pane the footer is drawn the way the statusline project draws its bar: Powerline chips in Catppuccin colours. Spec Kit is mauve, the model red, git lavender, and the usage windows and the context follow statusline's ramp: green below 60%, yellow to 85% (5h 72%▵), red above (5h 94%▴). The context takes the colour without the mark. The flavor option picks the palette; with ascii icons, the accessible mode or the NO_COLOR environment variable set, the footer is plain text with · between the parts.

When the room is too narrow, the parts go from the end: duration first, then the burn rate, git, model, the other window and the context. The Spec Kit part and the window that decides always stay.

A statusline showedIn Astrolabe
Directory, repository, branch, ahead and behind, changed files, stashes, worktreeThe footer's git part (the directory is the project you opened)
Model, effort, context windowThe footer
5-hour and 7-day windows with their resetsThe footer, and the governor acts on them
Session durationThe footer (Astrolabe shows no cost: on a subscription it means nothing)
Burn rate and projectionThe Dashboard tab
Todo progress, Claude working or idleThe band, the spinner and the Tasks tab
Skills in useThe band and the prompt hint show the running Spec Kit skill
Pull request and last CI runThe footer's git part with the pullRequest option on
Prompt cache timerA toast 30 seconds before the cache goes cold (see Toasts)
Vim mode, rtk savingsLeft out

The git part reads like this with icons set to ascii:

git:023-git-footer ^2 ~3 stash:1 wt:review PR#32 ok

^2 and ~3 are commits ahead and changed files, stash:1 is one stash entry, and wt:review says the checkout is the linked worktree review. PR#32 ok is the branch's open pull request with every check passed; x means one failed and .. that some are still running. The pull request part needs the pullRequest option and the gh command, signed in. Astrolabe asks gh at most once every five minutes per branch, on a timer after the turn, so a slow network never holds the footer. Without gh, or with no pull request for the branch, the part is left out.

Icons follow the icons option: Nerd Font glyphs in the terminal and emoji in the Desktop app by default, or ascii for plain characters everywhere. A Nerd Font cannot be detected, so pick emoji or ascii if the glyphs show as boxes.

The other entries you may see:

EntryWhen
◆ no Spec KitNo folder from the session's directory up to the filesystem root holds a .specify/ directory. Nothing else is read. The band only shows update buttons, if any (image below).
◆ no active feature · next: /speckit-specifySpec Kit is set up, but every feature is done or abandoned, or there are none yet. The command after next: is the one to run.
◆ no active feature · next: /speckit-constitutionSame, and the constitution is missing or still the unfilled template.
◆ 003 · abandonedfeature.json names a feature whose spec says status: abandoned. A percentage follows when it has tasks (◆ 003 · abandoned 40%).
◆ 001 · done 100%feature.json names a finished feature. Run /speckit-specify for the next one.
… · 5h 42%The fuller usage window, with its band when it is not ok (5h 83% hold). See Usage governance.

A folder without Spec Kit

Phases

The phase is read from the files on disk, top to bottom; the first rule that matches wins.

RulePhaseWhat to run next
spec.md front matter says status: abandonedabandoned/speckit-specify for a new feature
spec.md front matter says status: donedone/speckit-specify
No spec.mdspecify/speckit-specify
Front matter says track: quickimplement/speckit-implement, until you set status: done
No plan.md, and spec.md still has [NEEDS CLARIFICATIONclarify/speckit-clarify
No plan.mdplan/speckit-plan
No tasks.md, or no tasks in ittasks/speckit-tasks
Some tasks openimplement/speckit-analyze before the first tick in a session, then /speckit-implement
All ticked, front matter says status: activeimplement (100%)Set status: done when you agree it is finished
All tickeddone/speckit-specify

When it updates

Astrolabe reads every feature once when the session starts. At the end of each turn it re-reads feature.json, the constitution, the specs/ listing, the active feature, and any other feature a tool touched during the turn, so a box you tick in another editor shows up when the next turn ends. While a turn runs, an edit or write by Claude to spec.md, plan.md or tasks.md updates the entry right after that tool call.

A project with 40 features costs one full read per session and a handful of file reads per turn.

What the band shows

The band is the row directly above the prompt (the first row in the image at the top):

◆ 002 shopping-cart  constitution ● specify ● clarify ● plan ● tasks ● implement ◐  ████░░░░░░ 9/20 45%
PartExampleWhat it means
Id and name◆ 002 band-hintThe active feature, from specs/002-band-hint/. A ~ before the id means the feature was guessed, as in the status entry.
The railconstitution ● specify ● … implement ◐The six Spec Kit steps in order. ● is finished, ◐ is the step the feature is in now, ○ is still ahead. The constitution is ● once it is ratified; while it is missing or still the template it is ◐ and every later step is ○. When a spec still has [NEEDS CLARIFICATION after its plan exists, the current step turns red and shows ? instead of ◐.
… after a markplan ◐…A Spec Kit skill for that step is running in this turn.
The bar███████░░░Ten cells, one per tenth of the tasks ticked, rounded down. It appears once tasks.md has tasks.
The count9/20 45%Ticked tasks, all tasks, and the percentage rounded down.
Sparkline▂▃▅▆The deciding usage window over its last 12 readings, one block per reading, from 0% (▁) to 100% (█). It shows once there are 3 readings, on a band of 70 columns or more.
Update buttonsupdates: [ gstack 1.91.33.0 ]A second row when something has a newer version. See Update notices.

With the pointer on a step of the rail, a card under the band says what the step is for and how many features are in it (plan: the technical plan, research and data model · 2 features here).

A finished feature shows every mark as ● and a full bar. An abandoned feature named by feature.json shows ◆ 003 name abandoned instead of the rail.

Below 100 columns the rail shows only the current step's name; the hover cards name the others. When the band is narrower, it drops detail in this order and never cuts the id: the labels of the finished and later steps, then the name, then the bar. Below that it turns compact: the current step and the count (◆ 002 ◐ implement 14/31 45%), then the rail alone, then the step alone (◆ 002 ◐ implement), then ◆ 002. The bandDensity option starts lower: compact at the current step and the count, minimal at the step alone. A +2 after the name counts the other features in progress, and ⑂ name names the linked worktree this session runs in or, from the main checkout, the worktree where the active feature is being worked on.

On the next-command row, the run button is where the focus starts, so Enter runs it once the band has the keyboard, and with the pointer on it a line says why it is next (the plan is ready: break it into tasks). In a 100-column terminal it looks like this:

Astrolabe in a 100-column terminal

The band shows nothing of its own when the project has no Spec Kit, when no feature is active (the prompt hint then names the command to run), or while a survey uses the band. Whatever other mods draw there stays.

What the spinner says

While a turn works on the active feature, the spinner line names the task in progress and how long it has been the current one:

The spinner while Claude works on the feature

Claude Code still draws its own word and, after Astrolabe's part, the turn's time and token count. Astrolabe only adds the part after the word:

PartWhat it means
T011The id of the first open task in the active feature's tasks.md.
The textThe task's text without the [P] and [US1] markers or backticks, cut with … when the terminal is narrow. The id is never cut.
1sHow long this task has been the first open one, counted from when Astrolabe first saw it this session: 45s, 12m or 1h 5m.

It shows only during a turn that works on the active feature, meaning /speckit-implement was called, or Claude read or edited a file in the feature's folder during the turn. A read counts from the moment it starts, so the narration shows early in a short turn. Other turns keep the plain spinner. When Claude Code shows its own message on the spinner (while compacting, for example), that message wins.

The next command

Under the band, a row shows the next Spec Kit command as a button. Press it and the command runs, as if you had typed it. From 60 columns a copy button sits beside it and copies the command. Each time the next command changes, it is also proposed in the empty prompt box: press Tab (or the right arrow) to take it. /astrolabe next runs it from the prompt. When the last task of a feature is ticked, the prompt box proposes a retrospective instead: /speckit-retro when the project has that skill in .claude/skills/, gstack's /retro otherwise. The minimal preset shows none of this.

The row's buttons have keys once the band has the keyboard (ctrl+x tab, or a click): n runs the next command, c copies it, and a opens the /astrolabe pane (from 70 columns).

Acting on a spec

/astrolabe priority 3 high (or normal, low) orders a spec within its section of the Specs tab: high ones first with ↑ before the id, low ones last with ↓. On the Specs tab, p cycles the active spec through normal, high and low. Priorities are kept per project in Astrolabe's own store; nothing is written in the project.

/astrolabe review (or /astrolabe review 3) sends the spec's spec.md, plan.md and tasks.md, with the names of the constitution's principles, to a stronger model (opus, effort xhigh) and asks for at most 10 findings: what is missing, ambiguous, inconsistent or risky. The answer lands in the Session tab under review, and a toast says when. Only you can start it, by typing the command, since it costs that model's tokens.

/astrolabe advisor (or /astrolabe advisor 3) asks Claude to review the spec with its advisor: Claude reads spec.md, plan.md and tasks.md, calls the advisor, reports what it finds and proposes changes without editing anything. A mod cannot call the advisor itself, since the API runs it inside Claude's own request, so this takes one turn. The advisor review button above the active feature's summary does the same, and the Session tab counts the advisor's runs this session. When that turn ends after the advisor ran, the Session tab also keeps Claude's report, its first 12 non-blank lines under advisor 002. Only you can start it.

When gstack is installed, that row also holds its skills, above the active feature's summary: investigate, review, health, qa-only and retro. A press runs that skill with the active feature named in its arguments.

/astrolabe doctor checks what Astrolabe needs and prints the fix next to anything missing:

🧭 Astrolabe doctor
  ✓ git version 2.50.0
  ✓ gh version 2.80.0
  ✗ gh not signed in: run gh auth login
  ✓ specify 0.4.2
  ✓ Spec Kit project at /proj
  ✗ Spec Kit skills 0.4.0, CLI 0.4.2: refresh the skills: specify init --here --integration claude --force
  · icons: nerd (needs a Nerd Font in the terminal; set icons to emoji or ascii if glyphs show as boxes)
  · options: all defaults (change them in /config or the Config tab)
  ✓ slowest hook work: state update, 42 ms of the 10 s budget

The last line times every state update since the plugin loaded and names the slowest. It turns into a ✗ past 5 s, half of the 10 s the engine gives each hook.

/astrolabe recap lists the last 5 turns on the active feature, one line each: the time and the first line of Claude's answer. /astrolabe recap 026 does the same for feature 026. It keeps the last 20 answers' first lines in the session's state only.

Recent turns on 054:
  14:02  Done: T057 ticked, the implement guard refuses once
  14:20  Shipped v0.80.0 after the three CI jobs passed

/astrolabe focus on turns on focus mode. While it is on, the line Astrolabe adds to your prompt also tells Claude to change only the files the current task names: backticked paths and words with a slash or a file extension, such as hooks/core/pane.ts or README.md. A task that names no file gets "change only what task T010 needs and no other file". /astrolabe focus off turns it off, and /astrolabe focus says whether it is on. Only you can switch it, from the prompt; the setting lasts for the session, across a /reload-plugins. It needs the claudeContext option on.

Astrolabe: the active Spec Kit feature is 054 pane-polish, phase implement, 62 of 99 tasks done; the current task is T010 Edit `hooks/core/pane.ts`; the next command is /speckit-implement; focus mode is on: change only hooks/core/pane.ts.

/astrolabe kpis prints the Dashboard's numbers as a Markdown table you can paste into a pull request body: the active feature's tasks, the turns, the tool calls, the pace, the burn rate and the rest of the KPI rows the session has. Pipes in a value are escaped so the table stays whole.

### Session numbers for 054 pane-polish

| | |
|---|---|
| tasks | 61/99 |
| turns | 42 |
| tool calls | 318 |
| session | 2h10m |

/astrolabe status prints the active feature, the next command and the footer as text, with the band drawn above it in the terminal:

◆ 002 band-hint: implement, 9/20 tasks (45%)

next: /speckit-implement

◆ 002 · implement 45% · 5h 42% (2h13m) · ctx 61% · opus 5.5 high · git:main ~2

When Claude ticks tasks with an Edit, the tool's row in the transcript names them (↳ ticked T010 task 10).

What the prompt hint shows

While the prompt is empty, Astrolabe adds the next Spec Kit command to the end of Claude Code's hint line, after whatever is already there:

⏵⏵ auto mode on (shift+tab to cycle) · next: /speckit-implement · 4 tasks left

The command is the one in the Phases table. In the implement phase it also counts the tasks left. It disappears as soon as you type.

Options

Seven options appear in Claude Code's config menu (/config, then Astrolabe). Changing one reloads the mod right away.

OptionValuesDefaultWhat it changes

| preset | minimal, compact, full | compact | Where Astrolabe draws. minimal keeps only the status entry. compact adds the band, the prompt hint, the spinner narration and the drift alarm. full also opens the /astrolabe pane by itself on a wide fullscreen terminal, and shows phase toas

Source 51 files
hooks/register.tsx 2660 lines
1// Wires engine events to the io layer, the core and the status surface. No business logic.
2// The engine follows $ only into functions declared in this file, so every $ call lives here.
3import type { ConfigRow, EngineInterface, Hook, Register, RenderNode } from 'claude-code'
4
5import { bandSegments, nextReason, stepCards, type BandDensity } from './core/band'
6import { hintTail } from './core/hint'
7import { phaseToasts } from './core/phase-toast'
8import { justFinished } from './core/next-command'
9import { filterFeatures, helpRows, nextStatus, sessionRows, specsRows, taskRows, windowUnits } from './core/pane'
10import { presetOf } from './core/presets'
11import { spinnerSuffix } from './core/spinner'
12import {
13  astrolabeUpdate,
14  firstLine,
15  isDue,
16  isStoredUpdates,
17  localDay,
18  parseCliVersion,
19  parseGstackCheck,
20  parseSelfCheck,
21  skillsVersusCli,
22  releaseNotesUrl,
23  skillsUpdate,
24  updateLabel,
25} from './core/updates'
26import { principleHeadings } from './core/constitution'
27import { fileUrl, joinPath } from './core/paths'
28import { configMark, focusNote, GSTACK_SKILLS, nextPriority, OPTION_GROUPS, optionDefaults, optionGroup, parallelPrompt, parsePriority, REVIEW_MODEL, reviewPrompt, withPriority, type OptionGroup, type Priority } from './core/spec-actions'
29import { parallelTasks } from './core/extensions'
30import { CHANGES, VERSION } from './core/version'
31import {
32  ASK_MS,
33  clockOf,
34  decide,
35  dropped,
36  EXTEND_MS,
37  HOLD_LIFT_MS,
38  holdQuestion,
39  nextHeld,
40  isPaused,
41  isReadOnlyTool,
42  parseAllow,
43  pauseQuestion,
44  RAISE_MS,
45  refusal,
46  resumePrompt,
47  runPrompt,
48  usageRows,
49  usageSegment,
50  type Decision,
51  type Question,
52} from './core/governor'
53import { CHIPS, FLAVORS, flavorOf, isThemeKeys, STATUS_ROLE, themeOf, type ThemeRole } from './core/theme'
54import { capDiff, recapLine, recapOf, tasksDiff } from './core/summary'
55import { styleSections } from './core/style'
56import { parsePullList, prOpened, pullAction, PR_LIST_FIELDS } from './core/pulls'
57import { SKILL_MODELS, skillModelFor } from './core/skill-models'
58import { featureDirFor, mergedBranches, parseWorktrees, uncommittedCount, withWorktreeProgress, worktreeName, worktreeState } from './core/worktrees'
59import { readFeature } from './io/snapshot'
60import { deriveFeature } from './core/phase'
61import { parseTasks } from './core/tasks-parser'
62import { chartImage, dialFrames, imagesFor } from './core/pixels'
63import { emptyMemo, type PaneState, type PaneTab, type UpdateId, type UpdateItem, type UpdatesState, type UsageState, type UsageReading, type QueuedAgent, type SessionStats, type GitState, type PullRequest, type SpeckitState } from './core/types'
64import type { Preset } from './core/presets'
65import type { Fs } from './io/fs-port'
66import { findRoot } from './io/root'
67import { applyFileTouch, applyRead, applyShell, applySkill, type Held, reconcileDeferred, reconcileNow, reconcileStart, reconcileTurn } from './io/reconcile'
68import { bandRow, nextRow, updatesRow } from './surfaces/band'
69import { askTree } from './surfaces/ask'
70import { dashboardSections, dashboardTree } from './surfaces/dashboard'
71import { burnRate, dial, kpiChips, kpiRows, kpisMarkdown, phaseBars, sparkline, trendRows, usageChart } from './core/dashboard'
72import { addDay, addWeek, dayKey, estimateLeft, lastWeeks, pastReset, slowest, weekKey, weekdays, type Days, type Weeks } from './core/history'
73import { footerChips, footerText, type FooterInput } from './core/footer'
74import { branchWebUrl, parseGitStatus, parsePullRequest, remoteWebUrl } from './core/git-status'
75import { iconSet, iconsFor } from './core/icons'
76import { guessLang, langOf, t, type Lang, type TextKey } from './core/i18n'
77import { paneTree } from './surfaces/pane'
78import { activeMark, formatStatus } from './core/status-text'
79
80const SPECKIT = { plugin: 'astrolabe', key: 'speckit' } as const
81// The session memo lives apart from what drawings read, so no redraw carries it (spec 009).
82const MEMO = { plugin: 'astrolabe', key: 'memo' } as const
83const PANE_STATE = { plugin: 'astrolabe', key: 'pane' } as const
84const PANE_ID = 'astrolabe'
85const PANE_TITLE = '🧭 Astrolabe'
86const ASK_ID = 'astrolabe-usage'
87/** `/astrolabe doctor` (048 #79): what Astrolabe needs, each line with the fix when it is missing. */
88async function doctor($: EngineInterface): Promise<string> {
89  const probe = (argv: string[]) =>
90    $.process.run(argv, { timeoutMs: 3000 }).catch(() => ({ exitCode: 127, stdout: '', stderr: 'not found' }))
91  const [git, gh, specify] = await Promise.all([probe(['git', '--version']), probe(['gh', '--version']), probe(['specify', 'version'])])
92  const auth = gh.exitCode === 0 ? await probe(['gh', 'auth', 'status']) : undefined
93  const state = (await $.state.get(SPECKIT)).value
94  const lines = ['🧭 Astrolabe doctor']
95  const check = (isOk: boolean, text: string, fix: string) => lines.push(isOk ? `  ✓ ${text}` : `  ✗ ${text}: ${fix}`)
96  check(git.exitCode === 0, git.exitCode === 0 ? firstLine(git.stdout) : 'git not found', 'install git from https://git-scm.com')
97  check(gh.exitCode === 0, gh.exitCode === 0 ? firstLine(gh.stdout) : 'gh not found', 'install the GitHub CLI from https://cli.github.com for the PRs tab and the pullRequest option')
98  if (auth !== undefined) check(auth.exitCode === 0, auth.exitCode === 0 ? 'gh signed in' : 'gh not signed in', 'run gh auth login')
99  check(specify.exitCode === 0, specify.exitCode === 0 ? `specify ${firstLine(specify.stdout)}` : 'specify not found', 'install Spec Kit: uv tool install specify-cli --from git+https://github.com/github/spec-kit.git')
100  check(state?.present === true, state?.present === true ? `Spec Kit project at ${state.root ?? '?'}` : 'no Spec Kit project here', 'run specify init --here')
101  // The project's skills against the CLI (054 #98), named when they differ.
102  if (state?.root !== undefined && specify.exitCode === 0) {
103    const manifest = await $.fs.read(`${state.root}/.specify/integrations/speckit.manifest.json`).catch(() => undefined)
104    const versions = skillsVersusCli(typeof manifest === 'string' ? manifest : undefined, parseCliVersion(specify.stdout))
105    if (versions !== undefined) {
106      check(
107        versions.behind === 'none',
108        versions.behind === 'none' ? `Spec Kit skills match the CLI (${versions.cli})` : `Spec Kit skills ${versions.skills}, CLI ${versions.cli}`,
109        versions.behind === 'skills' ? `refresh the skills: ${SKILLS_REFRESH.join(' ')}` : 'upgrade the CLI: uv tool upgrade specify-cli',
110      )
111    }
112  }
113  const icons = iconsFor(iconsOption, 'terminal')
114  lines.push(`  · icons: ${icons}${icons === 'nerd' ? ' (needs a Nerd Font in the terminal; set icons to emoji or ascii if glyphs show as boxes)' : ''}`)
115  const set = Object.entries(optionsSeen).filter(([, v]) => v !== undefined)
116  lines.push(`  · options: ${set.length === 0 ? 'all defaults' : set.map(([k, v]) => `${k}=${String(v)}`).join(', ')} (change them in /config or the Config tab)`)
117  // The hook budget (054 #15): the engine gives each hook 10 s.
118  check(slowestHook === undefined || slowestHook.ms < 5000, slowestHook === undefined ? 'no hook work timed yet' : `slowest hook work: ${slowestHook.name}, ${slowestHook.ms} ms of the 10 s budget`, 'a large project or a slow disk; /astrolabe status still works, and the Specs tab reads the rest later')
119  return lines.join('\n')
120}
121
122// /astrolabe help (025, roadmap #39): the commands, the pane's tabs and their keys, in the
123// person's language (019).
124const buildHelp = (lang: Lang): string =>
125  [
126    t(lang, 'help.title'),
127    // What this version changed (048 #80).
128    t(lang, 'help.changes', { version: VERSION, changes: CHANGES }),
129    `  /astrolabe                  ${t(lang, 'help.open')}`,
130    `  /astrolabe help             ${t(lang, 'help.help')}`,
131    `  /astrolabe next             ${t(lang, 'help.next')}`,
132    `  /astrolabe status           ${t(lang, 'help.status')}`,
133    `  /astrolabe ask <question>   ${t(lang, 'help.ask')}`,
134    `  /astrolabe root <folder>    ${t(lang, 'help.root')}`,
135    `  /astrolabe allow <90-99> <30m-12h>   ${t(lang, 'help.allow')}`,
136    `  /astrolabe revoke           ${t(lang, 'help.revoke')}`,
137    `  /astrolabe run <id>         ${t(lang, 'help.run')}`,
138    `  /astrolabe doctor           ${t(lang, 'help.doctor')}`,
139    `  /astrolabe priority <id> <high|normal|low>   ${t(lang, 'help.priority')}`,
140    `  /astrolabe review [id]      ${t(lang, 'help.review')}`,
141    `  /astrolabe advisor [id]     ${t(lang, 'help.advisor')}`,
142    `  /astrolabe config reset     ${t(lang, 'help.configReset')}`,
143    `  /astrolabe worktrees        ${t(lang, 'help.worktrees')}`,
144    `  /astrolabe recap [id]       ${t(lang, 'help.recap')}`,
145    `  /astrolabe kpis             ${t(lang, 'help.kpis')}`,
146    `  /astrolabe focus [on|off]   ${t(lang, 'help.focus')}`,
147    // Each option with its value now (048 #75).
148    `${t(lang, 'help.options')}: ${OPTION_NAMES.map(name => `${name}=${optionsSeen[name] === undefined ? 'default' : String(optionsSeen[name])}`).join(', ')}.`,
149    t(lang, 'help.tabs'),
150    `  1 ${t(lang, 'tab.specs').padEnd(10)} ${t(lang, 'help.specs')}`,
151    `  2 ${t(lang, 'tab.tasks').padEnd(10)} ${t(lang, 'help.tasksTab')}`,
152    `  3 ${t(lang, 'tab.session').padEnd(10)} ${t(lang, 'help.sessionTab')}`,
153    `  4 ${t(lang, 'tab.dashboard').padEnd(10)} ${t(lang, 'help.dashboardTab')}`,
154    `  5 ${t(lang, 'tab.help').padEnd(10)} ${t(lang, 'help.helpTab')}`,
155    `  6 ${t(lang, 'tab.config').padEnd(10)} ${t(lang, 'help.configTab')}`,
156    `  7 ${t(lang, 'tab.prs').padEnd(10)} ${t(lang, 'help.prsTab')}`,
157    t(lang, 'help.keys'),
158    t(lang, 'help.models'),
159    ...Object.entries(SKILL_MODELS).map(([skill, m]) => `  ${skill.padEnd(22)} ${m.model.replace(/^claude-/, '').padEnd(12)} ${m.effort.padEnd(7)} ${m.why}`),
160    t(lang, 'help.footer'),
161    t(lang, 'help.marks'),
162    ...t(lang, 'help.marksList').split('\n').map(line => `  ${line}`),
163    t(lang, 'help.glossary'),
164    ...(['constitution', 'specify', 'clarify', 'plan', 'tasks', 'implement'] as const).map(step => `  ${step.padEnd(13)} ${t(lang, `card.${step}`)}`),
165    // The gates row under the active feature, each one explained (054 #61).
166    t(lang, 'help.gates'),
167    ...(['constitution', 'clarify', 'checklist', 'tasks', 'analyze'] as const).map(gate => `  ${t(lang, `gate.${gate}`).padEnd(13)} ${t(lang, `help.gate.${gate}`)}`),
168    // Where to read more (054 #81): each line ends with its link, which the Help tab makes clickable.
169    t(lang, 'help.docs'),
170    ...DOC_LINKS.map(d => `  ${d.name.padEnd(13)} ${d.url}`),
171  ].join('\n')
172const DOC_LINKS = [
173  { name: 'Spec Kit', url: 'https://github.github.com/spec-kit/' },
174  { name: 'gstack', url: 'https://github.com/garrytan/gstack' },
175  { name: 'Astrolabe', url: 'https://github.com/jonyfs/astrolabe#readme' },
176] as const
177const OPTION_NAMES = ['preset', 'flavor', 'icons', 'language', 'bandDensity', 'checkUpdates', 'governUsage', 'askOnLimit', 'pullRequest', 'images', 'autoReload', 'footerIn', 'accessible', 'claudeContext', 'featureSummary', 'humanize', 'terse', 'skillModels'] as const
178const WELCOMED = 'welcomed'
179const ABOUT: Readonly<Record<PaneTab, TextKey>> = {
180  specs: 'help.specs',
181  tasks: 'help.tasksTab',
182  session: 'help.sessionTab',
183  dashboard: 'help.dashboardTab',
184  help: 'help.helpTab',
185  config: 'help.configTab',
186  prs: 'help.prsTab',
187}
188// The help text built once per language and option set, not on every render (054 #2).
189let helpCache: { key: string; text: string } | undefined
190const helpText = (lang: Lang): string => {
191  const key = `${lang}|${JSON.stringify(optionsSeen)}`
192  if (helpCache?.key !== key) helpCache = { key, text: buildHelp(lang) }
193  return helpCache.text
194}
195const ASK = { plugin: 'astrolabe', key: 'ask' } as const
196const DEFAULT_PANE: PaneState = { tab: 'specs', autoOpened: false }
197const UPDATES = { plugin: 'astrolabe', key: 'updates' } as const
198const UPDATES_STORE = 'updates'
199const RELEASES_URL = 'https://api.github.com/repos/jonyfs/astrolabe/releases/latest'
200const UPDATES_HIDDEN = 'updates:hidden'
201const SKILLS_REFRESH = ['specify', 'init', '--here', '--integration', 'claude', '--force']
202const USAGE = { plugin: 'astrolabe', key: 'usage' } as const
203const QUEUE_MAX = 20
204const DEFAULT_USAGE: UsageState = { readings: [], history: [], inFlight: 0, queue: [], paused: false }
205const HISTORY_POINTS = 10
206const SESSION = { plugin: 'astrolabe', key: 'session' } as const
207const SERIES_POINTS = 60
208const HISTORY = 'history'
209const DAYS = 'days'
210// The disk version the plugins were last reloaded for (034).
211const RELOADED = 'reloaded'
212const GIT_STATUS = ['git', 'status', '--porcelain=v2', '--branch', '--show-stash']
213// The branch's pull request and its checks (023), opt-in, at most once per five minutes per branch.
214const GH_PR = ['gh', 'pr', 'view', '--json', 'number,statusCheckRollup']
215const PR_TTL_MS = 300_000
216
217// The session's numbers between writes (018): a tool call costs no state write; they are
218// written at the end of each main turn and at each measure. A reload loses one turn's counts.
219const live = { toolCalls: 0, drifts: 0, driftsById: {} as Record<string, number>, agentsRun: 0, agentsQueued: 0, model: undefined as string | undefined, effort: undefined as string | undefined }
220// The footer's room: the last width a drawing saw, less the "⚠ astrolabe: " the terminal adds.
221let columnsSeen = 120
222let surfaceSeen: string | null = 'terminal'
223let iconsOption: unknown = 'auto'
224// The person's language (019): the `language` option, or the guess from their prompts.
225let languageOption: unknown = 'auto'
226let guessedLang: Lang | undefined
227const currentLang = (): Lang => langOf(languageOption, guessedLang)
228
229const decisionOf = (usage: UsageState, now: number): Decision =>
230  decide(usage.readings, usage.history, usage.override, now, usage.holdLift, usage.held)
231
232/** The window an override is given for: the one binding now (016); none known, every window. */
233const kindOf = (usage: UsageState, now: number): { kind?: string } => {
234  const kind = decisionOf(usage, now).highest?.kind
235  return kind === undefined ? {} : { kind }
236}
237
238/** The status entry: the footer of spec 018, the Spec Kit part first (008, 018). */
239async function showStatus($: EngineInterface, state: Held['state']): Promise<void> {
240  // With the footer in the pane (035), the status entry keeps only what is never dropped.
241  $.ui.status(footerText({ ...(await footerInput($, state, Math.max(20, columnsSeen - 14))), lead: footerIn === 'pane' }))
242}
243
244/** What the footer shows, for the status entry and the pane alike (018, 035). */
245async function footerInput($: EngineInterface, state: Held['state'], width: number): Promise<FooterInput> {
246  const usage = (await $.state.get(USAGE)).value ?? DEFAULT_USAGE
247  const stats = (await $.state.get(SESSION)).value
248  const now = await $.clock.now()
249  return {
250      speckit: columns => formatStatus(state, columns, currentLang()),
251      lang: currentLang(),
252      readings: usage.readings,
253      decision: decisionOf(usage, now),
254      ...(stats?.context === undefined ? {} : { context: stats.context }),
255      ...(stats?.model === undefined ? {} : { model: stats.model }),
256      ...(stats?.effort === undefined ? {} : { effort: stats.effort }),
257      ...(stats?.git === undefined ? {} : { git: stats.git }),
258      ...(stats === undefined ? {} : { startedAt: stats.startedAt }),
259      ...((rate => (rate === undefined ? {} : { burn: rate }))(stats === undefined ? undefined : burnRate(stats.series))),
260      ...(state.runningSkill === undefined
261        ? {}
262        : { skill: { name: state.runningSkill.name, ...((m => (m === undefined ? {} : { model: m }))(skillModels === 'auto' ? skillModelFor(state.runningSkill.name)?.model : undefined)) } }),
263      now,
264      icons: iconSet(iconsFor(iconsOption, surfaceSeen as never)),
265      columns: width,
266  }
267}
268
269// Where the footer goes (035): the pane (the status entry keeps the lead), the status entry, or both.
270let footerIn: 'pane' | 'status' | 'both' = 'pane'
271let noColor = false
272// The options' defaults from plugin.json, read once at session start (052 #39).
273let defaultsSeen: Record<string, string | number | boolean> = {}
274let bandDensity: BandDensity = 'full'
275
276/** Writes what the session counted since the last write, merged with `change`, if anything moved. */
277async function flushStats($: EngineInterface, change: (s: SessionStats) => SessionStats = s => s): Promise<void> {
278  for (let attempt = 0; attempt < 5; attempt += 1) {
279    const { value, version } = await $.state.get(SESSION)
280    const now = await $.clock.now()
281    const before: SessionStats = value ?? { startedAt: now, turns: 0, toolCalls: 0, drifts: 0, agentsRun: 0, agentsQueued: 0, series: [] }
282    const counted: SessionStats = {
283      ...before,
284      toolCalls: before.toolCalls + live.toolCalls,
285      drifts: before.drifts + live.drifts,
286      ...(Object.keys(live.driftsById).length === 0
287        ? {}
288        : { driftsByFeature: Object.fromEntries([...new Set([...Object.keys(before.driftsByFeature ?? {}), ...Object.keys(live.driftsById)])].map(id => [id, (before.driftsByFeature?.[id] ?? 0) + (live.driftsById[id] ?? 0)])) }),
289      agentsRun: before.agentsRun + live.agentsRun,
290      agentsQueued: before.agentsQueued + live.agentsQueued,
291      ...(live.model === undefined ? {} : { model: live.model }),
292      ...(live.effort === undefined ? {} : { effort: live.effort }),
293    }
294    const next = change(counted)
295    if (value !== undefined && JSON.stringify(next) === JSON.stringify(value)) return
296    if ((await $.state.set(SESSION, next, { ifVersion: version })).isSet) {
297      live.toolCalls = 0
298      live.drifts = 0
299      live.driftsById = {}
300      live.agentsRun = 0
301      live.agentsQueued = 0
302      return
303    }
304  }
305}
306
307/** One `git status` at the end of a main turn (018 FR-003): no shell, 2 s at most. */
308async function readGit($: EngineInterface, root: string | undefined, branch: string | undefined): Promise<SessionStats['git'] | undefined> {
309  // A branch read from the repository files says there is a repository: no extra check.
310  if (root === undefined || branch === undefined) return undefined
311  try {
312    const run = await $.process.run(GIT_STATUS, { cwd: root, timeoutMs: 2000 })
313    if (run.exitCode !== 0) return undefined
314    // The remote's web page, asked once per session (054 #79).
315    if (remoteWeb === undefined) {
316      const remote = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: root, timeoutMs: 2000 }).catch(() => undefined)
317      remoteWeb = remote?.exitCode === 0 ? (remoteWebUrl(remote.stdout) ?? null) : null
318    }
319    const state = parseGitStatus(run.stdout)
320    return remoteWeb === null ? state : { ...state, remote: remoteWeb }
321  } catch {
322    return undefined
323  }
324}
325
326// The remote's web page for this session; null once asked and there is none.
327let remoteWeb: string | null | undefined
328
329let prRunning = false
330
331const DEFER_BATCH = 100
332
333/** Reads the deferred features a batch at a time, one timer each, until none is left (040). */
334async function loadDeferred($: EngineInterface): Promise<void> {
335  try {
336    const held = (await $.state.get(SPECKIT)).value
337    const pending = held?.features.filter(f => f.warnings.includes('loading')).map(f => f.dir) ?? []
338    if (pending.length === 0) return
339    const batch = pending.slice(0, DEFER_BATCH)
340    const now = await $.clock.now()
341    const fs = fsOf($)
342    const next = await guarded($, async previous => (previous === undefined ? undefined : reconcileDeferred(fs, previous, batch, now)))
343    if (next !== undefined) await showStatus($, next.state)
344    if (pending.length > batch.length) $.clock.after(0, () => void loadDeferred($))
345  } catch (error) {
346    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
347  }
348}
349
350// skillModels (030): off, or auto to send a skill's requests with the model and effort it does best with.
351let skillModels: unknown = 'off'
352// The skill the main thread last started this turn, cleared when the turn ends (030).
353let skillRunning: string | undefined
354
355const PULLS_TTL_MS = 120_000
356let pullsRunning = false
357
358/** Reads the open pull requests for the PRs tab (032), at most every two minutes unless forced. */
359async function refreshPulls($: EngineInterface, force = false): Promise<void> {
360  if (pullsRunning) return
361  const root = (await $.state.get(SPECKIT)).value?.root ?? (await $.session.cwd())
362  const at = await $.clock.now()
363  const was = (await $.state.get(SESSION)).value?.pulls
364  if (!force && was !== undefined && at - was.at < PULLS_TTL_MS) return
365  pullsRunning = true
366  try {
367    const run = await $.process.run(['gh', 'pr', 'list', '--state', 'open', '--limit', '20', '--json', PR_LIST_FIELDS], { cwd: root, timeoutMs: 8000 }).catch(() => undefined)
368    // A gh that fails or hangs says so instead of leaving the tab on "Reading…" (054 #14).
369    if (run === undefined || run.exitCode !== 0) {
370      const error = run === undefined ? 'gh did not answer in 8 s' : firstLine(run.stderr || run.stdout) || `gh exited ${run.exitCode}`
371      await flushStats($, s => ({ ...s, pulls: { at, rows: s.pulls?.rows ?? [], error } }))
372      return
373    }
374    const rows = parsePullList(run.stdout)
375    await flushStats($, s => ({ ...s, pulls: { at, rows } }))
376  } catch (error) {
377    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
378  } finally {
379    pullsRunning = false
380  }
381}
382
383/** Runs an action on a pull request (032) after its second press, then reads the list again. */
384async function runPullAction($: EngineInterface, action: 'approve' | 'update' | 'merge', n: number, head?: string): Promise<void> {
385  try {
386    const lang = currentLang()
387    // A merge waits for green checks (054 #58): pending or failing checks refuse it here.
388    const checks = (await $.state.get(SESSION)).value?.pulls?.rows.find(r => r.number === n)?.checks
389    if (action === 'merge' && (checks === 'pending' || checks === 'fail')) {
390      $.ui.toast(t(lang, checks === 'pending' ? 'prs.mergePending' : 'prs.mergeFailing', { n }))
391      return
392    }
393    const root = (await $.state.get(SPECKIT)).value?.root ?? (await $.session.cwd())
394    const run = await $.process.run(pullAction(action, n, head), { cwd: root, timeoutMs: 30_000 })
395    $.ui.toast(run.exitCode === 0 ? t(lang, `prs.done.${action}`, { n }) : t(lang, 'prs.failed', { n, error: (run.stderr || run.stdout).trim().split('\n')[0] ?? '', fix: pullAction(action, n).join(' ') }))
396    await refreshPulls($, true)
397  } catch (error) {
398    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
399  }
400}
401
402/** The PRs tab (032), led by gstack's /ship for the branch when gstack is installed (054 #96). */
403function pullsBody(
404  $: Parameters<Hook<'ui.render'>>[0],
405  e: Parameters<Hook<'ui.render'>>[1],
406  pane: PaneState,
407  stats: SessionStats | undefined,
408): Array<{ node: RenderNode; rows: number }> {
409  const elements = $.ui.resolve(e)
410  const branch = stats?.git?.branch
411  const ship =
412    stats?.gstack === true && branch !== undefined && branch !== 'main' && branch !== 'master' && 'Button' in elements
413      ? [{ rows: 1, node: <elements.Button key="gstack-ship" label={t(currentLang(), 'prs.ship', { branch })} plain onPress={() => void $.clock.after(0, () => void runSkill($, 'ship', `branch ${branch}`))} /> }]
414      : []
415  return [...ship, ...pullsRows($, e, pane, stats)]
416}
417
418/** The PRs tab's rows (032): a link per pull request, its state, and the buttons GitHub allows. */
419function pullsRows(
420  $: Parameters<Hook<'ui.render'>>[0],
421  e: Parameters<Hook<'ui.render'>>[1],
422  pane: PaneState,
423  stats: SessionStats | undefined,
424): Array<{ node: RenderNode; rows: number }> {
425  const elements = $.ui.resolve(e)
426  const { Box, Text, Button } = elements
427  const Link = 'Link' in elements ? elements.Link : undefined
428  const lang = currentLang()
429  const rows = stats?.pulls?.rows
430  if (rows === undefined) return [{ rows: 1, node: <Text color={tokens0.muted}>{t(lang, 'prs.loading')}</Text> }]
431  if (stats?.pulls?.error !== undefined && rows.length === 0) return [{ rows: 1, node: <Text color={tokens0[STATUS_ROLE.error]}>{t(lang, 'prs.listFailed', { error: stats.pulls.error })}</Text> }]
432  if (rows.length === 0) return [{ rows: 1, node: <Text color={tokens0.muted}>{t(lang, 'prs.none')}</Text> }]
433  const press = (key: string, run: () => void) => async () => {
434    const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
435    if (held.confirm === key) {
436      const { confirm: _gone, ...rest } = held
437      await $.state.set(PANE_STATE, rest)
438      $.clock.after(0, run)
439    } else {
440      await $.state.set(PANE_STATE, { ...held, confirm: key })
441      // The second press must come within 10 s; after that the row asks again from scratch (052 #41).
442      $.clock.after(10_000, () => void (async () => {
443        const now = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
444        if (now.confirm !== key) return
445        const { confirm: _late, ...rest } = now
446        await $.state.set(PANE_STATE, rest)
447      })().catch(error => $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })))
448    }
449  }
450  const mark = (c: string) => (c === 'pass' ? '✓' : c === 'fail' ? '✗' : c === 'pending' ? '…' : '·')
451  const colour = (c: string) => (c === 'pass' ? tokens0[STATUS_ROLE.success] : c === 'fail' ? tokens0[STATUS_ROLE.error] : c === 'pending' ? tokens0[STATUS_ROLE.warning] : tokens0.muted)
452  return rows.flatMap(pr => {
453    const title = `${mark(pr.checks)} #${pr.number} ${pr.isDraft ? `[${t(lang, 'prs.draft')}] ` : ''}${pr.title}`
454    const facts = [t(lang, `prs.review.${pr.review}`), ...pr.labels.map(l => `#${l}`), pr.branch].filter(s => s !== '').join('  ')
455    const buttons: RenderNode[] = []
456    const add = (action: 'approve' | 'update' | 'merge') => {
457      const key = `pr-${action}-${pr.number}`
458      buttons.push(<Button key={key} label={pane.confirm === key ? t(lang, 'prs.confirmAction', { action: t(lang, `prs.${action}`).toLowerCase(), n: pr.number }) : t(lang, `prs.${action}`)} onPress={press(key, () => void runPullAction($, action, pr.number, pr.head))} />, <Text> </Text>)
459    }
460    if (pr.review !== 'approved' && !pr.isDraft) add('approve')
461    if (pr.merge === 'BEHIND') add('update')
462    if (pr.merge === 'CLEAN' && !pr.isDraft) add('merge')
463    return [
464      {
465        rows: 1,
466        node: Link !== undefined && pr.url !== '' ? <Link href={pr.url} label={title} /> : <Text color={colour(pr.checks)}>{title}</Text>,
467      },
468      {
469        rows: 1,
470        node: (
471          <Box flexDirection="row">
472            <Text color={tokens0.muted}>{`   ${facts}  `}</Text>
473            {buttons}
474          </Box>
475        ),
476      },
477      // Each check, linked to its run (054 #80), at most 6.
478      ...(Link === undefined || pr.runs === undefined
479        ? []
480        : [
481            {
482              rows: 1,
483              node: (
484                <Box flexDirection="row">
485                  <Text color={tokens0.muted}>{'   '}</Text>
486                  {pr.runs.slice(0, 6).map(run => (
487                    <Link key={`pr-run-${pr.number}-${run.name}`} href={run.url} label={`${mark(run.result)} ${run.name} `} />
488                  ))}
489                </Box>
490              ),
491            },
492          ]),
493    ]
494  })
495}
496
497// Astrolabe's own /config rows (028), read at session start and after a save.
498let configRows: ConfigRow[] = []
499
500/** The Config tab's rows (028): one per option, its control, and Save / Cancel. */
501function configBody(
502  $: Parameters<Hook<'ui.render'>>[0],
503  e: Parameters<Hook<'ui.render'>>[1],
504  pane: PaneState,
505): Array<{ node: RenderNode; rows: number }> {
506  const elements = $.ui.resolve(e)
507  const { Box, Text, Button } = elements
508  const Select = 'Select' in elements ? elements.Select : undefined
509  const Input = 'Input' in elements ? elements.Input : undefined
510  const lang = currentLang()
511  const draft = pane.draft ?? {}
512  const setDraft = async (key: string, value: string | number | boolean) => {
513    const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
514    await $.state.set(PANE_STATE, { ...held, draft: { ...(held.draft ?? {}), [key]: value } })
515  }
516  const rows: Array<{ node: RenderNode; rows: number }> = []
517  if (configRows.length === 0) return [{ rows: 1, node: <Text color={tokens0.muted}>{t(lang, 'config.none')}</Text> }]
518  // Under a heading per group (054 #64).
519  const ordered = (Object.keys(OPTION_GROUPS) as OptionGroup[]).flatMap(group => configRows.filter(row => optionGroup(row.key) === group).map((row, i) => ({ row, heading: i === 0 ? group : undefined })))
520  for (const { row, heading } of ordered) {
521    if (heading !== undefined) rows.push({ rows: 1, node: <Text key={`config-group-${heading}`} color={tokens0.accent} bold>{t(lang, `config.group.${heading}` as TextKey)}</Text> })
522    const shown = row.key in draft ? draft[row.key] : row.value
523    const changed = row.key in draft && draft[row.key] !== row.value
524    // ● a change not saved yet; • a saved value that differs from the option's default (052 #39).
525    const label = `${configMark(changed, row.value, defaultsSeen, row.key)}${row.label}`.padEnd(30)
526    const key = `config-${row.key}`
527    let control: RenderNode
528    if (row.isLocked) control = <Text color={tokens0.muted}>{`${String(shown)} (${t(lang, 'config.locked')})`}</Text>
529    else if (row.kind === 'boolean') control = <Button key={key} label={shown === true ? '[x] on' : '[ ] off'} onPress={() => setDraft(row.key, shown !== true)} />
530    else if (row.kind === 'choice' && row.options !== undefined && Select !== undefined)
531      control = <Select key={key} options={row.options.map(value => ({ value }))} value={String(shown)} onSelect={(value: string) => setDraft(row.key, value)} />
532    else if (row.kind === 'choice' && row.options !== undefined) {
533      const options = row.options
534      const next = options[(options.indexOf(String(shown)) + 1) % options.length] ?? String(shown)
535      control = <Button key={key} label={`${String(shown)} ▸`} onPress={() => setDraft(row.key, next)} />
536    } else if (Input !== undefined) {
537      const save = (value: string) => setDraft(row.key, row.kind === 'number' ? Number(value) : value)
538      control = <Input key={key} value={String(shown)} onInput={save} onSubmit={save} />
539    } else control = <Text>{String(shown)}</Text>
540    rows.push({
541      rows: 1,
542      node: (
543        <Box flexDirection="row">
544          <Text color={changed ? tokens0.current : tokens0.text}>{label}</Text>
545          {control}
546        </Box>
547      ),
548    })
549  }
550  const pending = Object.keys(draft).filter(k => draft[k] !== configRows.find(r => r.key === k)?.value)
551  rows.push({
552    rows: 1,
553    node: (
554      <Box flexDirection="row">
555        <Button key="config-save" label={pending.length === 0 ? t(lang, 'config.saved') : t(lang, 'config.save', { n: pending.length })} variant="primary" onPress={() => saveConfig($)} />
556        <Text> </Text>
557        {/* Every option back to its default, as a draft Save applies (054 #63). */}
558        <Button key="config-reset" label={t(lang, 'config.reset')} onPress={() => draftDefaults($)} />
559        <Text> </Text>
560        <Button key="config-cancel" label={t(lang, 'config.cancel')} onPress={async () => {
561          const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
562          const { draft: _gone, ...rest } = held
563          await $.state.set(PANE_STATE, rest)
564        }} />
565      </Box>
566    ),
567  })
568  return rows
569}
570
571/** Applies the Config tab's changes through $.config.set (028); options reload the mod. */
572/** The options' defaults from our own plugin.json (054 #63). */
573async function defaults($: EngineInterface): Promise<Record<string, string | number | boolean>> {
574  const text = await fsOf($).read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '')
575  return optionDefaults(text)
576}
577
578/** Puts every option that differs from its default in the draft, for Save to apply (054 #63). */
579async function draftDefaults($: EngineInterface): Promise<void> {
580  const wanted = await defaults($)
581  const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
582  const draft = { ...(held.draft ?? {}) }
583  for (const row of configRows) if (!row.isLocked && row.key in wanted && wanted[row.key] !== row.value) draft[row.key] = wanted[row.key]!
584  await $.state.set(PANE_STATE, { ...held, draft })
585}
586
587/** `/astrolabe config reset`: every option back to its default now (054 #63). */
588async function resetConfig($: EngineInterface): Promise<string> {
589  const wanted = await defaults($)
590  const rows = (await $.config.list().catch(() => [] as ConfigRow[])).filter(row => row.key.startsWith('astrolabe.'))
591  const changed: string[] = []
592  for (const row of rows) {
593    if (row.isLocked || !(row.key in wanted) || wanted[row.key] === row.value) continue
594    const result = await $.config.set({ key: row.key, value: wanted[row.key]! }).catch(() => ({ deny: 'refused' }))
595    if (!('deny' in result && result.deny !== undefined)) changed.push(row.key.replace(/^astrolabe\./, ''))
596  }
597  configRows = (await $.config.list().catch(() => [] as ConfigRow[])).filter(row => row.key.startsWith('astrolabe.'))
598  return t(currentLang(), changed.length === 0 ? 'config.resetNone' : 'config.resetDone', { list: changed.join(', ') })
599}
600
601async function saveConfig($: EngineInterface): Promise<void> {
602  const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
603  const draft = held.draft ?? {}
604  const refused: string[] = []
605  let saved = 0
606  for (const [key, value] of Object.entries(draft)) {
607    if (configRows.find(r => r.key === key)?.value === value) continue
608    const result = await $.config.set({ key, value }).catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
609    if ('deny' in result && result.deny !== undefined) refused.push(`${key}: ${result.deny}`)
610    else saved += 1
611  }
612  const { draft: _gone, ...rest } = held
613  await $.state.set(PANE_STATE, rest)
614  configRows = (await $.config.list().catch(() => [] as ConfigRow[])).filter(row => row.key.startsWith('astrolabe.'))
615  const lang = currentLang()
616  $.ui.toast(refused.length === 0 ? t(lang, 'config.applied', { n: saved }) : t(lang, 'config.refused', { list: refused.join('; ') }))
617}
618
619// The options as loaded, for drawings that need more than one (039).
620let optionsSeen: Readonly<Record<string, unknown>> = {}
621
622/** Whether a colour is light enough to carry dark text (039), by its relative luminance. */
623const isLight = (hex: string): boolean => {
624  const n = Number.parseInt(hex.slice(1), 16)
625  const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(c => {
626    const v = c / 255
627    return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
628  })
629  return 0.2126 * r! + 0.7152 * g! + 0.0722 * b! > 0.3
630}
631
632// How many units each pane tab drew last (038), to clamp a scroll that arrives between draws.
633const unitsShown: Partial<Record<string, number>> = {}
634
635// Whether a finished feature gets a summary from a small model (026 #53), off by default.
636let featureSummary = false
637const SUMMARIES = 'summaries'
638
639/** Five lines on a finished feature, from `haiku`, kept in $.store and shown in the Session tab (026 #53). */
640async function summarizeFeature($: EngineInterface, feature: { dir: string; id: string; name: string }): Promise<void> {
641  try {
642    const root = (await $.state.get(SPECKIT)).value?.root
643    if (root === undefined) return
644    const fs = fsOf($)
645    const spec = await fs.read(`${root}/specs/${feature.dir}/spec.md`).catch(() => '')
646    const tasks = await fs.read(`${root}/specs/${feature.dir}/tasks.md`).catch(() => '')
647    const reply = await $.model.complete({
648      model: 'haiku',
649      maxTokens: 300,
650      prompt: `Summarize this finished Spec Kit feature in at most five short lines, plain text, no headings: what it delivers and anything left open.\n\n${spec.slice(0, 8000)}\n\n${tasks.slice(0, 4000)}`,
651    })
652    if (!reply.isAnswered) return
653    const stored = await $.store.get(SUMMARIES).catch(() => undefined)
654    const all = typeof stored === 'object' && stored !== null && !Array.isArray(stored) ? (stored as Record<string, string>) : {}
655    await $.store.set(SUMMARIES, { ...all, [feature.dir]: reply.text.trim().split('\n').slice(0, 5).join('\n') })
656    await flushStats($, s => ({ ...s, lastSummary: { dir: feature.dir, text: reply.text.trim().split('\n').slice(0, 5).join('\n') } }))
657    $.ui.toast(t(currentLang(), 'summary.ready', { feature: `${feature.id} ${feature.name}` }))
658  } catch (error) {
659    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
660  }
661}
662
663const GIT_WORKTREES = ['git', 'worktree', 'list', '--porcelain']
664let worktreesRunning = false
665
666/** Reads what the repository's other worktrees work on (037): one git call, then their spec files. */
667async function refreshWorktrees($: EngineInterface, root: string): Promise<void> {
668  if (worktreesRunning) return
669  worktreesRunning = true
670  try {
671    const run = await $.process.run(GIT_WORKTREES, { cwd: root, timeoutMs: 2000 }).catch(() => undefined)
672    const list = run?.exitCode === 0 ? parseWorktrees(run.stdout) : []
673    const main = list[0]
674    const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
675    const rel = main === undefined ? '' : norm(root).startsWith(norm(main.path)) ? norm(root).slice(norm(main.path).length) : ''
676    const fs = fsOf($)
677    const found: NonNullable<SessionStats['worktrees']> = []
678    // Branches already merged into the main checkout's branch (054 #50), one git call.
679    const merged = main?.branch === undefined ? new Set<string>() : await $.process.run(['git', 'branch', '--merged', main.branch], { cwd: root, timeoutMs: 2000 }).then(r => (r.exitCode === 0 ? mergedBranches(r.stdout) : new Set<string>())).catch(() => new Set<string>())
680    for (const wt of list.slice(1, 9)) {
681      const specRoot = `${norm(wt.path)}${rel}`
682      if (norm(specRoot) === norm(root)) continue
683      const dirs = (await fs.list(`${specRoot}/specs`).catch(() => [])).filter(d => d.kind === 'dir').map(d => d.name)
684      const dir = featureDirFor(wt.branch, dirs)
685      if (dir === undefined) continue
686      const feature = deriveFeature(await readFeature(fs, specRoot, dir))
687      // Its uncommitted files (054 #51).
688      const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: wt.path, timeoutMs: 2000 }).catch(() => undefined)
689      const changed = status?.exitCode === 0 ? uncommittedCount(status.stdout) : undefined
690      found.push({
691        name: worktreeName(wt.path),
692        ...(wt.branch === undefined ? {} : { branch: wt.branch }),
693        dir,
694        id: feature.id,
695        featureName: feature.name,
696        phase: feature.phase,
697        done: feature.done,
698        total: feature.total,
699        path: wt.path,
700        ...(changed === undefined ? {} : { changed }),
701        ...(wt.branch !== undefined && merged.has(wt.branch) ? { merged: true as const } : {}),
702      })
703    }
704    await flushStats($, ({ worktrees: _old, ...s }) => (found.length === 0 ? s : { ...s, worktrees: found }))
705  } catch (error) {
706    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
707  } finally {
708    worktreesRunning = false
709  }
710}
711
712// Whether Claude is told of the Spec Kit work (026), and what it was last told.
713let claudeContext = true
714let lastTold: string | undefined
715// Focus mode (054 #88), kept in the session's state across a reload.
716let focusMode = false
717
718/** One line on the active feature for Claude (026 #50); undefined without one. */
719/** The note for a prompt that names a feature other than the active one, by its 3-digit id (054 #91). */
720export const otherFeatureNamed = (text: string, state: SpeckitState): string | undefined => {
721  const active = state.features.find(f => f.dir === state.active?.dir)
722  if (active === undefined) return undefined
723  const ids = [...text.matchAll(/(?:^|[^\d])(\d{3})(?![\d])/g)].map(m => m[1]!)
724  const named = state.features.find(f => f.id !== active.id && ids.includes(f.id))
725  return named === undefined ? undefined : `Astrolabe: this prompt names feature ${named.id} ${named.name}, but the active one is ${active.id} ${active.name}; .specify/feature.json decides which one Spec Kit skills work on.`
726}
727
728// Features whose implement step was refused once this session (054 #57): a repeat call goes through.
729const implementWaived = new Set<string>()
730
731/** Why `/speckit-implement` is refused while the active spec has open clarifications (054 #57); undefined to let it run. */
732export const implementRefusal = (skill: string, state: SpeckitState | undefined, waived: ReadonlySet<string>): string | undefined => {
733  if (!/^speckit[-.]implement$/.test(skill) || state === undefined) return undefined
734  const feature = state.features.find(f => f.dir === state.active?.dir)
735  if (feature === undefined || waived.has(feature.dir)) return undefined
736  const open = feature.clarifications ?? 0
737  if (open === 0 && !feature.warnings.includes('clarification-after-plan')) return undefined
738  const count = open === 0 ? 'open [NEEDS CLARIFICATION] markers' : `${open} open [NEEDS CLARIFICATION] marker${open === 1 ? '' : 's'}`
739  return `Astrolabe: feature ${feature.id} ${feature.name} still has ${count} in spec.md. Run /speckit-clarify first. To implement anyway, call /speckit-implement again.`
740}
741
742const featureContext = (state: SpeckitState): string | undefined => {
743  const feature = state.features.find(f => f.dir === state.active?.dir)
744  if (!state.present || feature === undefined) return undefined
745  const parts = [
746    `the active Spec Kit feature is ${feature.id} ${feature.name}, phase ${feature.phase}${feature.total === 0 ? '' : `, ${feature.done} of ${feature.total} tasks done`}`,
747    ...(state.currentTask === undefined ? [] : [`the current task is ${state.currentTask.id === undefined ? '' : `${state.currentTask.id} `}${state.currentTask.text}`]),
748    ...(state.nextCommand === undefined ? [] : [`the next command is ${state.nextCommand}`]),
749    // What still blocks the feature (054 #84, #86): open questions, open checklist items, analyze not run.
750    ...((feature.clarifications ?? 0) > 0 || feature.warnings.includes('clarification-after-plan') ? ['the spec still has [NEEDS CLARIFICATION] markers'] : []),
751    ...((feature.checklist?.open ?? 0) > 0 ? [`${feature.checklist!.open} checklist items are open`] : []),
752    ...(feature.phase === 'implement' && !state.isAnalyzed && feature.done === 0 ? ['/speckit-analyze has not run on these tasks'] : []),
753    ...(focusMode ? [focusNote(state.currentTask)] : []),
754  ]
755  return `Astrolabe: ${parts.join('; ')}.`
756}
757
758/** The constitution's Core Principles, by heading, as a reminder (026 #51). */
759async function constitutionReminder($: EngineInterface): Promise<string | undefined> {
760  const root = (await $.state.get(SPECKIT)).value?.root
761  if (root === undefined) return undefined
762  const text = await fsOf($).read(`${root}/.specify/memory/constitution.md`).catch(() => undefined)
763  const principles = text === undefined ? [] : principlesOf(text)
764  return principles.length === 0 ? undefined : `Astrolabe: check this step against the constitution (.specify/memory/constitution.md): ${principles.join('; ')}.`
765}
766
767/** The `###` headings under `## Core Principles`, at most 22. */
768const principlesOf = (text: string): string[] => principleHeadings(text).map(p => p.name)
769
770/** `/astrolabe ask` (026 #54): one question over the session's own transcript, answered in a toast. */
771/** Runs one of gstack's skills on a feature (051), from a timer: a command does not run inside a render. */
772async function runSkill($: EngineInterface, skill: string, about: string): Promise<void> {
773  await $.command.run({ command: skill, args: `Spec Kit feature ${about}` }).catch((error: unknown) => {
774    $.ui.toast(t(currentLang(), 'gstack.failed', { skill }))
775    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
776  })
777}
778
779/** Sets a spec's priority in this project's store and the session (051). */
780async function setPriority($: EngineInterface, id: string, level: Priority): Promise<string> {
781  const state = (await $.state.get(SPECKIT)).value
782  const feature = state?.features.find(f => f.id === id)
783  if (state?.root === undefined || feature === undefined) return t(currentLang(), 'priority.none', { id })
784  const map = withPriority((await $.state.get(SESSION)).value?.priorities ?? {}, id, level)
785  await $.store.set(`priority:${state.root}`, map).catch(() => undefined)
786  await flushStats($, st => ({ ...st, priorities: map }))
787  return t(currentLang(), 'priority.set', { feature: `${feature.id} ${feature.name}`, level })
788}
789
790/** The deep review (051): spec, plan and tasks to a stronger model; the findings to the Session tab. */
791async function deepReview($: EngineInterface, feature: { dir: string; id: string; name: string }): Promise<void> {
792  try {
793    const root = (await $.state.get(SPECKIT)).value?.root
794    if (root === undefined) return
795    const fs = fsOf($)
796    const read = (file: string) => fs.read(`${root}/specs/${feature.dir}/${file}`).catch(() => '')
797    const [spec, plan, tasks] = await Promise.all([read('spec.md'), read('plan.md'), read('tasks.md')])
798    const constitution = await fs.read(`${root}/.specify/memory/constitution.md`).catch(() => '')
799    const reply = await $.model.complete({ ...REVIEW_MODEL, prompt: reviewPrompt(feature, { spec, plan, tasks }, principlesOf(constitution)) })
800    const text = reply.isAnswered ? reply.text.trim() : t(currentLang(), 'ask.failed', { reason: reply.reason })
801    await flushStats($, st => ({ ...st, lastReview: { id: feature.id, text: text.split('\n').slice(0, 12).join('\n'), at: Date.now() } }))
802    $.ui.toast(t(currentLang(), 'review.ready', { feature: `${feature.id} ${feature.name}` }), { timeoutMs: 15_000 })
803  } catch (error) {
804    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
805  }
806}
807
808async function askFork($: EngineInterface, question: string, about: string): Promise<void> {
809  try {
810    const reply = await $.model.fork({ prompt: `About the Spec Kit feature ${about}, answer in at most three sentences, plain text: ${question}` })
811    const text = reply.isAnswered ? reply.text.trim() : t(currentLang(), 'ask.failed', { reason: reply.reason })
812    $.ui.toast(`🧭 ${text.length > 280 ? `${text.slice(0, 279)}…` : text}`, { timeoutMs: 15_000 })
813  } catch (error) {
814    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
815  }
816}
817
818// The theme tokens, for drawings outside register's closure (025).
819let tokens0: ReturnType<typeof themeOf> = themeOf({})
820// The accessible mode (025 #48): ascii icons, text charts, no hover, no animation, no pictures.
821let accessible = false
822
823/**
824 * Opened on request: it takes the keys (1 to 4 at once) and Esc closes it. An unasked open never
825 * takes focus (Principle VII).
826 */
827async function openPane($: EngineInterface): Promise<void> {
828  // The title names the active feature (052 #4) the way the band and the footer do (054 #30).
829  const state = (await $.state.get(SPECKIT)).value
830  const title = state?.active === undefined ? PANE_TITLE : `${PANE_TITLE} · ${activeMark(state)} ${state.active.name}`
831  await $.ui.open({ id: PANE_ID, title, focus: true, closeOnEscape: true })
832}
833
834/** `/astrolabe status` (025 #42): the active feature, the next command and the footer, as text. */
835/** The tasks an edit ticks: unticked in the old text, ticked in the new (025 #43). */
836const tickedBy = (before: string, after: string): string[] => {
837  const was = new Map(parseTasks(before).map(task => [task.id ?? task.text, task.isDone]))
838  return parseTasks(after)
839    .filter(task => task.isDone && was.get(task.id ?? task.text) === false)
840    .map(task => `${task.id === undefined ? '' : `${task.id} `}${task.text}`)
841}
842
843const statusText = (state: SpeckitState, footer: string, lang: Lang): string => {
844  const feature = state.features.find(f => f.dir === state.active?.dir)
845  const lines = [
846    feature === undefined
847      ? t(lang, 'status.noActive')
848      : `◆ ${feature.id} ${feature.name}: ${feature.phase}${feature.total === 0 ? '' : `, ${feature.done}/${feature.total} tasks (${Math.floor((feature.done * 100) / feature.total)}%)`}`,
849    ...(state.nextCommand === undefined ? [] : [`${t(lang, 'status.next')}: ${state.nextCommand}`]),
850    footer,
851  ]
852  return lines.join('\n\n')
853}
854
855// Whether the plugins reload by themselves when a new version lands on disk (034).
856let autoReload = true
857// The version on disk this module last acted on, so one version reloads at most once (034).
858let actedOn: string | undefined
859
860/** Compares the version in our own plugin.json with the one running; reloads once when they differ (034). */
861async function checkDiskVersion($: EngineInterface): Promise<void> {
862  try {
863    const text = await fsOf($).read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => undefined)
864    if (text === undefined) return
865    const version = (JSON.parse(text) as { version?: unknown }).version
866    if (typeof version !== 'string' || version === VERSION || version === actedOn) return
867    const stored = await $.store.get(RELOADED).catch(() => undefined)
868    if (stored === version) return
869    actedOn = version
870    await $.store.set(RELOADED, version).catch(() => undefined)
871    const lang = currentLang()
872    if (!autoReload) {
873      $.ui.toast(t(lang, 'toast.onDisk', { version, running: VERSION }))
874      return
875    }
876    $.ui.toast(t(lang, 'toast.reloading', { version, running: VERSION }))
877    await $.command.run({ command: 'reload-plugins' })
878  } catch (error) {
879    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
880  }
881}
882
883// Whether Claude Code's theme is a light one, read at session start (024): the charts' colors.
884let isLightTheme = false
885// Whether the terminal draws pictures (024 #5), from the images option and its variables.
886let isImageTerminal = false
887let pictureCache: { key: string; picture: { rgba: string; width: number; height: number } } | undefined
888// The active tasks at the end of the last main turn, to diff the next one against (024).
889let turnTasks: { dir: string; tasks: NonNullable<SpeckitState['activeTasks']> } | undefined
890
891/** Keeps the features whose id or name holds the filter (024 #49). */
892const filtered = (state: SpeckitState, filter: string | undefined, status: PaneState['status'] = 'all'): SpeckitState =>
893  (filter ?? '').trim() === '' && status === 'all' ? state : { ...state, features: filterFeatures(state.features, filter, state.active?.dir, status) }
894
895
896/**
897 * What the Specs and Tasks tabs draw above their rows (024): the filter and the active spec's
898 * summary with links to its files, or the last turn's tasks diff. Only elements the surface has.
899 */
900function paneHeader(
901  $: Parameters<Hook<'ui.render'>>[0],
902  e: Parameters<Hook<'ui.render'>>[1],
903  pane: PaneState,
904  state: SpeckitState,
905  stats: SessionStats | undefined,
906): RenderNode[] {
907  const elements = $.ui.resolve(e)
908  const Input = 'Input' in elements ? elements.Input : undefined
909  const Markdown = 'Markdown' in elements ? elements.Markdown : undefined
910  const Code = 'Code' in elements ? elements.Code : undefined
911  const active = state.active
912  // One filter for Specs, Tasks and Help (043 #23).
913  if (pane.tab === 'specs' || pane.tab === 'tasks' || pane.tab === 'help') {
914    const out: RenderNode[] = []
915    if (Input !== undefined) {
916      // Each keystroke filters; Enter keeps the text the same way.
917      const setFilter = async (value: string) => {
918        const held = (await $.state.get(PANE_STATE)).value ?? DEFAULT_PANE
919        await $.state.set(PANE_STATE, { ...held, filter: value })
920      }
921      out.push(
922        <Input key="astrolabe-filter" placeholder={t(currentLang(), pane.tab === 'specs' ? 'pane.filter' : 'pane.filterRows')} value={pane.filter ?? ''} onInput={setFilter} onSubmit={setFilter} />,
923      )
924    }
925    if (pane.tab === 'tasks' && Code !== undefined && stats?.tasksDiff !== undefined && stats.tasksDiff.dir === active?.dir) {
926      out.push(<Code source={capDiff(stats.tasksDiff.text, currentLang())} format="diff" path={stats.tasksDiff.file} />)
927    }
928    // gstack's skills on the active feature, when gstack is installed (051).
929    const Button = 'Button' in elements ? elements.Button : undefined
930    // The active feature's actions (051, 055): the advisor's review, and gstack's skills when installed.
931    if (pane.tab === 'specs' && active !== undefined && Button !== undefined) {
932      const about = `${active.id} ${active.name}`
933      const feature = state.features.find(f => f.dir === active.dir)
934      out.push(
935        <elements.Box key="astrolabe-gstack" flexDirection="row">
936          {feature !== undefined && <Button key="advisor-review" label={t(currentLang(), 'advisor.button')} plain onPress={() => askAdvisor($, feature)} />}
937          {stats?.gstack === true &&
938            GSTACK_SKILLS.map(skill => <Button key={`gstack-${skill}`} label={skill} plain onPress={() => void $.clock.after(0, () => void runSkill($, skill, about))} />)}
939        </elements.Box>,
940      )
941    }
942    // A run of [P] tasks offered as one prompt to subagents (054 #89).
943    if (pane.tab === 'tasks' && active !== undefined && Button !== undefined) {
944      const open = (state.activeTasks ?? []).filter(task => !task.isDone)
945      const run = parallelTasks(open)
946      const feature = state.features.find(f => f.dir === active.dir)
947      if (run.length >= 2 && feature !== undefined) {
948        const tasks = open.filter(task => task.id !== undefined && run.includes(task.id))
949        out.push(
950          <Button key="parallel-dispatch" label={t(currentLang(), 'parallel.button', { ids: run.join(', ') })} plain onPress={() => void $.clock.after(0, () => void $.prompt.submit({ text: parallelPrompt(feature, tasks) }).catch(() => undefined))} />,
951        )
952      }
953    }
954    if (pane.tab === 'specs' && Markdown !== undefined && active !== undefined && state.activeSummary !== undefined && state.root !== undefined) {
955      const links = (state.activeDocs ?? []).map(file => `[${file}](${fileUrl(`${state.root}/specs/${active.dir}/${file}`)})`).join(' · ')
956      out.push(<Markdown key="astrolabe-summary" text={links === '' ? state.activeSummary : `${state.activeSummary}\n\n${links}`} />)
957    }
958    return out
959  }
960  return []
961}
962
963/** Notes the turn's change to the active tasks as diff hunks for the Tasks tab (024 #8). */
964async function noteTasksDiff($: EngineInterface, state: SpeckitState | undefined): Promise<void> {
965  const dir = state?.active?.dir
966  const tasks = state?.activeTasks
967  const before = turnTasks
968  turnTasks = dir === undefined || tasks === undefined ? undefined : { dir, tasks }
969  if (before === undefined || dir === undefined || tasks === undefined || before.dir !== dir) return
970  const text = tasksDiff(before.tasks, tasks)
971  if (text === undefined) return
972  const file = state?.activeDocs?.includes('tasks.md') === true ? 'tasks.md' : 'spec.md'
973  await flushStats($, s => ({ ...s, tasksDiff: { dir, file, text } }))
974}
975
976/** Asks `gh` for the branch's pull request when the cached answer is older than five minutes (023). */
977async function refreshPr($: EngineInterface, root: string, branch: string): Promise<void> {
978  if (prRunning) return
979  prRunning = true
980  try {
981    const now = await $.clock.now()
982    const cached = (await $.state.get(SESSION)).value?.prCache
983    if (cached !== undefined && cached.branch === branch && now - cached.at < PR_TTL_MS) return
984    // gh missing, signed out or no pull request: no part, no error; the next try is in five minutes.
985    const run = await $.process.run(GH_PR, { cwd: root, timeoutMs: 5000 }).catch(() => undefined)
986    const pr = run?.exitCode === 0 ? parsePullRequest(run.stdout) : undefined
987    await flushStats($, s => {
988      const git = s.git?.branch === branch ? withPr(s.git, pr) : s.git
989      return { ...s, prCache: { branch, at: now, ...(pr === undefined ? {} : { pr }) }, ...(git === undefined ? {} : { git }) }
990    })
991    const speckit = (await $.state.get(SPECKIT)).value
992    if (speckit !== undefined) await showStatus($, speckit)
993  } catch (error) {
994    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
995  } finally {
996    prRunning = false
997  }
998}
999
1000const withPr = (git: GitState, pr: PullRequest | undefined): GitState => {
1001  const { pr: _old, ...rest } = git
1002  return pr === undefined ? rest : { ...rest, pr }
1003}
1004
1005/** Notes the model and effort of each main-thread request (018), leaving the request untouched. */
1006async function* noteModel($: Parameters<Hook<'turn.step'>>[0], e: Parameters<Hook<'turn.step'>>[1], next: Parameters<Hook<'turn.step'>>[2]) {
1007  // skillModels auto (030): while a skill with an entry runs, its model and effort.
1008  const pick = skillModels === 'auto' && e.agentId === undefined ? skillModelFor(skillRunning) : undefined
1009  const step = pick === undefined ? e : { ...e, model: pick.model, effort: pick.effort }
1010  if (e.agentId === undefined) {
1011    live.model = step.model
1012    live.effort = step.effort === undefined ? undefined : String(step.effort)
1013  }
1014  const result = yield* next(step)
1015  // The advisor is a server tool the API runs inside the request (055): count each run.
1016  const advised = ((result as { serverToolUses?: ReadonlyArray<{ name: string }> } | undefined)?.serverToolUses ?? []).filter(use => use.name === 'advisor').length
1017  if (advised > 0 && e.agentId === undefined) {
1018    const at = await $.clock.now()
1019    await flushStats($, st => ({ ...st, advisor: { ...st.advisor, runs: (st.advisor?.runs ?? 0) + advised, at } }))
1020    if (advisorAsked !== undefined) advisorAsked.ran = true
1021  }
1022  // The answer of the turn Astrolabe asked for goes to the Session tab (055 T004).
1023  const response = result as { answer?: string; stopReason?: string | null } | undefined
1024  if (e.agentId === undefined && advisorAsked !== undefined && response?.stopReason !== 'tool_use' && response?.stopReason !== 'pause_turn') {
1025    const asked = advisorAsked
1026    advisorAsked = undefined
1027    const text = advisorFindings(response?.answer ?? '')
1028    if (asked.ran && text !== '') {
1029      const at = await $.clock.now()
1030      await flushStats($, st => ({ ...st, advisor: { runs: st.advisor?.runs ?? 0, at: st.advisor?.at ?? at, last: { id: asked.id, text, at } } }))
1031    }
1032  }
1033  return result
1034}
1035
1036// The advisor review Astrolabe asked for, until the turn that answers it ends (055 T004).
1037let advisorAsked: { id: string; ran: boolean } | undefined
1038
1039/** Sends the advisor prompt and waits for its answer (055). */
1040const askAdvisor = ($: EngineInterface, feature: { id: string; name: string; dir: string }): void => {
1041  advisorAsked = { id: feature.id, ran: false }
1042  $.clock.after(0, () => void $.prompt.submit({ text: advisorPrompt(feature) }).catch(() => undefined))
1043}
1044
1045/** The answer kept for the Session tab: its non-blank lines, at most 12 (055 T004). */
1046export const advisorFindings = (answer: string): string =>
1047  answer
1048    .split(/\r?\n/)
1049    .map(line => line.trimEnd())
1050    .filter(line => line.trim() !== '')
1051    .slice(0, 12)
1052    .join('\n')
1053
1054/** The prompt that asks Claude to have the advisor review a spec (055); the advisor is Claude's own tool. */
1055const advisorPrompt = (feature: { id: string; name: string; dir: string }): string =>
1056  [
1057    `Review the Spec Kit feature ${feature.id} ${feature.name} with the advisor.`,
1058    `Read specs/${feature.dir}/spec.md, and plan.md and tasks.md if they exist, then call the advisor tool.`,
1059    'Report what it finds that is missing, ambiguous, inconsistent between the files, untestable or risky, most serious first.',
1060    'Do not edit any file; end by proposing the changes for me to approve.',
1061  ].join(' ')
1062
1063/** Read-modify-write of astrolabe.usage with ifVersion, retried like `guarded`. */
1064async function updateUsage($: EngineInterface, change: (usage: UsageState) => UsageState): Promise<UsageState> {
1065  for (let attempt = 0; attempt < 5; attempt += 1) {
1066    const { value, version } = await $.state.get(USAGE)
1067    const next = change(value ?? DEFAULT_USAGE)
1068    if ((await $.state.set(USAGE, next, { ifVersion: version })).isSet) return next
1069  }
1070  return (await $.state.get(USAGE)).value ?? DEFAULT_USAGE
1071}
1072
1073async function decisionNow($: EngineInterface): Promise<{ usage: UsageState; decision: Decision }> {
1074  const usage = (await $.state.get(USAGE)).value ?? DEFAULT_USAGE
1075  return { usage, decision: decisionOf(usage, await $.clock.now()) }
1076}
1077
1078/** Submits one resume prompt for what the pause or the hold left waiting, then clears it. */
1079async function resume($: EngineInterface, why: string): Promise<void> {
1080  try {
1081    const usage = (await $.state.get(USAGE)).value ?? DEFAULT_USAGE
1082    if (usage.queue.length === 0 && !usage.paused) return
1083    const since = usage.waitingSince
1084    await updateUsage($, ({ waitingSince: _w, ...u }) => ({ ...u, queue: [], paused: false }))
1085    if (since !== undefined) {
1086      const waited = Math.max(0, (await $.clock.now()) - since)
1087      await flushStats($, st => ({ ...st, waitedMs: (st.waitedMs ?? 0) + waited }))
1088    }
1089    await logGovernor($, t(currentLang(), 'log.resumed', { why }))
1090    const speckit = (await $.state.get(SPECKIT)).value
1091    const task = speckit?.currentTask
1092    const feature = speckit?.features.find(f => f.dir === speckit.active?.dir)
1093    const where = task === undefined || feature === undefined ? undefined : `${task.id === undefined ? '' : `${task.id} `}${task.text} in ${feature.id} ${feature.name}`
1094    await $.prompt.submit({ text: resumePrompt(usage.queue, why, where) })
1095    // A phone notice when the work starts again (022 #31); the engine skips it while the person is present.
1096    await $.tool
1097      .call({ tool: 'PushNotification', tool_use_id: `astrolabe-resume-${await $.clock.now()}`, message: t(currentLang(), 'push.resumed', { why }), status: 'proactive' } as never)
1098      .catch(() => undefined)
1099  } catch (error) {
1100    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
1101  }
1102}
1103
1104/** The context warning (022 #33), once a session and again after a compact. Cost is not shown (054): on a subscription it means nothing. */
1105async function warnUsage($: EngineInterface, percent: number | undefined): Promise<void> {
1106  const lang = currentLang()
1107  const stats = (await $.state.get(SESSION)).value
1108  const warned = { ...(stats?.warned ?? {}) }
1109  const toasts: string[] = []
1110  if (percent !== undefined) {
1111    if (percent >= 85 && warned.context !== true) {
1112      toasts.push(t(lang, 'toast.context', { p: Math.round(percent) }))
1113      warned.context = true
1114    } else if (percent < 70 && warned.context === true) warned.context = false
1115  }
1116  if (JSON.stringify(warned) === JSON.stringify(stats?.warned ?? {})) return
1117  await flushStats($, s => ({ ...s, warned }))
1118  for (const text of toasts) $.ui.toast(text)
1119}
1120
1121// The images option (024): auto, on or off.
1122let imagesOption: unknown
1123// Whether the footer asks gh for the branch's pull request (023), off by default.
1124let pullRequests = false
1125// When the last main turn ended; the prompt cache timer fires only if no turn came after it (022 #34).
1126let lastTurnAt = 0
1127const CACHE_WARN_MS = 270_000
1128
1129// One resume timer at a time. A reload drops timers and this variable together, and the
1130// next reading or refusal arms a new one.
1131let resumeAt: number | undefined
1132
1133/**
1134 * At a window's reset: redraw the status (the window renewed), then resume only if no other
1135 * window still holds or pauses; otherwise wait for the reset of the one binding now (016).
1136 */
1137async function afterReset($: EngineInterface, why: string): Promise<void> {
1138  try {
1139    const { decision } = await decisionNow($)
1140    const speckit = (await $.state.get(SPECKIT)).value
1141    if (speckit !== undefined) await showStatus($, speckit)
1142    if (decision.band === 'ok' || decision.band === 'throttle') await resume($, why)
1143    else await armResume($, decision)
1144  } catch (error) {
1145    $.ui.log(`astrolabe: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
1146  }
1147}
1148
1149async function armResume($: EngineInterface, decision: Decision): Promise<void> {
1150  const reset = decision.highest?.resetsAt === undefined ? Number.NaN : Date.parse(decision.highest.resetsAt)
1151  if (Number.isNaN(reset) || resumeAt === reset) return
1152  resumeAt = reset
1153  const wait = Math.max(0, reset - (await $.clock.now())) + 1000
1154  const why = `${decision.highest?.kind === 'seven_day' ? '7d' : '5h'} reset`
1155  $.clock.after(wait, () => {
1156    resumeAt = undefined
1157    void afterReset($, why)
1158  })
1159}
1160
1161// One usage question at a time (015, 017). The gate never waits for it: a hook has 10 s, so it
1162// refuses with the cautious default at once and the question runs in the plugin's own time,
1163// from a timer. The answer then acts on the queue and the pause. `pick` settles it from a
1164// press in the pane or the pane's close.
1165let isAsking = false
1166let pick: ((value: string) => void) | undefined
1167// Whether a person is at the prompt: a -p run or the SDK is never asked (015).
1168let isInteractive = true
1169const TIMED_OUT = '\u0000timeout'
1170// What a refusal adds while the person is asked, so Claude waits instead of retrying.
1171const ASKING_NOTE = 'The person is being asked; if they allow it, a prompt will say so. Do not retry it.'
1172
1173/** A lift of the hold or of the ceiling, for its own window (016); false for any other answer. */
1174async function applyLift($: EngineInterface, question: Question, value: string): Promise<boolean> {
1175  const now = await $.clock.now()
1176  const target = question.options.find(o => o.value === value)?.target
1177  const change: ((u: UsageState) => UsageState) | undefined =
1178    value === 'lift' && question.kind === 'hold'
1179      ? ({ asked: _gone, ...u }) => ({ ...u, holdLift: now + HOLD_LIFT_MS })
1180      : (value === 'extend' || value === 'raise') && target !== undefined
1181        ? ({ asked: _gone, ...u }) => ({ ...u, override: { target, until: now + (value === 'extend' ? EXTEND_MS : RAISE_MS), ...kindOf(u, now) } })
1182        : undefined
1183  if (change === undefined) return false
1184  await updateUsage($, change)
1185  const speckit = (await $.state.get(SPECKIT)).value
1186  if (speckit !== undefined) await showStatus($, speckit)
1187  return true
1188}
1189
1190/**
1191 * What an answer does, whenever it comes (017). The default (or no answer) is remembered for
1192 * the band. `drop` takes the call out of the queue; `run` takes it out and lets that one
1193 * prompt through once, then tells Claude to send it again; a lift resumes what waits.
1194 */
1195async function answer($: EngineInterface, question: Question, value: string, item?: QueuedAgent): Promise<void> {
1196  await logGovernor($, `→ ${question.options.find(o => o.value === value)?.label ?? value}`)
1197  if (await applyLift($, question, value)) {
1198    await resume($, value === 'lift' ? 'subagents allowed for 1 hour, one at a time' : 'ceiling raised')
1199    return
1200  }
hooks/core/band.ts 148 lines
1// Lays out the band above the prompt (contracts/band.md). Pure: no $.
2// Returns segments, each with the theme role that colors it; the surface turns them
3// into elements. The first form that fits `columns` wins; an id is never cut.
4import { activeMark } from './status-text'
5import { t, type Lang } from './i18n'
6import type { ThemeRole } from './theme'
7import type { Feature, Phase, SpeckitState, Step } from './types'
8
9export type Segment = { key: string; text: string; role: ThemeRole }
10
11const STEPS: readonly Step[] = ['constitution', 'specify', 'clarify', 'plan', 'tasks', 'implement']
12const FEATURE_STEPS: readonly Phase[] = ['specify', 'clarify', 'plan', 'tasks', 'implement']
13const BAR_CELLS = 10
14
15type Mark = '●' | '◐' | '○'
16const ROLE: Readonly<Record<Mark, ThemeRole>> = { '●': 'done', '◐': 'current', '○': 'pending' }
17
18const width = (segments: readonly Segment[]): number => segments.reduce((n, s) => n + [...s.text].length, 0)
19
20export const bandText = (segments: readonly Segment[]): string => segments.map(s => s.text).join('')
21
22const marksOf = (state: SpeckitState, feature: Feature): Record<Step, Mark> => {
23  const isRatified = state.constitution === 'ratified'
24  const index = feature.phase === 'done' ? FEATURE_STEPS.length : FEATURE_STEPS.indexOf(feature.phase)
25  const marks = { constitution: isRatified ? '●' : '◐' } as Record<Step, Mark>
26  FEATURE_STEPS.forEach((step, i) => {
27    marks[step as Step] = !isRatified ? '○' : i < index ? '●' : i === index ? '◐' : '○'
28  })
29  return marks
30}
31
32const gap = (key: string, text = ' '): Segment => ({ key: `gap-${key}`, text, role: 'muted' })
33
34/** The rail, with every label or only the current step's. */
35const rail = (state: SpeckitState, feature: Feature, allLabels: boolean): Segment[] => {
36  const marks = marksOf(state, feature)
37  const running = state.runningSkill?.step
38  // Blocked (042 #13): clarifications left after the plan turn the current step red with a `?`.
39  const blocked = feature.warnings.includes('clarification-after-plan')
40  const out: Segment[] = []
41  STEPS.forEach((step, i) => {
42    const mark = marks[step]
43    const isStuck = blocked && mark === '◐'
44    if (i > 0) out.push(gap(`step-${step}`))
45    if (allLabels || mark === '◐') {
46      out.push({ key: `label-${step}`, text: step, role: isStuck ? 'blocked' : mark === '◐' ? 'current' : 'muted' }, gap(`label-${step}`))
47    }
48    out.push({ key: `mark-${step}`, text: isStuck ? '?' : mark, role: isStuck ? 'blocked' : ROLE[mark] })
49    if (running === step) out.push({ key: `running-${step}`, text: '…', role: 'accent' })
50  })
51  return out
52}
53
54const step = (feature: Feature): Segment => ({
55  key: 'step',
56  text: `${feature.phase === 'done' ? '●' : '◐'} ${feature.phase}`,
57  role: feature.phase === 'done' ? 'done' : 'current',
58})
59
60const progress = (feature: Feature, withBar: boolean): Segment[] => {
61  if (feature.total === 0) return []
62  const filled = Math.floor((feature.done * BAR_CELLS) / feature.total)
63  const percent = Math.floor((feature.done * 100) / feature.total)
64  const bar: Segment[] = withBar
65    ? [
66        gap('bar', '  '),
67        { key: 'bar-fill', text: '█'.repeat(filled), role: 'barFill' },
68        { key: 'bar-empty', text: '░'.repeat(BAR_CELLS - filled), role: 'barEmpty' },
69      ]
70    : []
71  return [...bar, gap('count'), { key: 'count', text: `${feature.done}/${feature.total} ${percent}%`, role: 'text' }]
72}
73
74export type BandDensity = 'full' | 'compact' | 'minimal'
75export type BandExtras = {
76  /** How much the band shows (042 #20). */
77  density?: BandDensity
78  /** The worktree the active feature runs in, when it is another one (042 #18). */
79  worktree?: string
80}
81
82export const bandSegments = (state: SpeckitState, columns: number, extras: BandExtras = {}): Segment[] => {
83  const active = state.active
84  if (!state.present || active === undefined) return []
85  const feature = state.features.find(f => f.dir === active.dir)
86  const id: Segment = { key: 'id', text: activeMark(state), role: 'accent' }
87  // Other features in progress (042 #17) and the worktree the active one runs in (042 #18).
88  const others = state.features.filter(f => f.dir !== active.dir && f.phase !== 'done' && f.phase !== 'abandoned').length
89  const tags: Segment[] = [
90    ...(others === 0 ? [] : [gap('others'), { key: 'others', text: `+${others}`, role: 'muted' as const }]),
91    ...(extras.worktree === undefined ? [] : [gap('worktree'), { key: 'worktree', text: `⑂ ${extras.worktree}`, role: 'muted' as const }]),
92  ]
93  const name: Segment[] = [gap('name'), { key: 'name', text: active.name, role: 'text' }, ...tags]
94  let forms: Segment[][]
95  if (feature === undefined) {
96    forms = [[id]]
97  } else if (feature.phase === 'abandoned') {
98    const label: Segment[] = [gap('abandoned', '  '), { key: 'abandoned', text: 'abandoned', role: 'muted' }]
99    forms = [[id, ...name, ...label], [id, ...label], [id]]
100  } else {
101    const lead = gap('rail', '  ')
102    forms = [
103      [id, ...name, lead, ...rail(state, feature, true), ...progress(feature, true)],
104      [id, ...name, lead, ...rail(state, feature, false), ...progress(feature, true)],
105      [id, lead, ...rail(state, feature, false), ...progress(feature, true)],
106      [id, lead, ...rail(state, feature, false), ...progress(feature, false)],
107      // The compact band (024 #12): the current step and the count, which say more than the rail alone.
108      [id, lead, step(feature), ...progress(feature, false)],
109      [id, lead, ...rail(state, feature, false)],
110      [id, lead, step(feature)],
111      [id],
112    ]
113  }
114  // Step names only from 100 columns; below, the hover cards name the steps (042 #11).
115  if (feature !== undefined && feature.phase !== 'abandoned' && columns < 100) forms = forms.slice(1)
116  // compact starts at the current step and the count; minimal at the id and the step (042 #20).
117  if (feature !== undefined && feature.phase !== 'abandoned' && extras.density === 'compact') forms = forms.slice(-4)
118  if (feature !== undefined && feature.phase !== 'abandoned' && extras.density === 'minimal') forms = forms.slice(-2)
119  return forms.find(form => width(form) <= columns) ?? []
120}
121
122/** The hover card of each rail step (024 #10): what the step is for and how many features are in it. */
123export const stepCards = (features: readonly Feature[], lang: Lang = 'en'): Array<{ step: Step; text: string }> =>
124  STEPS.map(step => {
125    const n = features.filter(f => f.phase === step).length
126    const about = t(lang, `card.${step}`)
127    if (step === 'constitution') return { step, text: `${step}: ${about}` }
128    const count = n === 0 ? t(lang, 'card.none') : n === 1 ? t(lang, 'card.one') : t(lang, 'card.many', { n })
129    return { step, text: `${step}: ${about} · ${count}` }
130  })
131
132/** The rail step a segment belongs to, by its key (`label-plan`, `mark-plan`, `running-plan`). */
133export const stepOf = (segment: Segment): Step | undefined => {
134  const m = /^(?:label|mark|running)-(.+)$/.exec(segment.key)
135  return m !== null && (STEPS as readonly string[]).includes(m[1]!) ? (m[1] as Step) : undefined
136}
137
138/** Why the next command is next (042 #14), shown while the pointer is on its button. */
139export const nextReason = (state: SpeckitState, lang: Lang = 'en'): string | undefined => {
140  const command = state.nextCommand
141  if (command === undefined) return undefined
142  const feature = state.features.find(f => f.dir === state.active?.dir)
143  const step = command.replace(/^\/speckit-/, '')
144  if (step === 'implement' && feature !== undefined) return t(lang, 'reason.implement', { n: feature.total - feature.done })
145  if (step === 'specify' && feature !== undefined && feature.phase !== 'specify') return t(lang, 'reason.specifyNext')
146  return (['constitution', 'specify', 'clarify', 'plan', 'tasks', 'analyze'] as const).includes(step as never) ? t(lang, `reason.${step}` as never) : undefined
147}
148
hooks/core/hint.ts 13 lines
1// The text Astrolabe adds after the engine's prompt hint (FR-007). Pure: no $.
2import type { SpeckitState } from './types'
3
4export const hintTail = (state: SpeckitState, isDraft: boolean): string | undefined => {
5  if (isDraft || !state.present || state.nextCommand === undefined) return undefined
6  const active = state.active
7  const feature = active === undefined ? undefined : state.features.find(f => f.dir === active.dir)
8  const next = `next: ${state.nextCommand}`
9  if (feature?.phase !== 'implement' || feature.total === 0) return next
10  const left = feature.total - feature.done
11  return left > 0 ? `${next} · ${left} ${left === 1 ? 'task' : 'tasks'} left` : next
12}
13
hooks/core/phase-toast.ts 50 lines
1// Decides which phase toasts a reconcile raises (FR-001). Pure: no $.
2import { t, type Lang } from './i18n'
3import type { Feature, Phase } from './types'
4
5export type Toast = { key: string; text: string }
6
7const ORDER: readonly Phase[] = ['specify', 'clarify', 'plan', 'tasks', 'implement', 'done']
8const NEXT: Readonly<Record<string, string>> = {
9  specify: '/speckit-specify',
10  clarify: '/speckit-clarify',
11  plan: '/speckit-plan',
12  tasks: '/speckit-tasks',
13  implement: '/speckit-implement',
14  done: '/speckit-specify',
15}
16
17/**
18 * Compares derived phases with the stored baseline. The first reconcile of a session
19 * (`isBaselined` false) only records; later ones toast each move to a later phase once
20 * per session. The returned baseline always holds the current phases.
21 */
22export const phaseToasts = (
23  features: readonly Feature[],
24  baseline: Readonly<Record<string, Phase>>,
25  toasted: readonly string[],
26  isBaselined: boolean,
27  /** The real next command for a feature (the active one's may be /speckit-analyze). */
28  nextOf: Readonly<Record<string, string>> = {},
29  lang: Lang = 'en',
30): { toasts: Toast[]; baseline: Record<string, Phase>; toasted: string[] } => {
31  // Only listed features stay, so the stored baseline never grows with deleted ones.
32  const next: Record<string, Phase> = {}
33  const toasts: Toast[] = []
34  const seen = [...toasted]
35  for (const f of features) {
36    const before = baseline[f.dir]
37    next[f.dir] = f.phase
38    if (!isBaselined || before === undefined || before === 'abandoned' || f.phase === 'abandoned') continue
39    const key = `${f.dir}:${f.phase}`
40    if (ORDER.indexOf(f.phase) <= ORDER.indexOf(before) || seen.includes(key)) continue
41    seen.push(key)
42    const text =
43      f.phase === 'done'
44        ? t(lang, 'toast.done', { id: f.id, name: f.name, next: NEXT.done! })
45        : t(lang, 'toast.moved', { id: f.id, name: f.name, phase: f.phase, next: nextOf[f.dir] ?? NEXT[f.phase]! })
46    toasts.push({ key, text })
47  }
48  return { toasts, baseline: next, toasted: seen }
49}
50
hooks/core/next-command.ts 31 lines
1// The Spec Kit command that moves the active feature forward (FR-021). Pure: no $.
2import type { ConstitutionState, Feature, Phase } from './types'
3
4export type NextCommandInput = {
5  present: boolean
6  constitution: ConstitutionState
7  active?: Pick<Feature, 'phase' | 'done'>
8  isAnalyzed: boolean
9}
10
11const BY_PHASE: Partial<Record<Phase, string>> = {
12  specify: '/speckit-specify',
13  clarify: '/speckit-clarify',
14  plan: '/speckit-plan',
15  tasks: '/speckit-tasks',
16}
17
18export const nextCommand = (input: NextCommandInput): string | undefined => {
19  if (!input.present) return undefined
20  if (input.constitution !== 'ratified') return '/speckit-constitution'
21  const active = input.active
22  if (active === undefined || active.phase === 'done' || active.phase === 'abandoned') return '/speckit-specify'
23  const byPhase = BY_PHASE[active.phase]
24  if (byPhase !== undefined) return byPhase
25  return active.done === 0 && !input.isAnalyzed ? '/speckit-analyze' : '/speckit-implement'
26}
27
28/** The feature that moved to done between two reads (054 #90), for the retro suggestion. */
29export const justFinished = <F extends Pick<Feature, 'dir' | 'phase'>>(before: readonly F[], after: readonly F[]): F | undefined =>
30  after.find(f => f.phase === 'done' && before.some(b => b.dir === f.dir && b.phase !== 'done'))
31
hooks/core/pane.ts 337 lines
1// What each tab of the /astrolabe pane says (contracts/pane.md). Pure: no $.
2import { t as tr, type Lang, type TextKey } from './i18n'
3import { formatElapsed, cleanTaskText } from './spinner'
4import { parallelTasks } from './extensions'
5import { fileUrl } from './paths'
6import { byPriority, priorityMark, type Priorities, type Priority } from './spec-actions'
7import { parseTasks } from './tasks-parser'
8import { STATUS_ROLE, type ThemeRole } from './theme'
9import type { Feature, SessionMemo, SpeckitState } from './types'
10
11/** A row; `segments`, when given, colour parts of `text` (which stays their join) (052 #12). */
12export type PaneRow = { key: string; text: string; role: ThemeRole; dim?: boolean; bold?: boolean; href?: string; links?: ReadonlyArray<{ label: string; href: string }>; segments?: ReadonlyArray<{ text: string; role: ThemeRole }> }
13
14const BAR_CELLS = 10
15const noSpeckit = (lang: Lang): PaneRow => ({ key: 'none', text: tr(lang, 'pane.noSpeckit'), role: 'muted' })
16
17const width = (text: string): number => [...text].length
18const cut = (text: string, room: number): string =>
19  width(text) <= room ? text : room < 2 ? '' : `${[...text].slice(0, room - 1).join('').trimEnd()}…`
20
21const markOf = (f: Feature): string => (f.phase === 'done' ? '●' : f.phase === 'abandoned' ? '○' : '◐')
22
23/** What a row shows besides the feature: the name column's width and the skill running on it (044). */
24type RowContext = { nameWidth: number; countWidth: number; idWidth?: number; running?: string; priority?: Priority; worktrees?: readonly string[] }
25
26/** One colour per phase, the rail's (054 #71). */
27const PHASE_ROLE: Partial<Record<Feature['phase'], ThemeRole>> = { specify: 'muted', clarify: 'current', plan: 'barFill', tasks: 'accent', implement: 'current', done: 'done' }
28
29/** Below this width the pane is compact (054 #69): one column, no bars. */
30export const COMPACT_COLUMNS = 60
31
32const featureRow = (f: Feature, isActive: boolean, columns: number, ctx: RowContext): PaneRow => {
33  // Not read yet in a large project (040): a mark and no phase until its batch lands.
34  if (f.warnings.includes('loading')) return { key: `feature-${f.id}`, text: `${isActive ? '▸' : ' '} … ${f.id.padEnd(ctx.idWidth ?? 0)} ${f.name}`, role: 'muted', dim: true }
35  // `↑` high, `↓` low in the space before the id (051).
36  // Ids padded to the widest, so every name starts in one column (054 #25).
37  const head = `${isActive ? '▸' : ' '} ${markOf(f)}${priorityMark(ctx.priority)}${f.id.padEnd(ctx.idWidth ?? 0)} `
38  const percent = f.total > 0 ? `${Math.floor((f.done * 100) / f.total)}%`.padStart(4) : ''
39  const count = f.total > 0 ? `${f.done}/${f.total}`.padStart(ctx.countWidth) : ''
40  const filled = f.total > 0 ? Math.floor((f.done * BAR_CELLS) / f.total) : 0
41  const bar = f.total > 0 ? `${'█'.repeat(filled)}${'░'.repeat(BAR_CELLS - filled)}` : ''
42  const role: ThemeRole = isActive ? 'accent' : f.phase === 'done' ? 'done' : f.phase === 'abandoned' ? 'muted' : 'text'
43  const dim = f.phase === 'abandoned' ? { dim: true } : {}
44  // Chips for open questions and checklist items (044 #39).
45  const chips = [
46    f.clarifications !== undefined && f.clarifications > 0 ? `?${f.clarifications}` : '',
47    f.checklist !== undefined && f.checklist.open > 0 && f.phase !== 'done' && f.phase !== 'abandoned' ? `☐${f.checklist.open}` : '',
48  ].filter(c => c !== '')
49  // The worktrees working on this feature (054 #49).
50  const trees = ctx.worktrees === undefined || ctx.worktrees.length === 0 ? '' : `  ⑂ ${ctx.worktrees.join(', ')}`
51  const running = `${chips.length === 0 ? '' : `  ${chips.join(' ')}`}${trees}${ctx.running === undefined ? '' : `  ⟳ ${ctx.running}`}`
52  const name = f.name.padEnd(ctx.nameWidth)
53  const phase = f.phase.padEnd(9)
54  // Columns line up across rows (044); narrower panes drop the bar, then the count, then cut the name.
55  const forms = [
56    `${head}${name}  ${phase}  ${bar.padEnd(BAR_CELLS)}  ${count} ${percent}${running}`,
57    `${head}${name}  ${phase}  ${count} ${percent}${running}`,
58    `${head}${name}  ${phase} ${percent}`,
59  ].map(text => text.trimEnd())
60  // A compact pane under 60 columns never draws bars (054 #69), even when a short name leaves room.
61  const fitting = forms.slice(columns < COMPACT_COLUMNS ? 1 : 0).find(text => width(text) <= columns)
62  // The widest form in colour (052 #12, 054 #71): the phase in its rail colour, the bar in its own.
63  if (fitting !== undefined && fitting === forms[0] && f.total > 0 && f.phase !== 'abandoned') {
64    const segments = [
65      { text: `${head}${name}  `, role },
66      { text: phase, role: PHASE_ROLE[f.phase] ?? role },
67      { text: '  ', role },
68      { text: '█'.repeat(filled), role: 'barFill' as ThemeRole },
69      { text: '░'.repeat(BAR_CELLS - filled), role: 'barEmpty' as ThemeRole },
70      { text: `  ${count} ${percent}${running}`.trimEnd(), role },
71    ]
72    return { key: `feature-${f.id}`, text: fitting, role, ...dim, segments }
73  }
74  if (fitting !== undefined) return { key: `feature-${f.id}`, text: fitting, role, ...dim }
75  const tail = `  ${f.phase}${percent === '' ? '' : ` ${percent.trim()}`}`
76  const cutName = cut(f.name, columns - width(head) - width(tail))
77  const text = cutName === '' ? `${head.trimEnd()}${tail}` : `${head}${cutName}${tail}`
78  return { key: `feature-${f.id}`, text, role, ...dim }
79}
80
81/** Which section a feature belongs to (044): working on it, next up, done, abandoned. */
82export const sectionOf = (f: Pick<Feature, 'phase' | 'dir' | 'done'>, activeDir: string | undefined): 'progress' | 'next' | 'done' | 'abandoned' =>
83  f.phase === 'done' ? 'done' : f.phase === 'abandoned' ? 'abandoned' : f.dir === activeDir || f.done > 0 || f.phase === 'implement' ? 'progress' : 'next'
84
85const WARNING_TEXT = {
86  'feature-json-dangling': 'pane.jsonDangling',
87  'feature-json-malformed': 'pane.jsonMalformed',
88} as const
89
90/** The active feature's gates (054 #56): constitution, clarifications, checklist, tasks, analyze. */
91export const gatesText = (state: Pick<SpeckitState, 'constitution' | 'isAnalyzed'>, f: Pick<Feature, 'clarifications' | 'checklist' | 'total' | 'warnings'>, lang: Lang = 'en'): string => {
92  const mark = (ok: boolean | undefined, n?: number) => (ok === undefined ? '–' : ok ? '✓' : n === undefined ? '✗' : `✗${n}`)
93  const open = f.checklist?.open ?? 0
94  return [
95    `${tr(lang, 'gate.title')}`,
96    `${tr(lang, 'gate.constitution')} ${mark(state.constitution === 'ratified')}`,
97    `${tr(lang, 'gate.clarify')} ${mark((f.clarifications ?? 0) === 0 && !f.warnings.includes('clarification-after-plan'), f.clarifications)}`,
98    `${tr(lang, 'gate.checklist')} ${mark(f.checklist === undefined ? undefined : open === 0, open)}`,
99    `${tr(lang, 'gate.tasks')} ${mark(f.total > 0 ? true : undefined)}`,
100    `${tr(lang, 'gate.analyze')} ${mark(state.isAnalyzed ? true : undefined)}`,
101  ].join('  ')
102}
103
104/** A feature's own warnings, drawn under its row (052 #14). */
105const featureWarnings = (f: Feature, lang: Lang): PaneRow[] => {
106  const rows: PaneRow[] = []
107  // Blocking first: a file that cannot be read, then a missing spec, then open questions and checklists (054 #29).
108  for (const file of ['spec', 'tasks'] as const) {
109    if (f.warnings.includes(`unreadable-${file}`)) {
110      rows.push({ key: `warning-${f.id}-${file}`, text: tr(lang, 'pane.unreadable', { id: f.id, file: `${file}.md` }), role: STATUS_ROLE.error })
111    }
112  }
113  if (f.warnings.includes('no-spec')) rows.push({ key: `nospec-${f.id}`, text: tr(lang, 'pane.noSpec', { id: f.id, dir: f.dir }), role: 'current' })
114  if (f.warnings.includes('clarification-after-plan')) {
115    rows.push({ key: `warning-${f.id}`, text: tr(lang, 'pane.clarifyLeft', { id: f.id }), role: 'current' })
116  }
117  if (f.clarifications !== undefined && !f.warnings.includes('clarification-after-plan')) {
118    rows.push({ key: `questions-${f.id}`, text: tr(lang, 'pane.questions', { id: f.id, n: f.clarifications }), role: 'current' })
119  }
120  if (f.checklist !== undefined && f.checklist.open > 0 && f.phase !== 'done' && f.phase !== 'abandoned') {
121    rows.push({ key: `checklist-${f.id}`, text: tr(lang, 'pane.checklist', { id: f.id, open: f.checklist.open, total: f.checklist.total }), role: 'current' })
122  }
123  return rows
124}
125
126export const specsRows = (
127  state: SpeckitState,
128  columns: number,
129  lang: Lang = 'en',
130  priorities: Priorities = {},
131  /** Feature id to the worktrees working on it (054 #49). */
132  worktrees: Readonly<Record<string, readonly string[]>> = {},
133  /** Fold Done and Abandoned past three features to their heading (052 #10, #11). */
134  fold = false,
135): PaneRow[] => {
136  if (!state.present) return [noSpeckit(lang)]
137  if (state.features.length === 0) return [{ key: 'empty', text: tr(lang, 'pane.noFeatures'), role: 'muted' }]
138  const activeDir = state.active?.dir
139  const ctx = {
140    idWidth: Math.max(0, ...state.features.map(f => width(f.id))),
141    nameWidth: Math.min(24, Math.max(...state.features.map(f => width(f.name)))),
142    countWidth: Math.max(0, ...state.features.filter(f => f.total > 0).map(f => width(`${f.done}/${f.total}`))),
143  }
144  const running = state.runningSkill?.name
145  const rows: PaneRow[] = []
146  const shown = new Set<string>()
147  // Sections by status (044), each only when it has features.
148  for (const section of ['progress', 'next', 'done', 'abandoned'] as const) {
149    // Within a section, high priority first and low last (051).
150    const inSection = byPriority(state.features.filter(f => sectionOf(f, activeDir) === section), priorities)
151    if (inSection.length === 0) continue
152    const folded = fold && (section === 'done' || section === 'abandoned') && inSection.length > 3 && !inSection.some(f => f.dir === activeDir)
153    rows.push({ key: `section-${section}`, text: `${tr(lang, `pane.section.${section}`)} (${inSection.length})${folded ? ` · ${tr(lang, 'pane.folded.section', { status: section })}` : ''}`, role: 'muted', bold: true })
154    if (folded) continue
155    for (const f of inSection) {
156      const row = featureRow(f, activeDir === f.dir, columns - 2, { ...ctx, ...(running !== undefined && f.dir === activeDir ? { running } : {}), ...(priorities[f.id] === undefined ? {} : { priority: priorities[f.id] }), ...(worktrees[f.id] === undefined ? {} : { worktrees: worktrees[f.id] }) })
157      // A link to the feature's spec.md (044 #35).
158      const root = state.root
159      // Its plan.md and tasks.md too, once they exist (054 #77); a quick spec keeps its tasks in spec.md.
160      const hasPlan = f.phase === 'tasks' || f.phase === 'implement' || f.phase === 'done'
161      const hasTasks = f.total > 0 && f.track !== 'quick'
162      const links = root === undefined ? [] : [...(hasPlan ? [{ label: 'plan', href: fileUrl(`${root}/specs/${f.dir}/plan.md`) }] : []), ...(hasTasks ? [{ label: 'tasks', href: fileUrl(`${root}/specs/${f.dir}/tasks.md`) }] : [])]
163      rows.push(root === undefined || f.warnings.includes('loading') ? row : { ...row, href: fileUrl(`${root}/specs/${f.dir}/spec.md`), ...(links.length === 0 ? {} : { links }) })
164      // The active feature's gates under its row (054 #56): each ✓, ✗ with a count, or – not yet.
165      if (activeDir === f.dir && f.phase !== 'done' && f.phase !== 'abandoned' && !f.warnings.includes('loading')) {
166        rows.push({ key: 'gates', text: cut(gatesText(state, f, lang), columns), role: 'muted' })
167      }
168      rows.push(...featureWarnings(f, lang))
169      shown.add(f.dir)
170    }
171  }
172  // One feature in two worktrees: their work will collide (054 #52).
173  for (const [id, trees] of Object.entries(worktrees)) {
174    if (trees.length > 1) rows.push({ key: `warning-worktrees-${id}`, text: tr(lang, 'pane.worktreeClash', { id, list: trees.join(', ') }), role: 'current' })
175  }
176  // feature.json names a finished feature while the branch names another one (044 #34).
177  const active = state.features.find(f => f.dir === activeDir)
178  const onBranch = state.branchFeature === undefined ? undefined : state.features.find(f => f.dir === state.branchFeature)
179  if (state.active?.source === 'feature.json' && active?.phase === 'done' && onBranch !== undefined && onBranch.dir !== activeDir && onBranch.phase !== 'done') {
180    rows.push({ key: 'warning-stale', text: tr(lang, 'pane.stale', { done: `${active.id} ${active.name}`, branch: `${onBranch.id} ${onBranch.name}`, dir: onBranch.dir }), role: 'current' })
181  }
182  if (state.activeWarning !== undefined) {
183    const showing = state.active === undefined ? tr(lang, 'pane.noFeature') : `${state.active.id} (${state.active.source})`
184    rows.push({ key: 'warning-active', text: tr(lang, 'pane.showing', { why: tr(lang, WARNING_TEXT[state.activeWarning]), showing }), role: 'current' })
185  }
186  // Warnings of features in folded sections still show, at the end (052 #14).
187  for (const f of state.features) if (!shown.has(f.dir)) rows.push(...featureWarnings(f, lang))
188  return rows
189}
190
191const elapsed = (ms: number): string => {
192  const minutes = Math.max(0, Math.floor(ms / 60_000))
193  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
194}
195
196export const taskRows = (state: SpeckitState, memo: SessionMemo, rows: number, columns: number, lang: Lang = 'en', now?: number): PaneRow[] => {
197  const active = state.active
198  if (!state.present) return [noSpeckit(lang)]
199  if (active === undefined) return [{ key: 'none', text: tr(lang, 'pane.noActive'), role: 'muted' }]
200  const tasks = state.activeTasks ?? parseTasks(memo.files[active.dir]?.tasks ?? '')
201  if (tasks.length === 0) return [{ key: 'no-tasks', text: tr(lang, 'pane.noTasks'), role: 'muted' }]
202  const open = tasks.filter(t => !t.isDone)
203  // Which feature these are, its phase and its count (052 #17).
204  const phase = state.features.find(f => f.dir === active.dir)?.phase
205  const count = tr(lang, 'pane.count', { done: tasks.length - open.length, total: tasks.length })
206  const out: PaneRow[] = [{ key: 'count', text: cut([`${active.id} ${active.name}`, phase, count].filter(x => x !== undefined).join(' · '), columns), role: 'muted' }]
207  if (open.length === 0) return [...out, { key: 'all-done', text: tr(lang, 'pane.allTicked'), role: 'done' }]
208  // Done tasks folded under one row (045 #41).
209  const done = tasks.filter(t => t.isDone && t.id !== undefined).map(t => t.id!)
210  // The fold row counts what it folds (052 #23).
211  if (done.length > 0) out.push({ key: 'done-folded', text: cut(tr(lang, 'pane.folded', { n: done.length, ids: done.length === 1 ? done[0]! : `${done[0]}…${done.at(-1)}` }), columns), role: 'done', dim: true })
212  const room = Math.max(1, rows - 1)
213  const shown = open.length <= room ? open : open.slice(0, room - 1)
214  // A run of [P] tasks at the head can go to subagents at once (020c #19); the line drops when
215  // brackets already draw a run of two or more (052 #21).
216  const parallel = parallelTasks(tasks)
217  const bracketed = shown.some((t, i) => t.text.includes('[P]') && shown[i + 1]?.text.includes('[P]') === true)
218  if (parallel.length > 0 && !bracketed) out.push({ key: 'parallel', text: tr(lang, 'pane.parallel', { ids: parallel.join(', ') }), role: 'accent' })
219  // [P] tasks (045 #43): a run of them is bracketed, a lone one is marked ⇉.
220  const isP = (i: number) => shown[i]?.text.includes('[P]') === true
221  const groupOf = (i: number): string => {
222    if (!isP(i)) return ''
223    const before = isP(i - 1)
224    const after = isP(i + 1)
225    return before && after ? '│ ' : before ? '└ ' : after ? '┌ ' : '⇉ '
226  }
227  // Ids padded to the widest shown, so the text column lines up (052 #22).
228  const idWidth = Math.max(0, ...shown.map(t => t.id?.length ?? 0))
229  let story: string | undefined
230  // Each task links to its line (054 #78); a quick spec keeps its tasks in spec.md.
231  const taskFile = state.root === undefined ? undefined : `${state.root}/specs/${active.dir}/${state.features.find(f => f.dir === active.dir)?.track === 'quick' ? 'spec.md' : 'tasks.md'}`
232  for (const [index, t] of shown.entries()) {
233    // The user story a run of tasks belongs to (045 #44).
234    if (t.story !== undefined && t.story !== story) {
235      story = t.story
236      // Each story carries its own count (052 #18).
237      const inStory = tasks.filter(x => x.story === t.story)
238      out.push({ key: `story-${t.line ?? index}`, text: cut(`${t.story} · ${inStory.filter(x => x.isDone).length}/${inStory.length}`, columns), role: 'muted' })
239    }
240    // The task being worked on (045 #42): marked, with how long it has run.
241    const current = state.currentTask
242    const isCurrent = current !== undefined && t.id !== undefined && current.id === t.id
243    const ran = isCurrent && now !== undefined && current.startedAt !== undefined ? elapsed(now - current.startedAt) : ''
244    const tail = ran === '' ? '' : `  ⏱ ${ran}`
245    const head = `${isCurrent ? '▸ ' : ''}${groupOf(index)}${t.id === undefined ? '' : `${t.id.padEnd(idWidth)} `}`
246    out.push({ key: `task-${t.id ?? index}`, text: `${head}${cut(cleanTaskText(t.text), columns - width(head) - width(tail))}`.trimEnd() + tail, role: isCurrent ? 'current' : 'text', ...(taskFile === undefined || t.line === undefined ? {} : { href: `${fileUrl(taskFile)}#L${t.line}` }) })
247  }
248  if (shown.length < open.length) out.push({ key: 'more', text: tr(lang, 'pane.more', { n: open.length - shown.length }), role: 'muted' })
249  return out
250}
251
252export const sessionRows = (state: SpeckitState, now: number, lang: Lang = 'en'): PaneRow[] => {
253  if (!state.present) {
254    const roots = state.otherRoots === undefined ? [] : [{ key: 'session-other-roots', text: `${tr(lang, 'session.otherRoots').padEnd(14)}${state.otherRoots.join(', ')} (/astrolabe root <folder>)`, role: 'text' as const }]
255    return [noSpeckit(lang), ...roots]
256  }
257  const task = state.currentTask
258  const none = tr(lang, 'word.none')
259  const pairs: Array<[string, string, string]> = [
260    ['root', tr(lang, 'session.root'), state.root ?? tr(lang, 'word.unknown')],
261    ['constitution', tr(lang, 'session.constitution'), state.constitution],
262    ['active', tr(lang, 'session.active'), state.active === undefined ? none : `${state.active.id} ${state.active.name}`],
263    ['chosen-by', tr(lang, 'session.chosenBy'), state.active?.source ?? none],
264    ['next', tr(lang, 'session.next'), state.nextCommand ?? none],
265    ['running', tr(lang, 'session.running'), state.runningSkill?.name ?? none],
266    ['analyzed', tr(lang, 'session.analyzed'), state.isAnalyzed ? tr(lang, 'word.yes') : tr(lang, 'word.no')],
267    [
268      'current-task',
269      tr(lang, 'session.currentTask'),
270      task === undefined
271        ? none
272        : `${task.id ?? cleanTaskText(task.text)}${task.startedAt === undefined ? '' : ` · ${formatElapsed(now - task.startedAt)}`}`,
273    ],
274  ]
275  if (state.otherRoots !== undefined) pairs.push(['other-roots', tr(lang, 'session.otherRoots'), `${state.otherRoots.join(', ')} (/astrolabe root <folder>)`])
276  if (state.nextHooks !== undefined && state.nextHooks.before.length > 0) pairs.push(['hooks-before', tr(lang, 'session.hooksBefore'), state.nextHooks.before.join(', ')])
277  if (state.nextHooks !== undefined && state.nextHooks.after.length > 0) pairs.push(['hooks-after', tr(lang, 'session.hooksAfter'), state.nextHooks.after.join(', ')])
278  const broken = state.extensionsError
279  if (broken !== undefined) pairs.push(['extensions', tr(lang, 'session.extensions'), tr(lang, 'session.extensionsBroken', { line: broken.line, reason: tr(lang, `ext.${broken.reason}` as TextKey) })])
280  const rows: PaneRow[] = pairs.map(([key, label, value]) => ({ key: `session-${key}`, text: `${label.padEnd(14)}${value}`, role: key === 'extensions' ? 'blocked' : 'text' }))
281  // Each principle under the constitution row, linked to its heading (054 #83).
282  const path = state.root === undefined ? undefined : `${state.root}/.specify/memory/constitution.md`
283  const principles: PaneRow[] = (state.principles ?? []).map((p, i) => ({ key: `session-principle-${i}`, text: `${''.padEnd(14)}${p.name}`, role: 'text', dim: true, ...(path === undefined ? {} : { href: `${fileUrl(path)}#L${p.line}` }) }))
284  const at = rows.findIndex(r => r.key === 'session-constitution') + 1
285  return [...rows.slice(0, at), ...principles, ...rows.slice(at)]
286}
287
288/**
289 * A window over units of known height (038): from `offset`, as many units as fit in `room` rows,
290 * keeping a row for each arrow that says more is above or below. Never empty while units remain.
291 */
292export const windowUnits = (heights: readonly number[], offset: number, room: number): { start: number; end: number } => {
293  const n = heights.length
294  if (heights.reduce((a, b) => a + b, 0) <= room) return { start: 0, end: n }
295  const start = Math.max(0, Math.min(n - 1, Math.floor(offset)))
296  const space = room - (start > 0 ? 1 : 0)
297  let end = start
298  let used = 0
299  while (end < n) {
300    const below = end + 1 < n ? 1 : 0
301    if (used + heights[end]! + below > space) break
302    used += heights[end]!
303    end += 1
304  }
305  return { start, end: Math.max(end, start + 1) }
306}
307
308export type StatusFilter = 'all' | 'progress' | 'next' | 'done' | 'abandoned'
309const STATUS_CYCLE: Readonly<Record<StatusFilter, StatusFilter>> = { all: 'progress', progress: 'next', next: 'done', done: 'abandoned', abandoned: 'all' }
310
311/** The next status the Specs tab's `s` shows (054 #21): all, in progress, next up, done, abandoned. */
312export const nextStatus = (status: StatusFilter | undefined): StatusFilter => STATUS_CYCLE[status ?? 'all']
313
314/**
315 * The features a filter keeps (054 #21): words match the id or name, and `is:done`,
316 * `is:progress`, `is:next` or `is:abandoned` keep one status; `status` does the same from `s`.
317 */
318export const filterFeatures = <F extends Pick<Feature, 'id' | 'name' | 'phase' | 'dir' | 'done'>>(
319  features: readonly F[],
320  filter: string | undefined,
321  activeDir: string | undefined,
322  status: StatusFilter = 'all',
323): F[] => {
324  const tokens = (filter ?? '').trim().toLowerCase().split(/\s+/).filter(x => x !== '')
325  const is = tokens.find(x => x.startsWith('is:'))?.slice(3)
326  const words = tokens.filter(x => !x.startsWith('is:')).join(' ')
327  const wanted = is === 'progress' || is === 'next' || is === 'done' || is === 'abandoned' ? is : status === 'all' ? undefined : status
328  return features.filter(f => (words === '' || `${f.id} ${f.name}`.toLowerCase().includes(words)) && (wanted === undefined || sectionOf(f, activeDir) === wanted))
329}
330
331/** The Help tab's rows: headings in the accent, and a line that ends with an https link opens it (054 #81). */
332export const helpRows = (text: string): PaneRow[] =>
333  text.split('\n').map((line, i) => {
334    const link = /\s(https:\/\/\S+)$/.exec(line)?.[1]
335    return { key: `help-${i}`, text: line, role: i === 0 || !line.startsWith(' ') ? 'accent' : 'text', ...(link === undefined ? {} : { href: link }) }
336  })
337
hooks/core/presets.ts 25 lines
1// Presets are data (Principle X): which places each one draws in. Pure: no $.
2// Spinner, pane and toasts are declared now and used by features 003 to 005.
3
4export type Preset = {
5  status: boolean
6  band: boolean
7  hint: boolean
8  spinner: boolean
9  pane: 'command' | 'auto'
10  toasts: 'none' | 'drift' | 'all'
11}
12
13export type PresetName = 'minimal' | 'compact' | 'full'
14
15export const PRESETS: Readonly<Record<PresetName, Preset>> = {
16  minimal: { status: true, band: false, hint: false, spinner: false, pane: 'command', toasts: 'none' },
17  compact: { status: true, band: true, hint: true, spinner: true, pane: 'command', toasts: 'drift' },
18  full: { status: true, band: true, hint: true, spinner: true, pane: 'auto', toasts: 'all' },
19}
20
21export const presetOf = (options: Readonly<Record<string, unknown>>): Preset => {
22  const name = options['preset']
23  return typeof name === 'string' && name in PRESETS ? PRESETS[name as PresetName] : PRESETS.compact
24}
25
hooks/core/spinner.ts 47 lines
1// The text the spinner adds after its word while Claude works on the active feature
2// (contracts/spinner.md). Pure: no $.
3import type { SessionMemo, SpeckitState } from './types'
4
5const DEFAULT_BUDGET = 80
6const ENGINE_COLUMNS = 40
7
8const width = (text: string): number => [...text].length
9
10export const cleanTaskText = (text: string): string =>
11  text
12    .replace(/\[(P|US\d+)\]/g, '')
13    .replace(/`/g, '')
14    .replace(/\s+/g, ' ')
15    .trim()
16
17export const formatElapsed = (ms: number): string => {
18  const seconds = Math.max(0, Math.floor(ms / 1000))
19  if (seconds < 60) return `${seconds}s`
20  const minutes = Math.floor(seconds / 60)
21  if (minutes < 60) return `${minutes}m`
22  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
23}
24
25/** Whether this turn worked on the active feature: speckit-implement ran, or a tool touched it. */
26const isWorkingOnActive = (state: SpeckitState, memo: SessionMemo): boolean =>
27  memo.runningSkill?.step === 'implement' || (state.active !== undefined && memo.touched.includes(state.active.dir))
28
29export const spinnerSuffix = (state: SpeckitState, memo: SessionMemo, now: number, columns?: number): string | undefined => {
30  const task = state.currentTask
31  if (!state.present || state.active === undefined || task === undefined || !(state.isWorkingOnActive ?? isWorkingOnActive(state, memo))) return undefined
32  const budget = columns === undefined ? DEFAULT_BUDGET : columns - ENGINE_COLUMNS
33  const head = task.id === undefined ? '… ' : `… ${task.id} · `
34  const tail = task.startedAt === undefined ? '' : ` · ${formatElapsed(now - task.startedAt)}`
35  const text = cleanTaskText(task.text)
36  if (text === '') {
37    const bare = task.id === undefined ? undefined : `… ${task.id}${tail}`
38    return bare !== undefined && width(bare) <= budget ? bare : undefined
39  }
40  const full = `${head}${text}${tail}`
41  if (width(full) <= budget) return full
42  const room = budget - width(head) - width(tail)
43  if (room >= 2) return `${head}${[...text].slice(0, room - 1).join('').trimEnd()}…${tail}`
44  const bare = task.id === undefined ? undefined : `… ${task.id}${tail}`
45  return bare !== undefined && width(bare) <= budget ? bare : undefined
46}
47
hooks/core/updates.ts 119 lines
1// Update notices (spec 007): parse what the tools report and decide what is newer. Pure: no $.
2import type { UpdateItem } from './types'
3
4export type StoredUpdates = { checkedOn: string; items: UpdateItem[] }
5
6const VERSION = /\d+(?:\.\d+)+/g
7
8export const parseGstackCheck = (stdout: string): UpdateItem | undefined => {
9  const m = /UPGRADE_AVAILABLE\s+(\S+)\s+(\S+)/.exec(stdout)
10  return m === null ? undefined : { id: 'gstack', installed: m[1] ?? '', latest: m[2] ?? '' }
11}
12
13/** `specify self check`: "Up to date: X" means none; otherwise the first two versions named. */
14export const parseSelfCheck = (stdout: string): UpdateItem | undefined => {
15  if (/up to date/i.test(stdout)) return undefined
16  const [installed, latest] = stdout.match(VERSION) ?? []
17  if (installed === undefined || latest === undefined || compareVersions(latest, installed) <= 0) return undefined
18  return { id: 'specify', installed, latest }
19}
20
21export const parseCliVersion = (stdout: string): string | undefined => /CLI Version\s+(\d+(?:\.\d+)+)/.exec(stdout)?.[1]
22
23export const compareVersions = (a: string, b: string): number => {
24  const parts = (v: string) => v.replace(/^v/, '').split('.').map(n => Number.parseInt(n, 10) || 0)
25  const [x, y] = [parts(a), parts(b)]
26  for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
27    const d = (x[i] ?? 0) - (y[i] ?? 0)
28    if (d !== 0) return d < 0 ? -1 : 1
29  }
30  return 0
31}
32
33const versionField = (json: string | undefined, field: string): string | undefined => {
34  if (json === undefined) return undefined
35  try {
36    const value = (JSON.parse(json) as Record<string, unknown>)[field]
37    return typeof value === 'string' ? value.replace(/^v/, '') : undefined
38  } catch {
39    return undefined
40  }
41}
42
43/** Astrolabe's installed version against the body of GitHub's releases/latest. */
44export const astrolabeUpdate = (installed: string, latestReleaseJson: string): UpdateItem | undefined => {
45  const latest = versionField(latestReleaseJson, 'tag_name')
46  return latest !== undefined && compareVersions(latest, installed) > 0 ? { id: 'astrolabe', installed, latest } : undefined
47}
48
49/** This project's Spec Kit skills (manifest version) against the installed CLI. */
50export const skillsUpdate = (manifestJson: string | undefined, cliVersion: string | undefined): UpdateItem | undefined => {
51  const installed = versionField(manifestJson, 'version')
52  if (installed === undefined || cliVersion === undefined || compareVersions(cliVersion, installed) <= 0) return undefined
53  return { id: 'speckit-skills', installed, latest: cliVersion }
54}
55
56/** The project's Spec Kit skills against the CLI (054 #98): both versions, and which is behind; undefined when one is unknown. */
57export const skillsVersusCli = (manifestJson: string | undefined, cliVersion: string | undefined): { skills: string; cli: string; behind: 'skills' | 'cli' | 'none' } | undefined => {
58  const skills = versionField(manifestJson, 'version')
59  if (skills === undefined || cliVersion === undefined) return undefined
60  const order = compareVersions(skills, cliVersion)
61  return { skills, cli: cliVersion, behind: order < 0 ? 'skills' : order > 0 ? 'cli' : 'none' }
62}
63
64export const localDay = (ms: number): string => {
65  const d = new Date(ms)
66  const pad = (n: number) => String(n).padStart(2, '0')
67  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
68}
69
70export const isDue = (stored: StoredUpdates | undefined, today: string): boolean => stored?.checkedOn !== today
71
72const NAMES: Readonly<Record<UpdateItem['id'], string>> = {
73  gstack: 'gstack',
74  specify: 'specify',
75  'speckit-skills': 'Spec Kit skills',
76  astrolabe: 'astrolabe',
77}
78
79export const updateLabel = (item: UpdateItem, isConfirming: boolean): string =>
80  isConfirming && item.id === 'speckit-skills' ? 'confirm: rewrite .claude/skills/speckit-*' : `${NAMES[item.id]} ${item.latest}`
81
82export const isStoredUpdates = (value: unknown): value is StoredUpdates =>
83  typeof value === 'object' &&
84  value !== null &&
85  typeof (value as StoredUpdates).checkedOn === 'string' &&
86  Array.isArray((value as StoredUpdates).items)
87
88/** The first non-empty line of a process's output, for a failure toast. */
89export const firstLine = (text: string): string => text.split(/\r?\n/).map(l => l.trim()).find(l => l !== '') ?? 'no output'
90
91const ROW_PREFIX = 'updates: '
92
93/**
94 * How many update Buttons fit a band `columns` wide. The terminal draws a Button as
95 * `[ label ]`; buttons that do not fit are summed up as ` +N`. One always shows.
96 */
97export const fitUpdateButtons = (labels: readonly string[], columns: number): { shown: number; more: number } => {
98  const widthOf = (label: string) => [...label].length + 4
99  let used = [...ROW_PREFIX].length
100  let shown = 0
101  for (const [i, label] of labels.entries()) {
102    const rest = labels.length - i - 1
103    const tail = rest > 0 ? ` +${rest}`.length : 0
104    if (shown > 0 && used + widthOf(label) + tail > columns) break
105    used += widthOf(label)
106    shown += 1
107  }
108  return { shown, more: labels.length - shown }
109}
110
111/** Where a new version's release notes are (054 #82); undefined when there is no known page. */
112export const releaseNotesUrl = (item: Pick<UpdateItem, 'id' | 'latest'>): string | undefined => {
113  const tag = `v${item.latest.replace(/^v/, '')}`
114  if (item.latest === '') return undefined
115  if (item.id === 'astrolabe') return `https://github.com/jonyfs/astrolabe/releases/tag/${tag}`
116  if (item.id === 'specify' || item.id === 'speckit-skills') return `https://github.com/github/spec-kit/releases/tag/${tag}`
117  return undefined
118}
119
hooks/core/constitution.ts 24 lines
1// Tells a ratified constitution from Spec Kit's template. Pure: no $.
2import type { ConstitutionState } from './types'
3
4const PLACEHOLDER = /\[[A-Z][A-Z0-9_]+\]/
5
6export const classifyConstitution = (text: string | undefined): ConstitutionState => {
7  if (text === undefined) return 'missing'
8  return PLACEHOLDER.test(text) ? 'template' : 'ratified'
9}
10
11/** The `###` headings under `## Core Principles` with their 1-based line, at most 22 (054 #83). */
12export const principleHeadings = (text: string): Array<{ name: string; line: number }> => {
13  const lines = text.split(/\r?\n/)
14  const start = lines.findIndex(l => /^##\s+Core Principles\s*$/i.test(l.trim()))
15  if (start < 0) return []
16  const out: Array<{ name: string; line: number }> = []
17  for (let i = start + 1; i < lines.length && out.length < 22; i += 1) {
18    const l = lines[i]!.trim()
19    if (/^##\s/.test(l)) break
20    if (/^###\s/.test(l)) out.push({ name: l.replace(/^###\s+/, ''), line: i + 1 })
21  }
22  return out
23}
24
hooks/core/paths.ts 91 lines
1// Path helpers that treat POSIX and Windows spellings alike. Pure: no $.
2
3const DRIVE_ROOT = /^[a-z]:\/$/
4const FEATURE_DIR = /^\d{3}-.+$/
5
6/** Forward slashes, `.` and `..` resolved, no repeated or trailing slash, lowercase drive letter. */
7export const normalizePath = (path: string): string => {
8  let p = path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, d: string) => `${d.toLowerCase()}:`)
9  // A UNC path (\\server\share\...) keeps its leading // and its server/share as the root.
10  const isUnc = /^\/\/[^/]/.test(p)
11  const isAbsolute = p.startsWith('/')
12  const parts: string[] = []
13  for (const part of p.split('/')) {
14    if (part === '' || part === '.') continue
15    const last = parts.at(-1)
16    const isDrive = parts.length === 1 && /^[a-z]:$/.test(last ?? '')
17    const isShareRoot = isUnc && parts.length <= 2
18    if (part === '..' && last !== undefined && last !== '..' && !isDrive && !isShareRoot) parts.pop()
19    else if (part === '..' && (isAbsolute || isDrive)) continue
20    else parts.push(part)
21  }
22  p = (isUnc ? '//' : isAbsolute ? '/' : '') + parts.join('/')
23  if (/^[a-z]:$/.test(p)) p += '/'
24  return p === '' ? '.' : p
25}
26
27export const isWindowsPath = (path: string): boolean => /^[a-z]:\//i.test(normalizePath(path))
28
29export const isAbsolutePath = (path: string): boolean => {
30  const p = normalizePath(path)
31  return p.startsWith('/') || /^[a-z]:\//.test(p)
32}
33
34export const joinPath = (base: string, ...parts: string[]): string => {
35  // Trim the base's trailing separator first, so joining onto `/` never makes `//` (UNC).
36  const head = normalizePath(base).replace(/\/+$/, '')
37  return normalizePath([head === '' ? '' : head, ...parts].join('/') || '/')
38}
39
40/** The parent directory, or undefined at a root (`/`, `c:/`). */
41export const parentDir = (path: string): string | undefined => {
42  const p = normalizePath(path)
43  if (p === '/' || DRIVE_ROOT.test(p) || /^\/\/[^/]+(\/[^/]+)?$/.test(p)) return undefined
44  const cut = p.lastIndexOf('/')
45  if (cut < 0) return undefined
46  const parent = p.slice(0, cut)
47  if (parent === '') return '/'
48  return /^[a-z]:$/.test(parent) ? `${parent}/` : parent
49}
50
51/**
52 * `path` relative to `root`, or undefined when it lies outside. A relative `path` is
53 * taken as relative to the root already. Windows roots compare case-insensitively.
54 */
55export const relativeTo = (root: string, path: string): string | undefined => {
56  const p = normalizePath(path)
57  if (!isAbsolutePath(p)) return p.replace(/^\.\//, '')
58  const r = normalizePath(root)
59  const fold = isWindowsPath(r) ? (s: string) => s.toLowerCase() : (s: string) => s
60  if (fold(p) === fold(r)) return ''
61  const prefix = r.endsWith('/') ? r : `${r}/`
62  return fold(p).startsWith(fold(prefix)) ? p.slice(prefix.length) : undefined
63}
64
65/** Which feature directory under `specs/` a path points into, and the file inside it. */
66export const specsLocation = (root: string, path: string): { dir: string; file: string } | undefined => {
67  const rel = relativeTo(root, path)
68  if (rel === undefined) return undefined
69  const [specs, dir, ...rest] = rel.split('/')
70  if (specs === undefined || specs.toLowerCase() !== 'specs' || dir === undefined || !FEATURE_DIR.test(dir)) {
71    return undefined
72  }
73  return { dir, file: rest.join('/') }
74}
75
76export const featureDirOf = (root: string, path: string): string | undefined => specsLocation(root, path)?.dir
77
78/**
79 * A `file:` URL for a local path, each segment percent-encoded on its own: a folder named
80 * with `#`, `?`, `%` or `)` stays part of the path instead of ending it, in a link or in
81 * Markdown (security review of 044). A Windows drive letter keeps its colon.
82 */
83export const fileUrl = (path: string): string => {
84  const segments = normalizePath(path).split('/')
85  const encoded = segments.map((segment, i) =>
86    i <= 1 && /^[a-z]:$/i.test(segment) ? segment : encodeURIComponent(segment).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`),
87  )
88  const joined = encoded.join('/')
89  return `file://${joined.startsWith('/') ? '' : '/'}${joined}`
90}
91
hooks/core/spec-actions.ts 118 lines
1// Acting on a spec from the pane (spec 051): priorities, the deep review's prompt, the gstack
2// skills offered. Pure: no $.
3import type { Feature } from './types'
4
5export type Priority = 'high' | 'normal' | 'low'
6export type Priorities = Readonly<Record<string, Priority>>
7
8const RANK: Readonly<Record<Priority, number>> = { high: 0, normal: 1, low: 2 }
9const CYCLE: Readonly<Record<Priority, Priority>> = { normal: 'high', high: 'low', low: 'normal' }
10
11/** `priority 002 high` → the feature id and the level; undefined when it does not parse. */
12export const parsePriority = (args: string): { id: string; level: Priority } | undefined => {
13  const m = /^priority\s+(\d{1,4})\s+(high|normal|low)$/i.exec(args.trim())
14  return m === null ? undefined : { id: m[1]!.padStart(3, '0'), level: m[2]!.toLowerCase() as Priority }
15}
16
17/** The next level when `p` is pressed: normal, high, low, normal. */
18export const nextPriority = (level: Priority | undefined): Priority => CYCLE[level ?? 'normal']
19
20/** A map with one feature's level set; `normal` is the absence of an entry. */
21export const withPriority = (map: Priorities, id: string, level: Priority): Record<string, Priority> => {
22  const { [id]: _old, ...rest } = map
23  return level === 'normal' ? rest : { ...rest, [id]: level }
24}
25
26/** Features by priority, keeping their order within a level (a stable sort). */
27export const byPriority = <F extends Pick<Feature, 'id'>>(features: readonly F[], map: Priorities): F[] =>
28  features
29    .map((f, i) => ({ f, i }))
30    .sort((a, b) => RANK[map[a.f.id] ?? 'normal'] - RANK[map[b.f.id] ?? 'normal'] || a.i - b.i)
31    .map(x => x.f)
32
33/** The mark a priority draws before the id: `↑` high, `↓` low, nothing for normal. */
34export const priorityMark = (level: Priority | undefined): string => (level === 'high' ? '↑' : level === 'low' ? '↓' : ' ')
35
36/** The model and effort the deep review asks for (051 T003): a more expensive model than the session's helpers. */
37export const REVIEW_MODEL = { model: 'opus', effort: 'xhigh' as const, maxTokens: 4000 }
38
39/** The deep review's prompt: the spec's files and the constitution's principle names. */
40export const reviewPrompt = (feature: { id: string; name: string }, files: { spec: string; plan: string; tasks: string }, principles: readonly string[]): string =>
41  [
42    `Review the Spec Kit feature ${feature.id} ${feature.name}. List at most 10 findings, most serious first, one line each:`,
43    'what is missing, ambiguous, inconsistent between spec, plan and tasks, untestable, or risky. Name the file and section.',
44    'Plain text, no headings, no preamble. If there is nothing to fix, say so in one line.',
45    principles.length === 0 ? '' : `The project's principles: ${principles.join('; ')}.`,
46    `--- spec.md\n${files.spec.slice(0, 24_000)}`,
47    files.plan === '' ? '' : `--- plan.md\n${files.plan.slice(0, 16_000)}`,
48    files.tasks === '' ? '' : `--- tasks.md\n${files.tasks.slice(0, 12_000)}`,
49  ]
50    .filter(line => line !== '')
51    .join('\n')
52
53/** The gstack skills offered on the active feature (051 T004), in the order drawn. */
54export const GSTACK_SKILLS = ['investigate', 'review', 'health', 'qa-only', 'retro'] as const
55
56/** Each option's default from plugin.json's userConfig, keyed `astrolabe.<field>` (054 #63). */
57export const optionDefaults = (pluginJson: string): Record<string, string | number | boolean> => {
58  try {
59    const parsed = JSON.parse(pluginJson) as { userConfig?: Record<string, { default?: unknown }> }
60    const out: Record<string, string | number | boolean> = {}
61    for (const [field, spec] of Object.entries(parsed.userConfig ?? {})) {
62      const value = spec.default
63      if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') out[`astrolabe.${field}`] = value
64    }
65    return out
66  } catch {
67    return {}
68  }
69}
70
71/** The mark before a Config row (052 #39): ● a change not saved yet, • a saved value off its default. */
72export const configMark = (changed: boolean, value: unknown, defaults: Readonly<Record<string, unknown>>, key: string): string =>
73  changed ? '● ' : key in defaults && defaults[key] !== value ? '• ' : '  '
74
75/** The Config tab's groups, in order (054 #64); an option not listed goes to the last one. */
76export const OPTION_GROUPS = {
77  display: ['preset', 'flavor', 'icons', 'language', 'accessible', 'footerIn', 'bandDensity', 'images'],
78  governor: ['governUsage', 'askOnLimit'],
79  claude: ['claudeContext', 'skillModels', 'humanize', 'terse', 'featureSummary'],
80  integrations: ['checkUpdates', 'autoReload', 'pullRequest'],
81} as const
82
83export type OptionGroup = keyof typeof OPTION_GROUPS
84
85/** The group a `/config` key (`astrolabe.<field>` or `<field>`) belongs to. */
86export const optionGroup = (key: string): OptionGroup => {
87  const field = key.replace(/^astrolabe\./, '')
88  for (const [group, fields] of Object.entries(OPTION_GROUPS) as Array<[OptionGroup, readonly string[]]>) if (fields.includes(field)) return group
89  return 'integrations'
90}
91
92/** The prompt that sends a run of [P] tasks to subagents at once (054 #89); the person presses it. */
93export const parallelPrompt = (feature: { id: string; name: string; dir: string }, tasks: ReadonlyArray<{ id?: string; text: string }>): string =>
94  [
95    `These tasks of Spec Kit feature ${feature.id} ${feature.name} are marked [P]: they touch different files and can run at once.`,
96    'Dispatch each one to its own subagent with the Agent tool, all in one message so they run in parallel.',
97    `Give each subagent its task, specs/${feature.dir}/plan.md and the files the task names; tell it to change only those files.`,
98    `When they are done, tick each finished task in specs/${feature.dir}/tasks.md and report what each one changed.`,
99    ...tasks.map(t => `- ${t.id === undefined ? '' : `${t.id} `}${t.text}`),
100  ].join('\n')
101
102/** The files a task names (054 #88): backticked paths and words with a slash or a file extension. */
103export const taskFiles = (text: string): string[] => {
104  const found = [
105    ...[...text.matchAll(/`([^`\s]+)`/g)].map(m => m[1]!),
106    ...[...text.matchAll(/(?:^|[\s(])((?:[\w.-]+\/)+[\w.-]+|[\w-]+\.(?:ts|tsx|js|mjs|json|md|sh|ps1|py|yml|yaml|toml))(?=[\s),;:]|$)/g)].map(m => m[1]!),
107  ].filter(f => /[/.]/.test(f) && !/^\d+(\.\d+)*$/.test(f))
108  return [...new Set(found)]
109}
110
111/** The focus-mode clause of the context line (054 #88). */
112export const focusNote = (task: { id?: string; text: string } | undefined): string =>
113  task === undefined
114    ? 'focus mode is on: change only what the current task needs'
115    : taskFiles(task.text).length === 0
116      ? `focus mode is on: change only what task ${task.id ?? ''} needs and no other file`.replace('  ', ' ')
117      : `focus mode is on: change only ${taskFiles(task.text).join(', ')}`
118