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…

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.
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.shin the demo project docs/demo/ (see How the images are made).
| Where | What Astrolabe draws | Section |
|---|---|---|
| Above the prompt | The active feature, the six Spec Kit steps, a progress bar, and buttons for updates | The band |
| Under the prompt | ◆ 002 · implement 45% · 5h 42%: feature, phase, progress, usage window | The status entry |
| The hint line | next: /speckit-implement · 11 tasks left | The prompt hint |
| The spinner | … T011 · Show an empty-cart message · 1s while Claude works on the feature | The spinner |
/astrolabe | A pane with Specs, Tasks and Session tabs | The pane |
| Toasts | A task ticked with no code edited; a phase finished | Toasts |
| Tool calls | New subagents capped or held, the session paused near the limit | Usage governance |
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.
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:
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):
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.
claude plugin update astrolabe@astrolabe
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
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.
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:
| Part | Example | What 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 id | 001 | The 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 | ~002 | The 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. |
| Phase | implement | The first Spec Kit step this feature has not finished. See Phases. |
| Percentage | 87% | 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 high | The 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.5 | The Spec Kit or gstack skill running now, with the model skillModels picked for it. |
| Git | · main ↑2 3 | The 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 | · 1h05m | How long the session has run, from its first minute on. |
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 showed | In Astrolabe |
|---|---|
| Directory, repository, branch, ahead and behind, changed files, stashes, worktree | The footer's git part (the directory is the project you opened) |
| Model, effort, context window | The footer |
| 5-hour and 7-day windows with their resets | The footer, and the governor acts on them |
| Session duration | The footer (Astrolabe shows no cost: on a subscription it means nothing) |
| Burn rate and projection | The Dashboard tab |
| Todo progress, Claude working or idle | The band, the spinner and the Tasks tab |
| Skills in use | The band and the prompt hint show the running Spec Kit skill |
| Pull request and last CI run | The footer's git part with the pullRequest option on |
| Prompt cache timer | A toast 30 seconds before the cache goes cold (see Toasts) |
| Vim mode, rtk savings | Left 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:
| Entry | When |
|---|---|
◆ no Spec Kit | No 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-specify | Spec 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-constitution | Same, and the constitution is missing or still the unfilled template. |
◆ 003 · abandoned | feature.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. |
The phase is read from the files on disk, top to bottom; the first rule that matches wins.
| Rule | Phase | What to run next |
|---|---|---|
spec.md front matter says status: abandoned | abandoned | /speckit-specify for a new feature |
spec.md front matter says status: done | done | /speckit-specify |
No spec.md | specify | /speckit-specify |
Front matter says track: quick | implement | /speckit-implement, until you set status: done |
No plan.md, and spec.md still has [NEEDS CLARIFICATION | clarify | /speckit-clarify |
No plan.md | plan | /speckit-plan |
No tasks.md, or no tasks in it | tasks | /speckit-tasks |
| Some tasks open | implement | /speckit-analyze before the first tick in a session, then /speckit-implement |
All ticked, front matter says status: active | implement (100%) | Set status: done when you agree it is finished |
| All ticked | done | /speckit-specify |
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.
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%
| Part | Example | What it means |
|---|---|---|
| Id and name | ◆ 002 band-hint | The active feature, from specs/002-band-hint/. A ~ before the id means the feature was guessed, as in the status entry. |
| The rail | constitution ● 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 mark | plan ◐… | 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 count | 9/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 buttons | updates: [ 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:
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.
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:
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:
| Part | What it means |
|---|---|
T011 | The id of the first open task in the active feature's tasks.md. |
| The text | The task's text without the [P] and [US1] markers or backticks, cut with … when the terminal is narrow. The id is never cut. |
1s | How 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.
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).
/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).
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.
Seven options appear in Claude Code's config menu (/config, then Astrolabe). Changing one reloads the mod right away.
| Option | Values | Default | What 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
hooks/register.tsx 2660 lines1// 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 lines1// 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}
148hooks/core/hint.ts 13 lines1// 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}
13hooks/core/phase-toast.ts 50 lines1// 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}
50hooks/core/next-command.ts 31 lines1// 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'))
31hooks/core/pane.ts 337 lines1// 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 })
337hooks/core/presets.ts 25 lines1// 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}
25hooks/core/spinner.ts 47 lines1// 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}
47hooks/core/updates.ts 119 lines1// 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}
119hooks/core/constitution.ts 24 lines1// 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}
24hooks/core/paths.ts 91 lines1// 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}
91hooks/core/spec-actions.ts 118 lines1// 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